diff --git a/.evergreen/.evg.yml b/.evergreen/.evg.yml index dc843379d2d..d2edb11d2af 100644 --- a/.evergreen/.evg.yml +++ b/.evergreen/.evg.yml @@ -148,7 +148,9 @@ functions: params: script: | ${PREPARE_SHELL} - REQUIRE_API_VERSION=${REQUIRE_API_VERSION} LOAD_BALANCER=${LOAD_BALANCER} MONGODB_VERSION=${VERSION} TOPOLOGY=${TOPOLOGY} AUTH=${AUTH} SSL=${SSL} STORAGE_ENGINE=${STORAGE_ENGINE} ORCHESTRATION_FILE=${ORCHESTRATION_FILE} SKIP_LEGACY_SHELL=${SKIP_LEGACY_SHELL} sh ${DRIVERS_TOOLS}/.evergreen/run-orchestration.sh + REQUIRE_API_VERSION=${REQUIRE_API_VERSION} LOAD_BALANCER=${LOAD_BALANCER} MONGODB_VERSION=${VERSION} TOPOLOGY=${TOPOLOGY} \ + AUTH=${AUTH} SSL=${SSL} STORAGE_ENGINE=${STORAGE_ENGINE} ORCHESTRATION_FILE=${ORCHESTRATION_FILE} \ + bash ${DRIVERS_TOOLS}/.evergreen/run-orchestration.sh # run-orchestration generates expansion file with the MONGODB_URI for the cluster - command: expansions.update params: @@ -158,12 +160,11 @@ functions: - command: shell.exec params: script: | - DRIVERS_TOOLS="${DRIVERS_TOOLS}" sh ${DRIVERS_TOOLS}/.evergreen/atlas_data_lake/build-mongohouse-local.sh + DRIVERS_TOOLS="${DRIVERS_TOOLS}" bash ${DRIVERS_TOOLS}/.evergreen/atlas_data_lake/pull-mongohouse-image.sh - command: shell.exec params: - background: true script: | - DRIVERS_TOOLS="${DRIVERS_TOOLS}" sh ${DRIVERS_TOOLS}/.evergreen/atlas_data_lake/run-mongohouse-local.sh + DRIVERS_TOOLS="${DRIVERS_TOOLS}" bash ${DRIVERS_TOOLS}/.evergreen/atlas_data_lake/run-mongohouse-image.sh "run load-balancer": - command: shell.exec @@ -343,241 +344,108 @@ functions: JAVA_VERSION="8" MONGODB_URI="${plain_auth_mongodb_uri}" .evergreen/run-plain-auth-test.sh "add aws auth variables to file": + - command: ec2.assume_role + params: + role_arn: ${aws_test_secrets_role} - command: shell.exec type: test params: + include_expansions_in_env: [ "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN" ] + shell: "bash" working_dir: "src" - silent: true script: | - cat < ${DRIVERS_TOOLS}/.evergreen/auth_aws/aws_e2e_setup.json - { - "iam_auth_ecs_account" : "${iam_auth_ecs_account}", - "iam_auth_ecs_secret_access_key" : "${iam_auth_ecs_secret_access_key}", - "iam_auth_ecs_account_arn": "arn:aws:iam::557821124784:user/authtest_fargate_user", - "iam_auth_ecs_cluster": "${iam_auth_ecs_cluster}", - "iam_auth_ecs_task_definition": "${iam_auth_ecs_task_definition}", - "iam_auth_ecs_subnet_a": "${iam_auth_ecs_subnet_a}", - "iam_auth_ecs_subnet_b": "${iam_auth_ecs_subnet_b}", - "iam_auth_ecs_security_group": "${iam_auth_ecs_security_group}", - - "iam_auth_assume_aws_account" : "${iam_auth_assume_aws_account}", - "iam_auth_assume_aws_secret_access_key" : "${iam_auth_assume_aws_secret_access_key}", - "iam_auth_assume_role_name" : "${iam_auth_assume_role_name}", - - "iam_auth_ec2_instance_account" : "${iam_auth_ec2_instance_account}", - "iam_auth_ec2_instance_secret_access_key" : "${iam_auth_ec2_instance_secret_access_key}", - "iam_auth_ec2_instance_profile" : "${iam_auth_ec2_instance_profile}", - - "iam_auth_assume_web_role_name": "${iam_auth_assume_web_role_name}", - "iam_web_identity_issuer": "${iam_web_identity_issuer}", - "iam_web_identity_rsa_key": "${iam_web_identity_rsa_key}", - "iam_web_identity_jwks_uri": "${iam_web_identity_jwks_uri}", - "iam_web_identity_token_file": "${iam_web_identity_token_file}" - } - EOF + ${PREPARE_SHELL} + cd $DRIVERS_TOOLS/.evergreen/auth_aws + ./setup_secrets.sh drivers/aws_auth "run aws auth test with regular aws credentials": - command: shell.exec type: test params: - working_dir: "src" shell: "bash" - script: | - ${PREPARE_SHELL} - cd ${DRIVERS_TOOLS}/.evergreen/auth_aws - . ./activate-authawsvenv.sh - mongo aws_e2e_regular_aws.js - - command: shell.exec - type: test - params: working_dir: "src" - silent: true script: | - cat <<'EOF' > "${PROJECT_DIRECTORY}/prepare_mongodb_aws.sh" - alias urlencode='python -c "import sys, urllib as ul; print ul.quote_plus(sys.argv[1])"' - USER=$(urlencode ${iam_auth_ecs_account}) - PASS=$(urlencode ${iam_auth_ecs_secret_access_key}) - MONGODB_URI="mongodb://$USER:$PASS@localhost" - EOF - JAVA_VERSION=${JAVA_VERSION} PROJECT_DIRECTORY=${PROJECT_DIRECTORY} \ - AWS_CREDENTIAL_PROVIDER=${AWS_CREDENTIAL_PROVIDER} \ - .evergreen/run-mongodb-aws-test.sh + ${PREPARE_SHELL} + JAVA_VERSION=${JAVA_VERSION} AWS_CREDENTIAL_PROVIDER=${AWS_CREDENTIAL_PROVIDER} .evergreen/run-mongodb-aws-test.sh regular "run aws auth test with assume role credentials": - command: shell.exec type: test params: - working_dir: "src" shell: "bash" - script: | - ${PREPARE_SHELL} - cd ${DRIVERS_TOOLS}/.evergreen/auth_aws - . ./activate-authawsvenv.sh - mongo aws_e2e_assume_role.js - - command: shell.exec - type: test - params: working_dir: "src" - silent: true script: | - # DO NOT ECHO WITH XTRACE (which PREPARE_SHELL does) - cat <<'EOF' > "${PROJECT_DIRECTORY}/prepare_mongodb_aws.sh" - alias urlencode='python -c "import sys, urllib as ul; print ul.quote_plus(sys.argv[1])"' - USER=$(jq -r '.AccessKeyId' ${DRIVERS_TOOLS}/.evergreen/auth_aws/creds.json) - USER=$(urlencode $USER) - PASS=$(jq -r '.SecretAccessKey' ${DRIVERS_TOOLS}/.evergreen/auth_aws/creds.json) - PASS=$(urlencode $PASS) - SESSION_TOKEN=$(jq -r '.SessionToken' ${DRIVERS_TOOLS}/.evergreen/auth_aws/creds.json) - SESSION_TOKEN=$(urlencode $SESSION_TOKEN) - MONGODB_URI="mongodb://$USER:$PASS@localhost" - EOF - JAVA_VERSION=${JAVA_VERSION} PROJECT_DIRECTORY=${PROJECT_DIRECTORY} DRIVERS_TOOLS=${DRIVERS_TOOLS} \ - AWS_CREDENTIAL_PROVIDER=${AWS_CREDENTIAL_PROVIDER} \ - .evergreen/run-mongodb-aws-test.sh + ${PREPARE_SHELL} + JAVA_VERSION=${JAVA_VERSION} AWS_CREDENTIAL_PROVIDER=${AWS_CREDENTIAL_PROVIDER} .evergreen/run-mongodb-aws-test.sh assume-role "run aws auth test with aws EC2 credentials": - command: shell.exec type: test params: - working_dir: "src" shell: "bash" - script: | - ${PREPARE_SHELL} - cd ${DRIVERS_TOOLS}/.evergreen/auth_aws - . ./activate-authawsvenv.sh - mongo aws_e2e_ec2.js - - command: shell.exec - type: test - params: working_dir: "src" - shell: "bash" script: | ${PREPARE_SHELL} - # Write an empty prepare_mongodb_aws so no auth environment variables are set. - echo "" > "${PROJECT_DIRECTORY}/prepare_mongodb_aws.sh" - JAVA_VERSION=${JAVA_VERSION} AWS_CREDENTIAL_PROVIDER=${AWS_CREDENTIAL_PROVIDER} .evergreen/run-mongodb-aws-test.sh + if [ "${SKIP_EC2_AUTH_TEST}" = "true" ]; then + echo "This platform does not support the EC2 auth test, skipping..." + exit 0 + fi + JAVA_VERSION=${JAVA_VERSION} AWS_CREDENTIAL_PROVIDER=${AWS_CREDENTIAL_PROVIDER} .evergreen/run-mongodb-aws-test.sh ec2 "run aws auth test with web identity credentials": - command: shell.exec type: test params: - working_dir: "src" - shell: "bash" - script: | - ${PREPARE_SHELL} - cd ${DRIVERS_TOOLS}/.evergreen/auth_aws - . ./activate-authawsvenv.sh - mongo aws_e2e_web_identity.js - - command: shell.exec - type: test - params: - working_dir: "src" shell: "bash" - silent: true - script: | - # DO NOT ECHO WITH XTRACE (which PREPARE_SHELL does) - cat <<'EOF' > "${PROJECT_DIRECTORY}/prepare_mongodb_aws.sh" - export AWS_ROLE_ARN="${iam_auth_assume_web_role_name}" - export AWS_WEB_IDENTITY_TOKEN_FILE="${iam_web_identity_token_file}" - EOF - - command: shell.exec - type: test - params: working_dir: "src" - shell: "bash" script: | ${PREPARE_SHELL} if [ "${AWS_CREDENTIAL_PROVIDER}" = "builtIn" ]; then echo "Built-in AWS credential provider does not support the web identity auth test, skipping..." exit 0 fi - JAVA_VERSION=${JAVA_VERSION} AWS_CREDENTIAL_PROVIDER=${AWS_CREDENTIAL_PROVIDER} ASSERT_NO_URI_CREDS=true .evergreen/run-mongodb-aws-test.sh + if [ "${SKIP_WEB_IDENTITY_AUTH_TEST}" = "true" ]; then + echo "This platform does not support the web identity auth test, skipping..." + exit 0 + fi + JAVA_VERSION=${JAVA_VERSION} AWS_CREDENTIAL_PROVIDER=${AWS_CREDENTIAL_PROVIDER} .evergreen/run-mongodb-aws-test.sh web-identity - command: shell.exec type: test params: - working_dir: "src" shell: "bash" - silent: true - script: | - # DO NOT ECHO WITH XTRACE (which PREPARE_SHELL does) - cat <<'EOF' > "${PROJECT_DIRECTORY}/prepare_mongodb_aws.sh" - export AWS_ROLE_ARN="${iam_auth_assume_web_role_name}" - export AWS_WEB_IDENTITY_TOKEN_FILE="${iam_web_identity_token_file}" - export AWS_ROLE_SESSION_NAME="test" - EOF - - command: shell.exec - type: test - params: working_dir: "src" - shell: "bash" script: | ${PREPARE_SHELL} if [ "${AWS_CREDENTIAL_PROVIDER}" = "builtIn" ]; then echo "Built-in AWS credential provider does not support the web identity auth test, skipping..." exit 0 fi - JAVA_VERSION=${JAVA_VERSION} AWS_CREDENTIAL_PROVIDER=${AWS_CREDENTIAL_PROVIDER} ASSERT_NO_URI_CREDS=true .evergreen/run-mongodb-aws-test.sh + if [ "${SKIP_WEB_IDENTITY_AUTH_TEST}" = "true" ]; then + echo "This platform does not support the web identity auth test, skipping..." + exit 0 + fi + export AWS_ROLE_SESSION_NAME="test" + JAVA_VERSION=${JAVA_VERSION} AWS_CREDENTIAL_PROVIDER=${AWS_CREDENTIAL_PROVIDER} .evergreen/run-mongodb-aws-test.sh web-identity "run aws auth test with aws credentials as environment variables": - command: shell.exec type: test params: - working_dir: "src" shell: "bash" - script: | - ${PREPARE_SHELL} - cd ${DRIVERS_TOOLS}/.evergreen/auth_aws - . ./activate-authawsvenv.sh - mongo aws_e2e_regular_aws.js - - command: shell.exec - type: test - params: - working_dir: "src" - silent: true - script: | - # DO NOT ECHO WITH XTRACE (which PREPARE_SHELL does) - cat <<'EOF' > "${PROJECT_DIRECTORY}/prepare_mongodb_aws.sh" - export AWS_ACCESS_KEY_ID=${iam_auth_ecs_account} - export AWS_SECRET_ACCESS_KEY=${iam_auth_ecs_secret_access_key} - EOF - - command: shell.exec - type: test - params: working_dir: "src" script: | ${PREPARE_SHELL} - JAVA_VERSION=${JAVA_VERSION} AWS_CREDENTIAL_PROVIDER=${AWS_CREDENTIAL_PROVIDER} .evergreen/run-mongodb-aws-test.sh + JAVA_VERSION=${JAVA_VERSION} AWS_CREDENTIAL_PROVIDER=${AWS_CREDENTIAL_PROVIDER} .evergreen/run-mongodb-aws-test.sh env-creds "run aws auth test with aws credentials and session token as environment variables": - command: shell.exec type: test params: - working_dir: "src" shell: "bash" - script: | - ${PREPARE_SHELL} - cd ${DRIVERS_TOOLS}/.evergreen/auth_aws - . ./activate-authawsvenv.sh - mongo aws_e2e_assume_role.js - - command: shell.exec - type: test - params: - working_dir: "src" - silent: true - script: | - # DO NOT ECHO WITH XTRACE (which PREPARE_SHELL does) - cat <<'EOF' > "${PROJECT_DIRECTORY}/prepare_mongodb_aws.sh" - export AWS_ACCESS_KEY_ID=$(jq -r '.AccessKeyId' ${DRIVERS_TOOLS}/.evergreen/auth_aws/creds.json) - export AWS_SECRET_ACCESS_KEY=$(jq -r '.SecretAccessKey' ${DRIVERS_TOOLS}/.evergreen/auth_aws/creds.json) - export AWS_SESSION_TOKEN=$(jq -r '.SessionToken' ${DRIVERS_TOOLS}/.evergreen/auth_aws/creds.json) - EOF - - command: shell.exec - type: test - params: working_dir: "src" script: | ${PREPARE_SHELL} - JAVA_VERSION=${JAVA_VERSION} AWS_CREDENTIAL_PROVIDER=${AWS_CREDENTIAL_PROVIDER} .evergreen/run-mongodb-aws-test.sh + JAVA_VERSION=${JAVA_VERSION} AWS_CREDENTIAL_PROVIDER=${AWS_CREDENTIAL_PROVIDER} .evergreen/run-mongodb-aws-test.sh session-creds "run aws ECS auth test": - command: shell.exec @@ -626,7 +494,7 @@ functions: OCSP_TLS_SHOULD_SUCCEED="${OCSP_TLS_SHOULD_SUCCEED}" \ OCSP_MUST_STAPLE="${OCSP_MUST_STAPLE}" \ JAVA_VERSION="${JAVA_VERSION}" \ - sh ${PROJECT_DIRECTORY}/.evergreen/run-ocsp-test.sh + bash ${PROJECT_DIRECTORY}/.evergreen/run-ocsp-test.sh "run-valid-ocsp-server-ca-responder": - command: shell.exec @@ -856,25 +724,6 @@ functions: perl -p -i -e "s|ABSOLUTE_PATH_REPLACEMENT_TOKEN|${DRIVERS_TOOLS}|g" $filename done - "windows fix": - - command: shell.exec - params: - script: | - ${PREPARE_SHELL} - for i in $(find ${DRIVERS_TOOLS}/.evergreen ${PROJECT_DIRECTORY}/.evergreen -name \*.sh); do - cat $i | tr -d '\r' > $i.new - mv $i.new $i - done - - "make files executable": - - command: shell.exec - params: - script: | - ${PREPARE_SHELL} - for i in $(find ${DRIVERS_TOOLS}/.evergreen ${PROJECT_DIRECTORY}/.evergreen -name \*.sh); do - chmod +x $i - done - "run perf tests": - command: shell.exec type: test @@ -894,9 +743,7 @@ functions: pre: - func: "fetch source" - func: "prepare resources" - - func: "windows fix" - func: "fix absolute paths" - - func: "make files executable" post: - func: "upload mo artifacts" @@ -1494,7 +1341,6 @@ tasks: TOPOLOGY: "server" SSL: "nossl" AUTH: "noauth" - SKIP_LEGACY_SHELL: "true" - func: "run perf tests" - func: "send dashboard data" @@ -1808,11 +1654,11 @@ axes: - id: "2.12" display_name: "Scala 2.12" variables: - SCALA: "2.12.15" + SCALA: "2.12.20" - id: "2.13" display_name: "Scala 2.13" variables: - SCALA: "2.13.6" + SCALA: "2.13.15" # Choice of MongoDB storage engine - id: storage-engine @@ -1864,7 +1710,6 @@ task_groups: setup_group: - func: fetch source - func: prepare resources - - func: make files executable - command: subprocess.exec params: working_dir: src @@ -1894,7 +1739,6 @@ task_groups: - func: fetch source - func: prepare resources - func: fix absolute paths - - func: make files executable - command: shell.exec params: shell: "bash" @@ -1930,7 +1774,6 @@ task_groups: - func: fetch source - func: prepare resources - func: fix absolute paths - - func: make files executable - command: shell.exec params: shell: "bash" diff --git a/.evergreen/run-atlas-search-index-management-tests.sh b/.evergreen/run-atlas-search-index-management-tests.sh old mode 100644 new mode 100755 diff --git a/.evergreen/run-deployed-lambda-aws-tests.sh b/.evergreen/run-deployed-lambda-aws-tests.sh old mode 100644 new mode 100755 diff --git a/.evergreen/run-mongodb-aws-test.sh b/.evergreen/run-mongodb-aws-test.sh index ff20ded9936..45c36227a63 100755 --- a/.evergreen/run-mongodb-aws-test.sh +++ b/.evergreen/run-mongodb-aws-test.sh @@ -15,19 +15,8 @@ RELATIVE_DIR_PATH="$(dirname "${BASH_SOURCE:-$0}")" echo "Running MONGODB-AWS authentication tests" - -# ensure no secrets are printed in log files -set +x - -# load the script -shopt -s expand_aliases # needed for `urlencode` alias -[ -s "${PROJECT_DIRECTORY}/prepare_mongodb_aws.sh" ] && source "${PROJECT_DIRECTORY}/prepare_mongodb_aws.sh" - -MONGODB_URI=${MONGODB_URI:-"mongodb://localhost"} -MONGODB_URI="${MONGODB_URI}/aws?authMechanism=MONGODB-AWS" -if [[ -n ${SESSION_TOKEN} ]]; then - MONGODB_URI="${MONGODB_URI}&authMechanismProperties=AWS_SESSION_TOKEN:${SESSION_TOKEN}" -fi +# Handle credentials and environment setup. +. $DRIVERS_TOOLS/.evergreen/auth_aws/aws_setup.sh $1 # show test output set -x diff --git a/.evergreen/run-socks5-tests.sh b/.evergreen/run-socks5-tests.sh old mode 100644 new mode 100755 diff --git a/.github/workflows/bump-and-tag.sh b/.github/workflows/bump-and-tag.sh new file mode 100755 index 00000000000..9e735586e91 --- /dev/null +++ b/.github/workflows/bump-and-tag.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -e + +if [ "$#" -ne 3 ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +CURRENT_VERSION=$1 +RELEASE_VERSION=$2 +NEXT_VERSION=$3 + +SCRIPT_DIR=$(dirname ${BASH_SOURCE[0]}) + +echo "Bump version in build.gradle to ${RELEASE_VERSION}" +${SCRIPT_DIR}/bump-version.sh "${RELEASE_VERSION_WITHOUT_SUFFIX}-SNAPSHOT" "${RELEASE_VERSION}" + +echo "Create release tag for ${RELEASE_VERSION}" +git tag -a -m "${RELEASE_VERSION}" r${RELEASE_VERSION} + +echo "Bump to snapshot version for ${NEXT_VERSION}" +${SCRIPT_DIR}/bump-version.sh "${RELEASE_VERSION}" "${NEXT_VERSION}-SNAPSHOT" diff --git a/.github/workflows/bump-version.sh b/.github/workflows/bump-version.sh new file mode 100755 index 00000000000..5f39df82d79 --- /dev/null +++ b/.github/workflows/bump-version.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -e + +if [ "$#" -ne 2 ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +FROM_VERSION=$1 +TO_VERSION=$2 + +sed --in-place "s/version = '${FROM_VERSION}'/version = '${TO_VERSION}'/g" build.gradle +git commit -m "Version: bump ${TO_VERSION}" build.gradle diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000000..4724227e0ff --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,139 @@ +name: "Release New Version" +run-name: "Release ${{ inputs.version }}" + +on: + workflow_dispatch: + inputs: + version: + description: "The version to be released (e.g. 1.2.3)" + required: true + type: "string" + +jobs: + prepare-release: + environment: release + name: "Prepare release" + runs-on: ubuntu-latest + permissions: + # Write permission for id-token is necessary to generate a new token for the GitHub App + id-token: write + # Write permission for contents is to ensure we're allowed to push to the repository + contents: write + + steps: + - name: "Create release output" + run: echo '🎬 Release process for version ${{ env.RELEASE_VERSION }} started by @${{ github.triggering_actor }}' >> $GITHUB_STEP_SUMMARY + + - uses: mongodb-labs/drivers-github-tools/secure-checkout@v2 + with: + app_id: ${{ vars.APP_ID }} + private_key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: "Store version numbers in env variables" + # The awk command to increase the version number was copied from + # StackOverflow: https://stackoverflow.com/a/61921674/3959933 + # Variables set here: + # RELEASE_VERSION: The version the deployment is expected to create + # RELEASE_VERSION_WITHOUT_SUFFIX: The version without any stability + # suffixes. Example: 5.2.0-beta0 => 5.2.0 + # NEXT_VERSION: The next version to be released. For pre-releases, the + # next version is a snapshot of the pre-release version. Examples: + # 5.2.0 => 5.2.1; 5.2.0-beta0 => 5.2.0 + # RELEASE_BRANCH: The name of the stable branch for this release series + # Example: 5.2.0 => 5.2.x + # Example: 5.2.0-beta1 => + run: | + echo RELEASE_VERSION=${{ inputs.version }} >> $GITHUB_ENV + echo RELEASE_VERSION_WITHOUT_SUFFIX=$(echo ${{ inputs.version }} | awk -F- '{print $1}') >> $GITHUB_ENV + if [[ "${{ inputs.version }}" =~ (alpha|beta|rc)[0-9]+$ ]]; then + echo NEXT_VERSION=$(echo ${{ inputs.version }} | awk -F- '{print $1}') >> $GITHUB_ENV + echo RELEASE_BRANCH=${{ github.ref_name }} >> $GITHUB_ENV + else + echo NEXT_VERSION=$(echo ${{ inputs.version }} | awk -F. -v OFS=. '{$NF += 1 ; print}') >> $GITHUB_ENV + echo RELEASE_BRANCH=$(echo ${{ inputs.version }} | awk -F. -v OFS=. '{$NF = "x" ; print}') >> $GITHUB_ENV + fi + + - name: "Ensure current snapshot version matches release version" + run: | + grep -q "version = '${{ env.RELEASE_VERSION_WITHOUT_SUFFIX }}-SNAPSHOT'" build.gradle + if [[ $? != 0 ]]; then + echo '❌ Release failed: version in build.gradle is not a snapshot for release version ${{ inputs.version }}' >> $GITHUB_STEP_SUMMARY + exit 1 + fi + + - name: "Ensure release tag does not already exist" + run: | + if [[ $(git tag -l r${{ env.RELEASE_VERSION }}) == r${{ env.RELEASE_VERSION }} ]]; then + echo '❌ Release failed: tag for version ${{ inputs.version }} already exists' >> $GITHUB_STEP_SUMMARY + exit 1 + fi + + # For patch releases (A.B.C where C != 0), we expect the release to be + # triggered from the A.B.x maintenance branch. We use the release version + # without suffixes to avoid mistakes when making pre-releases + - name: "Fail if patch release is created from wrong release branch" + if: ${{ !endsWith(env.RELEASE_VERSION_WITHOUT_SUFFIX, '.0') && env.RELEASE_BRANCH != github.ref_name }} + run: | + echo '❌ Release failed due to branch mismatch: expected ${{ inputs.version }} to be released from ${{ env.RELEASE_BRANCH }}, got ${{ github.ref_name }}' >> $GITHUB_STEP_SUMMARY + exit 1 + + # For non-patch releases (A.B.C where C == 0), we expect the release to + # be triggered from master or the A.B.x maintenance branch. This includes + # pre-releases for any non-patch releases, e.g. 5.2.0-beta1 + - name: "Fail if non-patch release is created from wrong release branch" + if: ${{ endsWith(env.RELEASE_VERSION_WITHOUT_SUFFIX, '.0') && env.RELEASE_BRANCH != github.ref_name && github.ref_name != 'master' }} + run: | + echo '❌ Release failed due to branch mismatch: expected ${{ inputs.version }} to be released from ${{ env.RELEASE_BRANCH }} or master, got ${{ github.ref_name }}' >> $GITHUB_STEP_SUMMARY + exit 1 + + # Set commit author information to the user that triggered the release workflow + - name: "Set git author information" + run: | + GITHUB_USER_NAME=$(gh api users/${{ github.actor }} --jq '.name') + GITHUB_USER_ID=$(gh api users/${{ github.actor }} --jq '.id') + git config user.name "${GITHUB_USER_NAME}" + git config user.email "${GITHUB_USER_ID}+${{ github.actor }}@users.noreply.github.com" + + # If a non-patch release is created from a branch other than its + # maintenance branch, create that branch from the current one and push it + # Pre-releases don't have this behaviour, so we can check the full release + # version including stability suffixes to exclude those + - name: "Create new release branch for non-patch release" + if: ${{ endsWith(env.RELEASE_VERSION, '.0') && env.RELEASE_BRANCH != github.ref_name }} + run: | + echo '🆕 Creating new release branch ${{ env.RELEASE_BRANCH }} from ${{ github.ref_name }}' >> $GITHUB_STEP_SUMMARY + git checkout -b ${{ env.RELEASE_BRANCH }} + NEXT_MINOR_VERSION=$(echo "${{ env.RELEASE_VERSION }}" | awk -F. -v OFS=. '{$2 += 1 ; $NF = 0 ; print}') + echo "➡️ Bumping version for ${{ github.ref_name }} branch to ${NEXT_MINOR_VERSION}" >> $GITHUB_STEP_SUMMARY + git checkout ${{ github.ref_name }} + .github/workflows/bump-version.sh "${{ env.RELEASE_VERSION_WITHOUT_SUFFIX }}-SNAPSHOT" "${NEXT_MINOR_VERSION}-SNAPSHOT" + git push origin ${{ github.ref_name }} + git checkout ${{ env.RELEASE_BRANCH }} + + # This step bumps version numbers in build.gradle and creates git artifacts for the release + - name: "Bump version numbers and create release tag" + run: .github/workflows/bump-and-tag.sh "${{ env.RELEASE_VERSION_WITHOUT_SUFFIX }}" "${{ env.RELEASE_VERSION }}" "${{ env.NEXT_VERSION }}" + + - name: "Push release branch and tag" + run: | + git push origin ${{ env.RELEASE_BRANCH }} + git push origin r${{ env.RELEASE_VERSION }} + + - name: "Create draft release with generated changelog" + run: | + if [[ "${{ inputs.version }}" =~ (alpha|beta|rc) ]]; then + PRERELEASE="--prerelease --latest=false" + fi + echo "RELEASE_URL=$(\ + gh release create r${RELEASE_VERSION} \ + ${PRERELEASE} \ + --target ${{ env.RELEASE_BRANCH }} \ + --title "Java Driver ${{ env.RELEASE_VERSION }} ($(date '+%B %d, %Y'))" \ + --generate-notes \ + --draft\ + )" >> "$GITHUB_ENV" + + - name: "Set summary" + run: | + echo '🚀 Created tag and drafted release for version [${{ env.RELEASE_VERSION }}](${{ env.RELEASE_URL }})' >> $GITHUB_STEP_SUMMARY + echo '✍️ You may now update the release notes and publish the release when ready' >> $GITHUB_STEP_SUMMARY diff --git a/.sdkmanrc b/.sdkmanrc new file mode 100644 index 00000000000..b9fe931c084 --- /dev/null +++ b/.sdkmanrc @@ -0,0 +1,3 @@ +# Enable auto-env through the sdkman_auto_env config +# Add key=value pairs of SDKs to use below +java=17.0.12-tem diff --git a/bson-kotlin/build.gradle.kts b/bson-kotlin/build.gradle.kts index ee9358c0c72..3840b3169cf 100644 --- a/bson-kotlin/build.gradle.kts +++ b/bson-kotlin/build.gradle.kts @@ -148,3 +148,5 @@ tasks.javadocJar.configure { // Sources publishing configuration // =========================== tasks.sourcesJar { from(project.sourceSets.main.map { it.kotlin }) } + +afterEvaluate { tasks.jar { manifest { attributes["Automatic-Module-Name"] = "org.mongodb.bson.kotlin" } } } diff --git a/bson-kotlin/src/main/kotlin/org/bson/codecs/kotlin/ArrayCodec.kt b/bson-kotlin/src/main/kotlin/org/bson/codecs/kotlin/ArrayCodec.kt new file mode 100644 index 00000000000..10ea90aee1b --- /dev/null +++ b/bson-kotlin/src/main/kotlin/org/bson/codecs/kotlin/ArrayCodec.kt @@ -0,0 +1,128 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * 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 + * + * http://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. + */ +package org.bson.codecs.kotlin + +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type +import kotlin.reflect.KClass +import org.bson.BsonReader +import org.bson.BsonType +import org.bson.BsonWriter +import org.bson.codecs.Codec +import org.bson.codecs.DecoderContext +import org.bson.codecs.EncoderContext +import org.bson.codecs.configuration.CodecRegistry + +@Suppress("UNCHECKED_CAST") +internal data class ArrayCodec(private val kClass: KClass, private val codec: Codec) : Codec { + + companion object { + internal fun create( + kClass: KClass, + typeArguments: List, + codecRegistry: CodecRegistry + ): Codec { + assert(kClass.javaObjectType.isArray) { "$kClass must be an array type" } + val (valueClass, nestedTypes) = + if (typeArguments.isEmpty()) { + Pair(kClass.java.componentType.kotlin.javaObjectType as Class, emptyList()) + } else { + // Unroll the actual class and any type arguments + when (val pType = typeArguments[0]) { + is Class<*> -> Pair(pType as Class, emptyList()) + is ParameterizedType -> Pair(pType.rawType as Class, pType.actualTypeArguments.toList()) + else -> Pair(Object::class.java as Class, emptyList()) + } + } + val codec = + if (nestedTypes.isEmpty()) codecRegistry.get(valueClass) else codecRegistry.get(valueClass, nestedTypes) + return ArrayCodec(kClass, codec) + } + } + + private val isPrimitiveArray = kClass.java.componentType != kClass.java.componentType.kotlin.javaObjectType + + override fun encode(writer: BsonWriter, arrayValue: R, encoderContext: EncoderContext) { + writer.writeStartArray() + + boxed(arrayValue).forEach { + if (it == null) writer.writeNull() else encoderContext.encodeWithChildContext(codec, writer, it) + } + + writer.writeEndArray() + } + + override fun getEncoderClass(): Class = kClass.java + + override fun decode(reader: BsonReader, decoderContext: DecoderContext): R { + reader.readStartArray() + val data = ArrayList() + while (reader.readBsonType() != BsonType.END_OF_DOCUMENT) { + if (reader.currentBsonType == BsonType.NULL) { + reader.readNull() + data.add(null) + } else { + data.add(decoderContext.decodeWithChildContext(codec, reader)) + } + } + reader.readEndArray() + return unboxed(data) + } + + fun boxed(arrayValue: R): Iterable { + val boxedValue = + if (!isPrimitiveArray) { + (arrayValue as Array).asIterable() + } else if (arrayValue is BooleanArray) { + arrayValue.asIterable() + } else if (arrayValue is ByteArray) { + arrayValue.asIterable() + } else if (arrayValue is CharArray) { + arrayValue.asIterable() + } else if (arrayValue is DoubleArray) { + arrayValue.asIterable() + } else if (arrayValue is FloatArray) { + arrayValue.asIterable() + } else if (arrayValue is IntArray) { + arrayValue.asIterable() + } else if (arrayValue is LongArray) { + arrayValue.asIterable() + } else if (arrayValue is ShortArray) { + arrayValue.asIterable() + } else { + throw IllegalArgumentException("Unsupported array type ${arrayValue.javaClass}") + } + return boxedValue as Iterable + } + + private fun unboxed(data: ArrayList): R { + return when (kClass) { + BooleanArray::class -> (data as ArrayList).toBooleanArray() as R + ByteArray::class -> (data as ArrayList).toByteArray() as R + CharArray::class -> (data as ArrayList).toCharArray() as R + DoubleArray::class -> (data as ArrayList).toDoubleArray() as R + FloatArray::class -> (data as ArrayList).toFloatArray() as R + IntArray::class -> (data as ArrayList).toIntArray() as R + LongArray::class -> (data as ArrayList).toLongArray() as R + ShortArray::class -> (data as ArrayList).toShortArray() as R + else -> data.toArray(arrayOfNulls(data.size)) as R + } + } + + private fun arrayOfNulls(size: Int): Array { + return java.lang.reflect.Array.newInstance(codec.encoderClass, size) as Array + } +} diff --git a/bson-kotlin/src/main/kotlin/org/bson/codecs/kotlin/ArrayCodecProvider.kt b/bson-kotlin/src/main/kotlin/org/bson/codecs/kotlin/ArrayCodecProvider.kt new file mode 100644 index 00000000000..eccb5b88b27 --- /dev/null +++ b/bson-kotlin/src/main/kotlin/org/bson/codecs/kotlin/ArrayCodecProvider.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * 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 + * + * http://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. + */ +package org.bson.codecs.kotlin + +import java.lang.reflect.Type +import org.bson.codecs.Codec +import org.bson.codecs.configuration.CodecProvider +import org.bson.codecs.configuration.CodecRegistry + +/** A Kotlin reflection based Codec Provider for data classes */ +public class ArrayCodecProvider : CodecProvider { + override fun get(clazz: Class, registry: CodecRegistry): Codec? = get(clazz, emptyList(), registry) + + override fun get(clazz: Class, typeArguments: List, registry: CodecRegistry): Codec? = + if (clazz.isArray) { + ArrayCodec.create(clazz.kotlin, typeArguments, registry) + } else null +} diff --git a/bson-kotlin/src/main/kotlin/org/bson/codecs/kotlin/DataClassCodec.kt b/bson-kotlin/src/main/kotlin/org/bson/codecs/kotlin/DataClassCodec.kt index 9027bec4574..85e705cb8c0 100644 --- a/bson-kotlin/src/main/kotlin/org/bson/codecs/kotlin/DataClassCodec.kt +++ b/bson-kotlin/src/main/kotlin/org/bson/codecs/kotlin/DataClassCodec.kt @@ -18,17 +18,19 @@ package org.bson.codecs.kotlin import java.lang.reflect.ParameterizedType import java.lang.reflect.Type import kotlin.reflect.KClass +import kotlin.reflect.KClassifier import kotlin.reflect.KFunction import kotlin.reflect.KParameter import kotlin.reflect.KProperty1 -import kotlin.reflect.KType import kotlin.reflect.KTypeParameter +import kotlin.reflect.KTypeProjection import kotlin.reflect.full.createType import kotlin.reflect.full.findAnnotation import kotlin.reflect.full.findAnnotations import kotlin.reflect.full.hasAnnotation import kotlin.reflect.full.primaryConstructor import kotlin.reflect.jvm.javaType +import kotlin.reflect.jvm.jvmErasure import org.bson.BsonReader import org.bson.BsonType import org.bson.BsonWriter @@ -140,7 +142,9 @@ internal data class DataClassCodec( val primaryConstructor = kClass.primaryConstructor ?: throw CodecConfigurationException("No primary constructor for $kClass") val typeMap = - types.mapIndexed { i, k -> primaryConstructor.typeParameters[i].createType() to k }.toMap() + types + .mapIndexed { i, k -> primaryConstructor.typeParameters[i].createType().classifier!! to k } + .toMap() val propertyModels = primaryConstructor.parameters.map { kParameter -> @@ -191,7 +195,7 @@ internal data class DataClassCodec( @Suppress("UNCHECKED_CAST") private fun getCodec( kParameter: KParameter, - typeMap: Map, + typeMap: Map, codecRegistry: CodecRegistry ): Codec { return when (kParameter.type.classifier) { @@ -199,12 +203,14 @@ internal data class DataClassCodec( codecRegistry.getCodec( kParameter, (kParameter.type.classifier as KClass).javaObjectType, - kParameter.type.arguments.mapNotNull { typeMap[it.type] ?: it.type?.javaType }.toList()) + kParameter.type.arguments + .mapNotNull { typeMap[it.type?.classifier] ?: computeJavaType(it) } + .toList()) } is KTypeParameter -> { - when (val pType = typeMap[kParameter.type] ?: kParameter.type.javaType) { + when (val pType = typeMap[kParameter.type.classifier] ?: kParameter.type.javaType) { is Class<*> -> - codecRegistry.getCodec(kParameter, (pType as Class).kotlin.javaObjectType, emptyList()) + codecRegistry.getCodec(kParameter, (pType as Class).kotlin.java, emptyList()) is ParameterizedType -> codecRegistry.getCodec( kParameter, @@ -219,14 +225,24 @@ internal data class DataClassCodec( "Could not find codec for ${kParameter.name} with type ${kParameter.type}") } + private fun computeJavaType(kTypeProjection: KTypeProjection): Type? { + val javaType: Type = kTypeProjection.type?.javaType!! + return if (javaType == Any::class.java) { + kTypeProjection.type?.jvmErasure?.javaObjectType + } else javaType + } + @Suppress("UNCHECKED_CAST") private fun CodecRegistry.getCodec(kParameter: KParameter, clazz: Class, types: List): Codec { val codec = - if (types.isEmpty()) { + if (clazz.isArray) { + ArrayCodec.create(clazz.kotlin, types, this) + } else if (types.isEmpty()) { this.get(clazz) } else { this.get(clazz, types) } + return kParameter.findAnnotation()?.let { if (codec !is RepresentationConfigurable<*>) { throw CodecConfigurationException( diff --git a/bson-kotlin/src/test/kotlin/org/bson/codecs/kotlin/DataClassCodecTest.kt b/bson-kotlin/src/test/kotlin/org/bson/codecs/kotlin/DataClassCodecTest.kt index c115b051529..c203a5d2358 100644 --- a/bson-kotlin/src/test/kotlin/org/bson/codecs/kotlin/DataClassCodecTest.kt +++ b/bson-kotlin/src/test/kotlin/org/bson/codecs/kotlin/DataClassCodecTest.kt @@ -23,7 +23,9 @@ import org.bson.codecs.DecoderContext import org.bson.codecs.EncoderContext import org.bson.codecs.configuration.CodecConfigurationException import org.bson.codecs.configuration.CodecRegistries.fromProviders +import org.bson.codecs.kotlin.samples.Box import org.bson.codecs.kotlin.samples.DataClassEmbedded +import org.bson.codecs.kotlin.samples.DataClassLastItemDefaultsToNull import org.bson.codecs.kotlin.samples.DataClassListOfDataClasses import org.bson.codecs.kotlin.samples.DataClassListOfListOfDataClasses import org.bson.codecs.kotlin.samples.DataClassListOfSealed @@ -35,6 +37,7 @@ import org.bson.codecs.kotlin.samples.DataClassSealedA import org.bson.codecs.kotlin.samples.DataClassSealedB import org.bson.codecs.kotlin.samples.DataClassSealedC import org.bson.codecs.kotlin.samples.DataClassSelfReferential +import org.bson.codecs.kotlin.samples.DataClassWithArrays import org.bson.codecs.kotlin.samples.DataClassWithBooleanMapKey import org.bson.codecs.kotlin.samples.DataClassWithBsonConstructor import org.bson.codecs.kotlin.samples.DataClassWithBsonDiscriminator @@ -50,11 +53,14 @@ import org.bson.codecs.kotlin.samples.DataClassWithEnum import org.bson.codecs.kotlin.samples.DataClassWithEnumMapKey import org.bson.codecs.kotlin.samples.DataClassWithFailingInit import org.bson.codecs.kotlin.samples.DataClassWithInvalidBsonRepresentation +import org.bson.codecs.kotlin.samples.DataClassWithListThatLastItemDefaultsToNull import org.bson.codecs.kotlin.samples.DataClassWithMutableList import org.bson.codecs.kotlin.samples.DataClassWithMutableMap import org.bson.codecs.kotlin.samples.DataClassWithMutableSet +import org.bson.codecs.kotlin.samples.DataClassWithNativeArrays import org.bson.codecs.kotlin.samples.DataClassWithNestedParameterized import org.bson.codecs.kotlin.samples.DataClassWithNestedParameterizedDataClass +import org.bson.codecs.kotlin.samples.DataClassWithNullableGeneric import org.bson.codecs.kotlin.samples.DataClassWithNulls import org.bson.codecs.kotlin.samples.DataClassWithObjectIdAndBsonDocument import org.bson.codecs.kotlin.samples.DataClassWithPair @@ -108,6 +114,59 @@ class DataClassCodecTest { assertRoundTrips(expected, dataClass) } + @Test + fun testDataClassWithArrays() { + val expected = + """{ + | "arraySimple": ["a", "b", "c", "d"], + | "nestedArrays": [["e", "f"], [], ["g", "h"]], + | "arrayOfMaps": [{"A": ["aa"], "B": ["bb"]}, {}, {"C": ["cc", "ccc"]}], + |}""" + .trimMargin() + + val dataClass = + DataClassWithArrays( + arrayOf("a", "b", "c", "d"), + arrayOf(arrayOf("e", "f"), emptyArray(), arrayOf("g", "h")), + arrayOf( + mapOf("A" to arrayOf("aa"), "B" to arrayOf("bb")), emptyMap(), mapOf("C" to arrayOf("cc", "ccc")))) + + assertRoundTrips(expected, dataClass) + } + + @Test + fun testDataClassWithNativeArrays() { + val expected = + """{ + | "booleanArray": [true, false], + | "byteArray": [1, 2], + | "charArray": ["a", "b"], + | "doubleArray": [ 1.1, 2.2, 3.3], + | "floatArray": [1.0, 2.0, 3.0], + | "intArray": [10, 20, 30, 40], + | "longArray": [{ "$numberLong": "111" }, { "$numberLong": "222" }, { "$numberLong": "333" }], + | "shortArray": [1, 2, 3], + | "listOfArrays": [[true, false], [false, true]], + | "mapOfArrays": {"A": [1, 2], "B":[], "C": [3, 4]} + |}""" + .trimMargin() + + val dataClass = + DataClassWithNativeArrays( + booleanArrayOf(true, false), + byteArrayOf(1, 2), + charArrayOf('a', 'b'), + doubleArrayOf(1.1, 2.2, 3.3), + floatArrayOf(1.0f, 2.0f, 3.0f), + intArrayOf(10, 20, 30, 40), + longArrayOf(111, 222, 333), + shortArrayOf(1, 2, 3), + listOf(booleanArrayOf(true, false), booleanArrayOf(false, true)), + mapOf(Pair("A", intArrayOf(1, 2)), Pair("B", intArrayOf()), Pair("C", intArrayOf(3, 4)))) + + assertRoundTrips(expected, dataClass) + } + @Test fun testDataClassWithDefaults() { val expectedDefault = @@ -131,6 +190,39 @@ class DataClassCodecTest { assertDecodesTo(withStoredNulls, dataClass) } + @Test + fun testDataClassWithListThatLastItemDefaultsToNull() { + val expected = + """{ + | "elements": [{"required": "required"}, {"required": "required"}], + |}""" + .trimMargin() + + val dataClass = + DataClassWithListThatLastItemDefaultsToNull( + listOf(DataClassLastItemDefaultsToNull("required"), DataClassLastItemDefaultsToNull("required"))) + assertRoundTrips(expected, dataClass) + } + + @Test + fun testDataClassWithNullableGenericsNotNull() { + val expected = + """{ + | "box": {"boxed": "String"} + |}""" + .trimMargin() + + val dataClass = DataClassWithNullableGeneric(Box("String")) + assertRoundTrips(expected, dataClass) + } + + @Test + fun testDataClassWithNullableGenericsNull() { + val expected = """{"box": {}}""" + val dataClass = DataClassWithNullableGeneric(Box(null)) + assertRoundTrips(expected, dataClass) + } + @Test fun testDataClassSelfReferential() { val expected = @@ -228,7 +320,7 @@ class DataClassCodecTest { |"nestedParameterized": { | "parameterizedDataClass": | {"number": 4.2, "string": "myString", "parameterizedList": [{"name": "embedded1"}]}, - | "other": "myOtherString" + | "other": "myOtherString", "optionalOther": "myOptionalOtherString" | } |}""" .trimMargin() @@ -236,7 +328,9 @@ class DataClassCodecTest { DataClassWithNestedParameterizedDataClass( "myId", DataClassWithNestedParameterized( - DataClassParameterized(4.2, "myString", listOf(DataClassEmbedded("embedded1"))), "myOtherString")) + DataClassParameterized(4.2, "myString", listOf(DataClassEmbedded("embedded1"))), + "myOtherString", + "myOptionalOtherString")) assertRoundTrips(expected, dataClass) } @@ -495,5 +589,5 @@ class DataClassCodecTest { assertEquals(expected, decoded) } - private fun registry() = fromProviders(DataClassCodecProvider(), Bson.DEFAULT_CODEC_REGISTRY) + private fun registry() = fromProviders(ArrayCodecProvider(), DataClassCodecProvider(), Bson.DEFAULT_CODEC_REGISTRY) } diff --git a/bson-kotlin/src/test/kotlin/org/bson/codecs/kotlin/samples/DataClasses.kt b/bson-kotlin/src/test/kotlin/org/bson/codecs/kotlin/samples/DataClasses.kt index eaa87ca603b..77483cc9ee7 100644 --- a/bson-kotlin/src/test/kotlin/org/bson/codecs/kotlin/samples/DataClasses.kt +++ b/bson-kotlin/src/test/kotlin/org/bson/codecs/kotlin/samples/DataClasses.kt @@ -15,6 +15,7 @@ */ package org.bson.codecs.kotlin.samples +import kotlin.time.Duration import org.bson.BsonDocument import org.bson.BsonMaxKey import org.bson.BsonType @@ -48,6 +49,91 @@ data class DataClassWithCollections( val mapMap: Map> ) +data class DataClassWithArrays( + val arraySimple: Array, + val nestedArrays: Array>, + val arrayOfMaps: Array>> +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as DataClassWithArrays + + if (!arraySimple.contentEquals(other.arraySimple)) return false + if (!nestedArrays.contentDeepEquals(other.nestedArrays)) return false + + if (arrayOfMaps.size != other.arrayOfMaps.size) return false + arrayOfMaps.forEachIndexed { i, map -> + val otherMap = other.arrayOfMaps[i] + if (map.keys != otherMap.keys) return false + map.keys.forEach { key -> if (!map[key].contentEquals(otherMap[key])) return false } + } + + return true + } + + override fun hashCode(): Int { + var result = arraySimple.contentHashCode() + result = 31 * result + nestedArrays.contentDeepHashCode() + result = 31 * result + arrayOfMaps.contentHashCode() + return result + } +} + +data class DataClassWithNativeArrays( + val booleanArray: BooleanArray, + val byteArray: ByteArray, + val charArray: CharArray, + val doubleArray: DoubleArray, + val floatArray: FloatArray, + val intArray: IntArray, + val longArray: LongArray, + val shortArray: ShortArray, + val listOfArrays: List, + val mapOfArrays: Map +) { + + @SuppressWarnings("ComplexMethod") + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as DataClassWithNativeArrays + + if (!booleanArray.contentEquals(other.booleanArray)) return false + if (!byteArray.contentEquals(other.byteArray)) return false + if (!charArray.contentEquals(other.charArray)) return false + if (!doubleArray.contentEquals(other.doubleArray)) return false + if (!floatArray.contentEquals(other.floatArray)) return false + if (!intArray.contentEquals(other.intArray)) return false + if (!longArray.contentEquals(other.longArray)) return false + if (!shortArray.contentEquals(other.shortArray)) return false + + if (listOfArrays.size != other.listOfArrays.size) return false + listOfArrays.forEachIndexed { i, value -> if (!value.contentEquals(other.listOfArrays[i])) return false } + + if (mapOfArrays.keys != other.mapOfArrays.keys) return false + mapOfArrays.keys.forEach { key -> if (!mapOfArrays[key].contentEquals(other.mapOfArrays[key])) return false } + + return true + } + + override fun hashCode(): Int { + var result = booleanArray.contentHashCode() + result = 31 * result + byteArray.contentHashCode() + result = 31 * result + charArray.contentHashCode() + result = 31 * result + doubleArray.contentHashCode() + result = 31 * result + floatArray.contentHashCode() + result = 31 * result + intArray.contentHashCode() + result = 31 * result + longArray.contentHashCode() + result = 31 * result + shortArray.contentHashCode() + result = 31 * result + listOfArrays.hashCode() + result = 31 * result + mapOfArrays.hashCode() + return result + } +} + data class DataClassWithDefaults( val boolean: Boolean = false, val string: String = "String", @@ -56,6 +142,10 @@ data class DataClassWithDefaults( data class DataClassWithNulls(val boolean: Boolean?, val string: String?, val listSimple: List?) +data class DataClassWithListThatLastItemDefaultsToNull(val elements: List) + +data class DataClassLastItemDefaultsToNull(val required: String, val optional: String? = null) + data class DataClassSelfReferential( val name: String, val left: DataClassSelfReferential? = null, @@ -88,7 +178,8 @@ data class DataClassWithNestedParameterizedDataClass( data class DataClassWithNestedParameterized( val parameterizedDataClass: DataClassParameterized, - val other: B + val other: B, + val optionalOther: B? ) data class DataClassWithPair(val pair: Pair) @@ -159,3 +250,9 @@ data class DataClassWithFailingInit(val id: String) { } data class DataClassWithSequence(val value: Sequence) + +data class DataClassWithJVMErasure(val duration: Duration, val ints: List) + +data class Box(val boxed: T) + +data class DataClassWithNullableGeneric(val box: Box) diff --git a/bson-kotlinx/build.gradle.kts b/bson-kotlinx/build.gradle.kts index 278c9988aa9..bb9dd42e10b 100644 --- a/bson-kotlinx/build.gradle.kts +++ b/bson-kotlinx/build.gradle.kts @@ -152,3 +152,5 @@ tasks.javadocJar.configure { // Sources publishing configuration // =========================== tasks.sourcesJar { from(project.sourceSets.main.map { it.kotlin }) } + +afterEvaluate { tasks.jar { manifest { attributes["Automatic-Module-Name"] = "org.mongodb.bson.kotlinx" } } } diff --git a/bson-kotlinx/src/main/kotlin/org/bson/codecs/kotlinx/BsonDecoder.kt b/bson-kotlinx/src/main/kotlin/org/bson/codecs/kotlinx/BsonDecoder.kt index b4cbad3b9dd..38d9c23309f 100644 --- a/bson-kotlinx/src/main/kotlin/org/bson/codecs/kotlinx/BsonDecoder.kt +++ b/bson-kotlinx/src/main/kotlin/org/bson/codecs/kotlinx/BsonDecoder.kt @@ -31,10 +31,13 @@ import kotlinx.serialization.modules.SerializersModule import org.bson.AbstractBsonReader import org.bson.BsonInvalidOperationException import org.bson.BsonReader +import org.bson.BsonReaderMark import org.bson.BsonType import org.bson.BsonValue import org.bson.codecs.BsonValueCodec import org.bson.codecs.DecoderContext +import org.bson.internal.NumberCodecHelper +import org.bson.internal.StringCodecHelper import org.bson.types.ObjectId /** @@ -68,6 +71,20 @@ internal open class DefaultBsonDecoder( val validKeyKinds = setOf(PrimitiveKind.STRING, PrimitiveKind.CHAR, SerialKind.ENUM) val bsonValueCodec = BsonValueCodec() const val UNKNOWN_INDEX = -10 + fun validateCurrentBsonType( + reader: AbstractBsonReader, + expectedType: BsonType, + descriptor: SerialDescriptor, + actualType: (descriptor: SerialDescriptor) -> String = { it.kind.toString() } + ) { + reader.currentBsonType?.let { + if (it != expectedType) { + throw SerializationException( + "Invalid data for `${actualType(descriptor)}` expected a bson " + + "${expectedType.name.lowercase()} found: ${reader.currentBsonType}") + } + } + } } private fun initElementMetadata(descriptor: SerialDescriptor) { @@ -119,29 +136,14 @@ internal open class DefaultBsonDecoder( @Suppress("ReturnCount") override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder { - when (descriptor.kind) { - is StructureKind.LIST -> { - reader.readStartArray() - return BsonArrayDecoder(reader, serializersModule, configuration) - } - is PolymorphicKind -> { - reader.readStartDocument() - return PolymorphicDecoder(reader, serializersModule, configuration) - } + return when (descriptor.kind) { + is StructureKind.LIST -> BsonArrayDecoder(descriptor, reader, serializersModule, configuration) + is PolymorphicKind -> PolymorphicDecoder(descriptor, reader, serializersModule, configuration) is StructureKind.CLASS, - StructureKind.OBJECT -> { - val current = reader.currentBsonType - if (current == null || current == BsonType.DOCUMENT) { - reader.readStartDocument() - } - } - is StructureKind.MAP -> { - reader.readStartDocument() - return BsonDocumentDecoder(reader, serializersModule, configuration) - } + StructureKind.OBJECT -> BsonDocumentDecoder(descriptor, reader, serializersModule, configuration) + is StructureKind.MAP -> MapDecoder(descriptor, reader, serializersModule, configuration) else -> throw SerializationException("Primitives are not supported at top-level") } - return DefaultBsonDecoder(reader, serializersModule, configuration) } override fun endStructure(descriptor: SerialDescriptor) { @@ -154,14 +156,17 @@ internal open class DefaultBsonDecoder( } } - override fun decodeByte(): Byte = decodeInt().toByte() - override fun decodeChar(): Char = decodeString().single() - override fun decodeFloat(): Float = decodeDouble().toFloat() - override fun decodeShort(): Short = decodeInt().toShort() - override fun decodeBoolean(): Boolean = readOrThrow({ reader.readBoolean() }, BsonType.BOOLEAN) - override fun decodeDouble(): Double = readOrThrow({ reader.readDouble() }, BsonType.DOUBLE) - override fun decodeInt(): Int = readOrThrow({ reader.readInt32() }, BsonType.INT32) - override fun decodeLong(): Long = readOrThrow({ reader.readInt64() }, BsonType.INT64) + override fun decodeByte(): Byte = NumberCodecHelper.decodeByte(reader) + + override fun decodeChar(): Char = StringCodecHelper.decodeChar(reader) + override fun decodeFloat(): Float = NumberCodecHelper.decodeFloat(reader) + + override fun decodeShort(): Short = NumberCodecHelper.decodeShort(reader) + override fun decodeBoolean(): Boolean = reader.readBoolean() + + override fun decodeDouble(): Double = NumberCodecHelper.decodeDouble(reader) + override fun decodeInt(): Int = NumberCodecHelper.decodeInt(reader) + override fun decodeLong(): Long = NumberCodecHelper.decodeLong(reader) override fun decodeString(): String = readOrThrow({ reader.readString() }, BsonType.STRING) override fun decodeNull(): Nothing? { @@ -194,10 +199,17 @@ internal open class DefaultBsonDecoder( @OptIn(ExperimentalSerializationApi::class) private class BsonArrayDecoder( + descriptor: SerialDescriptor, reader: AbstractBsonReader, serializersModule: SerializersModule, configuration: BsonConfiguration ) : DefaultBsonDecoder(reader, serializersModule, configuration) { + + init { + validateCurrentBsonType(reader, BsonType.ARRAY, descriptor) + reader.readStartArray() + } + private var index = 0 override fun decodeElementIndex(descriptor: SerialDescriptor): Int { val nextType = reader.readBsonType() @@ -208,18 +220,46 @@ private class BsonArrayDecoder( @OptIn(ExperimentalSerializationApi::class) private class PolymorphicDecoder( + descriptor: SerialDescriptor, reader: AbstractBsonReader, serializersModule: SerializersModule, configuration: BsonConfiguration ) : DefaultBsonDecoder(reader, serializersModule, configuration) { private var index = 0 + private var mark: BsonReaderMark? + + init { + mark = reader.mark + validateCurrentBsonType(reader, BsonType.DOCUMENT, descriptor) { it.serialName } + reader.readStartDocument() + } - override fun decodeSerializableValue(deserializer: DeserializationStrategy): T = - deserializer.deserialize(DefaultBsonDecoder(reader, serializersModule, configuration)) + override fun decodeSerializableValue(deserializer: DeserializationStrategy): T { + mark?.let { + it.reset() + mark = null + } + return deserializer.deserialize(DefaultBsonDecoder(reader, serializersModule, configuration)) + } override fun decodeElementIndex(descriptor: SerialDescriptor): Int { + var found = false return when (index) { - 0 -> index++ + 0 -> { + while (reader.readBsonType() != BsonType.END_OF_DOCUMENT) { + if (reader.readName() == configuration.classDiscriminator) { + found = true + break + } + reader.skipValue() + } + if (!found) { + throw SerializationException( + "Missing required discriminator field `${configuration.classDiscriminator}` " + + "for polymorphic class: `${descriptor.serialName}`.") + } + index++ + } 1 -> index++ else -> DECODE_DONE } @@ -228,6 +268,20 @@ private class PolymorphicDecoder( @OptIn(ExperimentalSerializationApi::class) private class BsonDocumentDecoder( + descriptor: SerialDescriptor, + reader: AbstractBsonReader, + serializersModule: SerializersModule, + configuration: BsonConfiguration +) : DefaultBsonDecoder(reader, serializersModule, configuration) { + init { + validateCurrentBsonType(reader, BsonType.DOCUMENT, descriptor) { it.serialName } + reader.readStartDocument() + } +} + +@OptIn(ExperimentalSerializationApi::class) +private class MapDecoder( + descriptor: SerialDescriptor, reader: AbstractBsonReader, serializersModule: SerializersModule, configuration: BsonConfiguration @@ -236,6 +290,11 @@ private class BsonDocumentDecoder( private var index = 0 private var isKey = false + init { + validateCurrentBsonType(reader, BsonType.DOCUMENT, descriptor) + reader.readStartDocument() + } + override fun decodeString(): String { return if (isKey) { reader.readName() diff --git a/bson-kotlinx/src/main/kotlin/org/bson/codecs/kotlinx/BsonEncoder.kt b/bson-kotlinx/src/main/kotlin/org/bson/codecs/kotlinx/BsonEncoder.kt index 2e68b992700..75080254cdb 100644 --- a/bson-kotlinx/src/main/kotlin/org/bson/codecs/kotlinx/BsonEncoder.kt +++ b/bson-kotlinx/src/main/kotlin/org/bson/codecs/kotlinx/BsonEncoder.kt @@ -72,7 +72,7 @@ internal class DefaultBsonEncoder( private var isPolymorphic = false private var state = STATE.VALUE private var mapState = MapState() - private var deferredElementName: String? = null + private val deferredElementHandler: DeferredElementHandler = DeferredElementHandler() override fun shouldEncodeElementDefault(descriptor: SerialDescriptor, index: Int): Boolean = configuration.encodeDefaults @@ -117,7 +117,7 @@ internal class DefaultBsonEncoder( is StructureKind.CLASS -> { val elementName = descriptor.getElementName(index) if (descriptor.getElementDescriptor(index).isNullable) { - deferredElementName = elementName + deferredElementHandler.set(elementName) } else { encodeName(elementName) } @@ -139,14 +139,28 @@ internal class DefaultBsonEncoder( return true } + override fun encodeSerializableValue(serializer: SerializationStrategy, value: T) { + deferredElementHandler.with( + { + // When using generics its possible for `value` to be null + // See: https://youtrack.jetbrains.com/issue/KT-66206 + if (value != null || configuration.explicitNulls) { + encodeName(it) + super.encodeSerializableValue(serializer, value) + } + }, + { super.encodeSerializableValue(serializer, value) }) + } + override fun encodeNullableSerializableValue(serializer: SerializationStrategy, value: T?) { - deferredElementName?.let { - if (value != null || configuration.explicitNulls) { - encodeName(it) - super.encodeNullableSerializableValue(serializer, value) - } - } - ?: super.encodeNullableSerializableValue(serializer, value) + deferredElementHandler.with( + { + if (value != null || configuration.explicitNulls) { + encodeName(it) + super.encodeNullableSerializableValue(serializer, value) + } + }, + { super.encodeNullableSerializableValue(serializer, value) }) } override fun encodeByte(value: Byte) = encodeInt(value.toInt()) @@ -187,7 +201,6 @@ internal class DefaultBsonEncoder( private fun encodeName(value: Any) { writer.writeName(value.toString()) - deferredElementName = null state = STATE.VALUE } @@ -210,4 +223,25 @@ internal class DefaultBsonEncoder( return getState() } } + + private class DeferredElementHandler { + private var deferredElementName: String? = null + + fun set(name: String) { + assert(deferredElementName == null) { -> "Overwriting an existing deferred name" } + deferredElementName = name + } + + fun with(actionWithDeferredElement: (String) -> Unit, actionWithoutDeferredElement: () -> Unit): Unit { + deferredElementName?.let { + reset() + actionWithDeferredElement(it) + } + ?: actionWithoutDeferredElement() + } + + private fun reset() { + deferredElementName = null + } + } } diff --git a/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/KotlinSerializerCodecProviderTest.kt b/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/KotlinSerializerCodecProviderTest.kt index 0870e2033e9..08527d36722 100644 --- a/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/KotlinSerializerCodecProviderTest.kt +++ b/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/KotlinSerializerCodecProviderTest.kt @@ -20,9 +20,14 @@ import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue +import org.bson.codecs.DecoderContext import org.bson.codecs.kotlinx.samples.DataClassParameterized +import org.bson.codecs.kotlinx.samples.DataClassSealedInterface import org.bson.codecs.kotlinx.samples.DataClassWithSimpleValues +import org.bson.codecs.kotlinx.samples.SealedInterface import org.bson.conversions.Bson +import org.bson.json.JsonReader +import org.bson.types.ObjectId import org.junit.jupiter.api.Test class KotlinSerializerCodecProviderTest { @@ -60,4 +65,39 @@ class KotlinSerializerCodecProviderTest { assertTrue { codec is KotlinSerializerCodec } assertEquals(DataClassWithSimpleValues::class.java, codec.encoderClass) } + + @Test + fun testDataClassWithSimpleValuesFieldOrdering() { + val codec = MongoClientSettings.getDefaultCodecRegistry().get(DataClassWithSimpleValues::class.java) + val expected = DataClassWithSimpleValues('c', 0, 1, 22, 42L, 4.0f, 4.2, true, "String") + + val numberLong = "\$numberLong" + val actual = + codec.decode( + JsonReader( + """{"boolean": true, "byte": 0, "char": "c", "double": 4.2, "float": 4.0, "int": 22, + |"long": {"$numberLong": "42"}, "short": 1, "string": "String"}""" + .trimMargin()), + DecoderContext.builder().build()) + + assertEquals(expected, actual) + } + + @Test + fun testDataClassSealedFieldOrdering() { + val codec = MongoClientSettings.getDefaultCodecRegistry().get(SealedInterface::class.java) + + val objectId = ObjectId("111111111111111111111111") + val oid = "\$oid" + val expected = DataClassSealedInterface(objectId, "string") + val actual = + codec.decode( + JsonReader( + """{"name": "string", "_id": {$oid: "${objectId.toHexString()}"}, + |"_t": "org.bson.codecs.kotlinx.samples.DataClassSealedInterface"}""" + .trimMargin()), + DecoderContext.builder().build()) + + assertEquals(expected, actual) + } } diff --git a/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/KotlinSerializerCodecTest.kt b/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/KotlinSerializerCodecTest.kt index 146e897c59b..05a0d3ffd7d 100644 --- a/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/KotlinSerializerCodecTest.kt +++ b/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/KotlinSerializerCodecTest.kt @@ -15,6 +15,7 @@ */ package org.bson.codecs.kotlinx +import java.util.stream.Stream import kotlin.test.assertEquals import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.MissingFieldException @@ -23,21 +24,28 @@ import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.plus import kotlinx.serialization.modules.polymorphic import kotlinx.serialization.modules.subclass +import org.bson.BsonBoolean import org.bson.BsonDocument import org.bson.BsonDocumentReader import org.bson.BsonDocumentWriter +import org.bson.BsonDouble +import org.bson.BsonInt32 +import org.bson.BsonInt64 import org.bson.BsonInvalidOperationException import org.bson.BsonMaxKey import org.bson.BsonMinKey +import org.bson.BsonString import org.bson.BsonUndefined import org.bson.codecs.DecoderContext import org.bson.codecs.EncoderContext import org.bson.codecs.configuration.CodecConfigurationException +import org.bson.codecs.kotlinx.samples.Box import org.bson.codecs.kotlinx.samples.DataClassBsonValues import org.bson.codecs.kotlinx.samples.DataClassContainsOpen import org.bson.codecs.kotlinx.samples.DataClassContainsValueClass import org.bson.codecs.kotlinx.samples.DataClassEmbedded import org.bson.codecs.kotlinx.samples.DataClassKey +import org.bson.codecs.kotlinx.samples.DataClassLastItemDefaultsToNull import org.bson.codecs.kotlinx.samples.DataClassListOfDataClasses import org.bson.codecs.kotlinx.samples.DataClassListOfListOfDataClasses import org.bson.codecs.kotlinx.samples.DataClassListOfSealed @@ -71,11 +79,13 @@ import org.bson.codecs.kotlinx.samples.DataClassWithEncodeDefault import org.bson.codecs.kotlinx.samples.DataClassWithEnum import org.bson.codecs.kotlinx.samples.DataClassWithEnumMapKey import org.bson.codecs.kotlinx.samples.DataClassWithFailingInit +import org.bson.codecs.kotlinx.samples.DataClassWithListThatLastItemDefaultsToNull import org.bson.codecs.kotlinx.samples.DataClassWithMutableList import org.bson.codecs.kotlinx.samples.DataClassWithMutableMap import org.bson.codecs.kotlinx.samples.DataClassWithMutableSet import org.bson.codecs.kotlinx.samples.DataClassWithNestedParameterized import org.bson.codecs.kotlinx.samples.DataClassWithNestedParameterizedDataClass +import org.bson.codecs.kotlinx.samples.DataClassWithNullableGeneric import org.bson.codecs.kotlinx.samples.DataClassWithNulls import org.bson.codecs.kotlinx.samples.DataClassWithPair import org.bson.codecs.kotlinx.samples.DataClassWithParameterizedDataClass @@ -84,20 +94,24 @@ import org.bson.codecs.kotlinx.samples.DataClassWithSequence import org.bson.codecs.kotlinx.samples.DataClassWithSimpleValues import org.bson.codecs.kotlinx.samples.DataClassWithTriple import org.bson.codecs.kotlinx.samples.Key +import org.bson.codecs.kotlinx.samples.SealedInterface import org.bson.codecs.kotlinx.samples.ValueClass import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource @OptIn(ExperimentalSerializationApi::class) +@Suppress("LargeClass") class KotlinSerializerCodecTest { - private val numberLong = "\$numberLong" + private val oid = "\$oid" private val emptyDocument = "{}" private val altConfiguration = BsonConfiguration(encodeDefaults = false, classDiscriminator = "_t", explicitNulls = true) private val allBsonTypesJson = """{ - | "id": {"${'$'}oid": "111111111111111111111111"}, + | "id": {"$oid": "111111111111111111111111"}, | "arrayEmpty": [], | "arraySimple": [{"${'$'}numberInt": "1"}, {"${'$'}numberInt": "2"}, {"${'$'}numberInt": "3"}], | "arrayComplex": [{"a": {"${'$'}numberInt": "1"}}, {"a": {"${'$'}numberInt": "2"}}], @@ -129,15 +143,59 @@ class KotlinSerializerCodecTest { private val allBsonTypesDocument = BsonDocument.parse(allBsonTypesJson) - @Test - fun testDataClassWithSimpleValues() { - val expected = - """{"char": "c", "byte": 0, "short": 1, "int": 22, "long": {"$numberLong": "42"}, "float": 4.0, - | "double": 4.2, "boolean": true, "string": "String"}""" - .trimMargin() - val dataClass = DataClassWithSimpleValues('c', 0, 1, 22, 42L, 4.0f, 4.2, true, "String") + companion object { + @JvmStatic + fun testTypesCastingDataClassWithSimpleValues(): Stream { + return Stream.of( + BsonDocument() + .append("char", BsonString("c")) + .append("byte", BsonInt32(1)) + .append("short", BsonInt32(2)) + .append("int", BsonInt32(10)) + .append("long", BsonInt32(10)) + .append("float", BsonInt32(2)) + .append("double", BsonInt32(3)) + .append("boolean", BsonBoolean.TRUE) + .append("string", BsonString("String")), + BsonDocument() + .append("char", BsonString("c")) + .append("byte", BsonDouble(1.0)) + .append("short", BsonDouble(2.0)) + .append("int", BsonDouble(9.9999999999999992)) + .append("long", BsonDouble(9.9999999999999992)) + .append("float", BsonDouble(2.0)) + .append("double", BsonDouble(3.0)) + .append("boolean", BsonBoolean.TRUE) + .append("string", BsonString("String")), + BsonDocument() + .append("char", BsonString("c")) + .append("byte", BsonDouble(1.0)) + .append("short", BsonDouble(2.0)) + .append("int", BsonDouble(10.0)) + .append("long", BsonDouble(10.0)) + .append("float", BsonDouble(2.0)) + .append("double", BsonDouble(3.0)) + .append("boolean", BsonBoolean.TRUE) + .append("string", BsonString("String")), + BsonDocument() + .append("char", BsonString("c")) + .append("byte", BsonInt64(1)) + .append("short", BsonInt64(2)) + .append("int", BsonInt64(10)) + .append("long", BsonInt64(10)) + .append("float", BsonInt64(2)) + .append("double", BsonInt64(3)) + .append("boolean", BsonBoolean.TRUE) + .append("string", BsonString("String"))) + } + } - assertRoundTrips(expected, dataClass) + @ParameterizedTest + @MethodSource("testTypesCastingDataClassWithSimpleValues") + fun testTypesCastingDataClassWithSimpleValues(data: BsonDocument) { + val expectedDataClass = DataClassWithSimpleValues('c', 1, 2, 10, 10L, 2.0f, 3.0, true, "String") + + assertDecodesTo(data, expectedDataClass) } @Test @@ -199,6 +257,48 @@ class KotlinSerializerCodecTest { assertRoundTrips(expectedNulls, dataClass, altConfiguration) } + @Test + fun testDataClassWithListThatLastItemDefaultsToNull() { + val expectedWithOutNulls = + """{ + | "elements": [{"required": "required"}, {"required": "required"}], + |}""" + .trimMargin() + + val dataClass = + DataClassWithListThatLastItemDefaultsToNull( + listOf(DataClassLastItemDefaultsToNull("required"), DataClassLastItemDefaultsToNull("required"))) + assertRoundTrips(expectedWithOutNulls, dataClass) + + val expectedWithNulls = + """{ + | "elements": [{"required": "required", "optional": null}, {"required": "required", "optional": null}], + |}""" + .trimMargin() + assertRoundTrips(expectedWithNulls, dataClass, BsonConfiguration(explicitNulls = true)) + } + + @Test + fun testDataClassWithNullableGenericsNotNull() { + val expected = + """{ + | "box": {"boxed": "String"} + |}""" + .trimMargin() + + val dataClass = DataClassWithNullableGeneric(Box("String")) + assertRoundTrips(expected, dataClass) + } + + @Test + fun testDataClassWithNullableGenericsNull() { + val expectedDefault = """{"box": {}}""" + val dataClass = DataClassWithNullableGeneric(Box(null)) + assertRoundTrips(expectedDefault, dataClass) + val expectedNull = """{"box": {"boxed": null}}""" + assertRoundTrips(expectedNull, dataClass, altConfiguration) + } + @Test fun testDataClassSelfReferential() { val expected = @@ -296,7 +396,7 @@ class KotlinSerializerCodecTest { |"nestedParameterized": { | "parameterizedDataClass": | {"number": 4.2, "string": "myString", "parameterizedList": [{"name": "embedded1"}]}, - | "other": "myOtherString" + | "other": "myOtherString", "optionalOther": "myOptionalOtherString" | } |}""" .trimMargin() @@ -304,7 +404,9 @@ class KotlinSerializerCodecTest { DataClassWithNestedParameterizedDataClass( "myId", DataClassWithNestedParameterized( - DataClassParameterized(4.2, "myString", listOf(DataClassEmbedded("embedded1"))), "myOtherString")) + DataClassParameterized(4.2, "myString", listOf(DataClassEmbedded("embedded1"))), + "myOtherString", + "myOptionalOtherString")) assertRoundTrips(expected, dataClass) } @@ -668,17 +770,49 @@ class KotlinSerializerCodecTest { codec?.decode(BsonDocumentReader(data), DecoderContext.builder().build()) } - assertThrows("Invalid complex types") { - val data = BsonDocument.parse("""{"_id": "myId", "embedded": 123}""") - val codec = KotlinSerializerCodec.create() - codec?.decode(BsonDocumentReader(data), DecoderContext.builder().build()) - } - assertThrows("Failing init") { val data = BsonDocument.parse("""{"id": "myId"}""") val codec = KotlinSerializerCodec.create() codec?.decode(BsonDocumentReader(data), DecoderContext.builder().build()) } + + var exception = + assertThrows("Invalid complex types - document") { + val data = BsonDocument.parse("""{"_id": "myId", "embedded": 123}""") + val codec = KotlinSerializerCodec.create() + codec?.decode(BsonDocumentReader(data), DecoderContext.builder().build()) + } + assertEquals( + "Invalid data for `org.bson.codecs.kotlinx.samples.DataClassEmbedded` " + + "expected a bson document found: INT32", + exception.message) + + exception = + assertThrows("Invalid complex types - list") { + val data = BsonDocument.parse("""{"_id": "myId", "nested": 123}""") + val codec = KotlinSerializerCodec.create() + codec?.decode(BsonDocumentReader(data), DecoderContext.builder().build()) + } + assertEquals("Invalid data for `LIST` expected a bson array found: INT32", exception.message) + + exception = + assertThrows("Invalid complex types - map") { + val data = BsonDocument.parse("""{"_id": "myId", "nested": 123}""") + val codec = KotlinSerializerCodec.create() + codec?.decode(BsonDocumentReader(data), DecoderContext.builder().build()) + } + assertEquals("Invalid data for `MAP` expected a bson document found: INT32", exception.message) + + exception = + assertThrows("Missing discriminator") { + val data = BsonDocument.parse("""{"_id": {"$oid": "111111111111111111111111"}, "name": "string"}""") + val codec = KotlinSerializerCodec.create() + codec?.decode(BsonDocumentReader(data), DecoderContext.builder().build()) + } + assertEquals( + "Missing required discriminator field `_t` for polymorphic class: " + + "`org.bson.codecs.kotlinx.samples.SealedInterface`.", + exception.message) } @Test diff --git a/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/samples/DataClasses.kt b/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/samples/DataClasses.kt index ea5e3fea3cd..66907bff103 100644 --- a/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/samples/DataClasses.kt +++ b/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/samples/DataClasses.kt @@ -82,6 +82,11 @@ data class DataClassWithDefaults( @Serializable data class DataClassWithNulls(val boolean: Boolean?, val string: String?, val listSimple: List?) +@Serializable +data class DataClassWithListThatLastItemDefaultsToNull(val elements: List) + +@Serializable data class DataClassLastItemDefaultsToNull(val required: String, val optional: String? = null) + @Serializable data class DataClassSelfReferential( val name: String, @@ -120,7 +125,8 @@ data class DataClassWithNestedParameterizedDataClass( @Serializable data class DataClassWithNestedParameterized( val parameterizedDataClass: DataClassParameterized, - val other: B + val other: B, + val optionalOther: B? ) @Serializable data class DataClassWithPair(val pair: Pair) @@ -245,6 +251,15 @@ data class DataClassOptionalBsonValues( @Serializable @SerialName("C") data class DataClassSealedC(val c: String) : DataClassSealed() +@Serializable +sealed interface SealedInterface { + val name: String +} + +@Serializable +data class DataClassSealedInterface(@Contextual @SerialName("_id") val id: ObjectId, override val name: String) : + SealedInterface + @Serializable data class DataClassListOfSealed(val items: List) interface DataClassOpen @@ -285,3 +300,7 @@ data class DataClassWithFailingInit(val id: String) { } @Serializable data class DataClassWithSequence(val value: Sequence) + +@Serializable data class Box(val boxed: T) + +@Serializable data class DataClassWithNullableGeneric(val box: Box) diff --git a/bson-record-codec/src/main/org/bson/codecs/record/RecordCodec.java b/bson-record-codec/src/main/org/bson/codecs/record/RecordCodec.java index 8a304760f31..5bce0560233 100644 --- a/bson-record-codec/src/main/org/bson/codecs/record/RecordCodec.java +++ b/bson-record-codec/src/main/org/bson/codecs/record/RecordCodec.java @@ -16,6 +16,7 @@ package org.bson.codecs.record; +import org.bson.BsonInvalidOperationException; import org.bson.BsonReader; import org.bson.BsonType; import org.bson.BsonWriter; @@ -62,6 +63,7 @@ private static final class ComponentModel { private final Codec codec; private final int index; private final String fieldName; + private final boolean isNullable; private ComponentModel(final List typeParameters, final RecordComponent component, final CodecRegistry codecRegistry, final int index) { @@ -70,6 +72,7 @@ private ComponentModel(final List typeParameters, final RecordComponent co this.codec = computeCodec(typeParameters, component, codecRegistry); this.index = index; this.fieldName = computeFieldName(component); + this.isNullable = !component.getType().isPrimitive(); } String getComponentName() { @@ -275,6 +278,11 @@ public T decode(final BsonReader reader, final DecoderContext decoderContext) { if (LOGGER.isTraceEnabled()) { LOGGER.trace(format("Found property not present in the ClassModel: %s", fieldName)); } + } else if (reader.getCurrentBsonType() == BsonType.NULL) { + if (!componentModel.isNullable) { + throw new BsonInvalidOperationException(format("Null value on primitive field: %s", componentModel.fieldName)); + } + reader.readNull(); } else { constructorArguments[componentModel.index] = decoderContext.decodeWithChildContext(componentModel.codec, reader); } diff --git a/bson-record-codec/src/test/unit/org/bson/codecs/record/RecordCodecTest.java b/bson-record-codec/src/test/unit/org/bson/codecs/record/RecordCodecTest.java index 606bc68e59a..636554443fd 100644 --- a/bson-record-codec/src/test/unit/org/bson/codecs/record/RecordCodecTest.java +++ b/bson-record-codec/src/test/unit/org/bson/codecs/record/RecordCodecTest.java @@ -22,6 +22,8 @@ import org.bson.BsonDocumentWriter; import org.bson.BsonDouble; import org.bson.BsonInt32; +import org.bson.BsonInvalidOperationException; +import org.bson.BsonNull; import org.bson.BsonObjectId; import org.bson.BsonString; import org.bson.codecs.DecoderContext; @@ -49,6 +51,7 @@ import org.bson.codecs.record.samples.TestRecordWithMapOfRecords; import org.bson.codecs.record.samples.TestRecordWithNestedParameterized; import org.bson.codecs.record.samples.TestRecordWithNestedParameterizedRecord; +import org.bson.codecs.record.samples.TestRecordWithNullableField; import org.bson.codecs.record.samples.TestRecordWithParameterizedRecord; import org.bson.codecs.record.samples.TestRecordWithPojoAnnotations; import org.bson.codecs.record.samples.TestSelfReferentialHolderRecord; @@ -325,6 +328,35 @@ public void testRecordWithNulls() { assertEquals(testRecord, decoded); } + @Test + public void testRecordWithStoredNulls() { + var codec = createRecordCodec(TestRecordWithNullableField.class, Bson.DEFAULT_CODEC_REGISTRY); + var identifier = new ObjectId(); + var testRecord = new TestRecordWithNullableField(identifier, null, 42); + + var document = new BsonDocument("_id", new BsonObjectId(identifier)) + .append("name", new BsonNull()) + .append("age", new BsonInt32(42)); + + // when + var decoded = codec.decode(new BsonDocumentReader(document), DecoderContext.builder().build()); + + // then + assertEquals(testRecord, decoded); + } + + @Test + public void testExceptionsWithStoredNullsOnPrimitiveField() { + var codec = createRecordCodec(TestRecordWithNullableField.class, Bson.DEFAULT_CODEC_REGISTRY); + + var document = new BsonDocument("_id", new BsonObjectId(new ObjectId())) + .append("name", new BsonString("Felix")) + .append("age", new BsonNull()); + + assertThrows(BsonInvalidOperationException.class, () -> + codec.decode(new BsonDocumentReader(document), DecoderContext.builder().build())); + } + @Test public void testRecordWithExtraData() { var codec = createRecordCodec(TestRecordWithDeprecatedAnnotations.class, Bson.DEFAULT_CODEC_REGISTRY); diff --git a/bson-record-codec/src/test/unit/org/bson/codecs/record/samples/TestRecordWithNullableField.java b/bson-record-codec/src/test/unit/org/bson/codecs/record/samples/TestRecordWithNullableField.java new file mode 100644 index 00000000000..f2329c8170e --- /dev/null +++ b/bson-record-codec/src/test/unit/org/bson/codecs/record/samples/TestRecordWithNullableField.java @@ -0,0 +1,23 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * 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 + * + * http://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. + */ + +package org.bson.codecs.record.samples; + +import org.bson.codecs.pojo.annotations.BsonId; +import org.bson.types.ObjectId; + +public record TestRecordWithNullableField(@BsonId ObjectId id, String name, int age) { +} diff --git a/bson-scala/build.gradle b/bson-scala/build.gradle index e2c48a87d77..5d21ed521b5 100644 --- a/bson-scala/build.gradle +++ b/bson-scala/build.gradle @@ -18,7 +18,7 @@ description = "A Scala wrapper / extension to the bson library" archivesBaseName = 'mongo-scala-bson' dependencies { - implementation project(path: ':bson', configuration: 'default') + api project(path: ':bson', configuration: 'default') } sourceSets { @@ -58,7 +58,10 @@ test { maxParallelForks = 1 } -jar.manifest.attributes['Import-Package'] = [ - '!scala.*', - '*' -].join(',') +afterEvaluate { + jar.manifest.attributes['Automatic-Module-Name'] = 'org.mongodb.bson.scala' + jar.manifest.attributes['Import-Package'] = [ + '!scala.*', + '*' + ].join(',') +} diff --git a/bson/build.gradle b/bson/build.gradle index 005c1f92411..d2b2ed3ba0e 100644 --- a/bson/build.gradle +++ b/bson/build.gradle @@ -22,4 +22,7 @@ ext { pomURL = 'https://bsonspec.org' } -jar.manifest.attributes['Import-Package'] = 'org.slf4j.*;resolution:=optional' +afterEvaluate { + jar.manifest.attributes['Automatic-Module-Name'] = 'org.mongodb.bson' + jar.manifest.attributes['Import-Package'] = 'org.slf4j.*;resolution:=optional' +} diff --git a/bson/src/main/org/bson/codecs/AtomicIntegerCodec.java b/bson/src/main/org/bson/codecs/AtomicIntegerCodec.java index 8fd3e55876b..d8963ed40d7 100644 --- a/bson/src/main/org/bson/codecs/AtomicIntegerCodec.java +++ b/bson/src/main/org/bson/codecs/AtomicIntegerCodec.java @@ -21,7 +21,7 @@ import java.util.concurrent.atomic.AtomicInteger; -import static org.bson.codecs.NumberCodecHelper.decodeInt; +import static org.bson.internal.NumberCodecHelper.decodeInt; /** * Encodes and decodes {@code AtomicInteger} objects. diff --git a/bson/src/main/org/bson/codecs/AtomicLongCodec.java b/bson/src/main/org/bson/codecs/AtomicLongCodec.java index c6e053c6d9f..7f08af77961 100644 --- a/bson/src/main/org/bson/codecs/AtomicLongCodec.java +++ b/bson/src/main/org/bson/codecs/AtomicLongCodec.java @@ -21,7 +21,7 @@ import java.util.concurrent.atomic.AtomicLong; -import static org.bson.codecs.NumberCodecHelper.decodeLong; +import static org.bson.internal.NumberCodecHelper.decodeLong; /** * Encodes and decodes {@code AtomicLong} objects. diff --git a/bson/src/main/org/bson/codecs/ByteCodec.java b/bson/src/main/org/bson/codecs/ByteCodec.java index 26b5005ea66..e7011f8b58d 100644 --- a/bson/src/main/org/bson/codecs/ByteCodec.java +++ b/bson/src/main/org/bson/codecs/ByteCodec.java @@ -16,12 +16,10 @@ package org.bson.codecs; -import org.bson.BsonInvalidOperationException; import org.bson.BsonReader; import org.bson.BsonWriter; -import static java.lang.String.format; -import static org.bson.codecs.NumberCodecHelper.decodeInt; +import static org.bson.internal.NumberCodecHelper.decodeByte; /** * Encodes and decodes {@code Byte} objects. @@ -37,11 +35,7 @@ public void encode(final BsonWriter writer, final Byte value, final EncoderConte @Override public Byte decode(final BsonReader reader, final DecoderContext decoderContext) { - int value = decodeInt(reader); - if (value < Byte.MIN_VALUE || value > Byte.MAX_VALUE) { - throw new BsonInvalidOperationException(format("%s can not be converted into a Byte.", value)); - } - return (byte) value; + return decodeByte(reader); } @Override diff --git a/bson/src/main/org/bson/codecs/CharacterCodec.java b/bson/src/main/org/bson/codecs/CharacterCodec.java index 0a9e6252056..4ad6efa2663 100644 --- a/bson/src/main/org/bson/codecs/CharacterCodec.java +++ b/bson/src/main/org/bson/codecs/CharacterCodec.java @@ -16,11 +16,10 @@ package org.bson.codecs; -import org.bson.BsonInvalidOperationException; import org.bson.BsonReader; import org.bson.BsonWriter; +import org.bson.internal.StringCodecHelper; -import static java.lang.String.format; import static org.bson.assertions.Assertions.notNull; /** @@ -38,13 +37,7 @@ public void encode(final BsonWriter writer, final Character value, final Encoder @Override public Character decode(final BsonReader reader, final DecoderContext decoderContext) { - String string = reader.readString(); - if (string.length() != 1) { - throw new BsonInvalidOperationException(format("Attempting to decode the string '%s' to a character, but its length is not " - + "equal to one", string)); - } - - return string.charAt(0); + return StringCodecHelper.decodeChar(reader); } @Override diff --git a/bson/src/main/org/bson/codecs/DoubleCodec.java b/bson/src/main/org/bson/codecs/DoubleCodec.java index 523042bb163..33e3f6782bd 100644 --- a/bson/src/main/org/bson/codecs/DoubleCodec.java +++ b/bson/src/main/org/bson/codecs/DoubleCodec.java @@ -19,7 +19,7 @@ import org.bson.BsonReader; import org.bson.BsonWriter; -import static org.bson.codecs.NumberCodecHelper.decodeDouble; +import static org.bson.internal.NumberCodecHelper.decodeDouble; /** * Encodes and decodes {@code Double} objects. diff --git a/bson/src/main/org/bson/codecs/FloatCodec.java b/bson/src/main/org/bson/codecs/FloatCodec.java index 84b85c5aa1b..49dc7e22aff 100644 --- a/bson/src/main/org/bson/codecs/FloatCodec.java +++ b/bson/src/main/org/bson/codecs/FloatCodec.java @@ -16,12 +16,10 @@ package org.bson.codecs; -import org.bson.BsonInvalidOperationException; import org.bson.BsonReader; import org.bson.BsonWriter; -import static java.lang.String.format; -import static org.bson.codecs.NumberCodecHelper.decodeDouble; +import static org.bson.internal.NumberCodecHelper.decodeFloat; /** * Encodes and decodes {@code Float} objects. @@ -37,11 +35,7 @@ public void encode(final BsonWriter writer, final Float value, final EncoderCont @Override public Float decode(final BsonReader reader, final DecoderContext decoderContext) { - double value = decodeDouble(reader); - if (value < -Float.MAX_VALUE || value > Float.MAX_VALUE) { - throw new BsonInvalidOperationException(format("%s can not be converted into a Float.", value)); - } - return (float) value; + return decodeFloat(reader); } @Override diff --git a/bson/src/main/org/bson/codecs/IntegerCodec.java b/bson/src/main/org/bson/codecs/IntegerCodec.java index dee6e2512fb..bb0c5c082d5 100644 --- a/bson/src/main/org/bson/codecs/IntegerCodec.java +++ b/bson/src/main/org/bson/codecs/IntegerCodec.java @@ -19,7 +19,7 @@ import org.bson.BsonReader; import org.bson.BsonWriter; -import static org.bson.codecs.NumberCodecHelper.decodeInt; +import static org.bson.internal.NumberCodecHelper.decodeInt; /** * Encodes and decodes {@code Integer} objects. diff --git a/bson/src/main/org/bson/codecs/LongCodec.java b/bson/src/main/org/bson/codecs/LongCodec.java index 29adc373488..0e16e4430bc 100644 --- a/bson/src/main/org/bson/codecs/LongCodec.java +++ b/bson/src/main/org/bson/codecs/LongCodec.java @@ -19,7 +19,7 @@ import org.bson.BsonReader; import org.bson.BsonWriter; -import static org.bson.codecs.NumberCodecHelper.decodeLong; +import static org.bson.internal.NumberCodecHelper.decodeLong; /** * Encodes and decodes {@code Long} objects. diff --git a/bson/src/main/org/bson/codecs/ShortCodec.java b/bson/src/main/org/bson/codecs/ShortCodec.java index e5aaf8f9acb..8c439e36b8d 100644 --- a/bson/src/main/org/bson/codecs/ShortCodec.java +++ b/bson/src/main/org/bson/codecs/ShortCodec.java @@ -16,12 +16,10 @@ package org.bson.codecs; -import org.bson.BsonInvalidOperationException; import org.bson.BsonReader; import org.bson.BsonWriter; -import static java.lang.String.format; -import static org.bson.codecs.NumberCodecHelper.decodeInt; +import static org.bson.internal.NumberCodecHelper.decodeShort; /** * Encodes and decodes {@code Short} objects. @@ -37,11 +35,7 @@ public void encode(final BsonWriter writer, final Short value, final EncoderCont @Override public Short decode(final BsonReader reader, final DecoderContext decoderContext) { - int value = decodeInt(reader); - if (value < Short.MIN_VALUE || value > Short.MAX_VALUE) { - throw new BsonInvalidOperationException(format("%s can not be converted into a Short.", value)); - } - return (short) value; + return decodeShort(reader); } @Override diff --git a/bson/src/main/org/bson/codecs/NumberCodecHelper.java b/bson/src/main/org/bson/internal/NumberCodecHelper.java similarity index 78% rename from bson/src/main/org/bson/codecs/NumberCodecHelper.java rename to bson/src/main/org/bson/internal/NumberCodecHelper.java index 69dfe29ac7e..faf63e56eb5 100644 --- a/bson/src/main/org/bson/codecs/NumberCodecHelper.java +++ b/bson/src/main/org/bson/internal/NumberCodecHelper.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.bson.codecs; +package org.bson.internal; import org.bson.BsonInvalidOperationException; import org.bson.BsonReader; @@ -25,9 +25,28 @@ import static java.lang.String.format; -final class NumberCodecHelper { +/** + * This class is not part of the public API. It may be removed or changed at any time. + */ +public final class NumberCodecHelper { + + public static byte decodeByte(final BsonReader reader) { + int value = decodeInt(reader); + if (value < Byte.MIN_VALUE || value > Byte.MAX_VALUE) { + throw new BsonInvalidOperationException(format("%s can not be converted into a Byte.", value)); + } + return (byte) value; + } + + public static short decodeShort(final BsonReader reader) { + int value = decodeInt(reader); + if (value < Short.MIN_VALUE || value > Short.MAX_VALUE) { + throw new BsonInvalidOperationException(format("%s can not be converted into a Short.", value)); + } + return (short) value; + } - static int decodeInt(final BsonReader reader) { + public static int decodeInt(final BsonReader reader) { int intValue; BsonType bsonType = reader.getCurrentBsonType(); switch (bsonType) { @@ -61,7 +80,7 @@ static int decodeInt(final BsonReader reader) { return intValue; } - static long decodeLong(final BsonReader reader) { + public static long decodeLong(final BsonReader reader) { long longValue; BsonType bsonType = reader.getCurrentBsonType(); switch (bsonType) { @@ -91,7 +110,15 @@ static long decodeLong(final BsonReader reader) { return longValue; } - static double decodeDouble(final BsonReader reader) { + public static float decodeFloat(final BsonReader reader) { + double value = decodeDouble(reader); + if (value < -Float.MAX_VALUE || value > Float.MAX_VALUE) { + throw new BsonInvalidOperationException(format("%s can not be converted into a Float.", value)); + } + return (float) value; + } + + public static double decodeDouble(final BsonReader reader) { double doubleValue; BsonType bsonType = reader.getCurrentBsonType(); switch (bsonType) { diff --git a/bson/src/main/org/bson/internal/StringCodecHelper.java b/bson/src/main/org/bson/internal/StringCodecHelper.java new file mode 100644 index 00000000000..04225aad939 --- /dev/null +++ b/bson/src/main/org/bson/internal/StringCodecHelper.java @@ -0,0 +1,46 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * 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 + * + * http://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. + */ + +package org.bson.internal; + +import org.bson.BsonInvalidOperationException; +import org.bson.BsonReader; +import org.bson.BsonType; + +import static java.lang.String.format; + +/** + * This class is not part of the public API. It may be removed or changed at any time. + */ +public final class StringCodecHelper { + + private StringCodecHelper(){ + //NOP + } + + public static char decodeChar(final BsonReader reader) { + BsonType currentBsonType = reader.getCurrentBsonType(); + if (currentBsonType != BsonType.STRING) { + throw new BsonInvalidOperationException(format("Invalid string type, found: %s", currentBsonType)); + } + String string = reader.readString(); + if (string.length() != 1) { + throw new BsonInvalidOperationException(format("Attempting to decode the string '%s' to a character, but its length is not " + + "equal to one", string)); + } + return string.charAt(0); + } +} diff --git a/build.gradle b/build.gradle index ac216dda6b9..5ae3a8dc9e3 100644 --- a/build.gradle +++ b/build.gradle @@ -74,7 +74,7 @@ configure(coreProjects) { apply plugin: 'idea' group = 'org.mongodb' - version = '4.11.0' + version = '4.11.6-SNAPSHOT' repositories { mavenLocal() @@ -108,6 +108,7 @@ configure(javaProjects) { configure(scalaProjects) { apply plugin: 'scala' + apply plugin: 'java-library' apply plugin: 'idea' apply plugin: "com.adtran.scala-multiversion-plugin" apply plugin: "com.diffplug.spotless" @@ -115,8 +116,8 @@ configure(scalaProjects) { group = 'org.mongodb.scala' dependencies { - implementation ('org.scala-lang:scala-library:%scala-version%') - implementation ('org.scala-lang:scala-reflect:%scala-version%') + api ('org.scala-lang:scala-library:%scala-version%') + api ('org.scala-lang:scala-reflect:%scala-version%') testImplementation(platform("org.junit:junit-bom:$junitBomVersion")) testImplementation("org.junit.vintage:junit-vintage-engine") @@ -156,7 +157,8 @@ configure(scalaProjects) { "-feature", "-unchecked", "-language:reflectiveCalls", - "-Wconf:cat=deprecation:ws,any:e", + "-Wconf:cat=deprecation:ws", + "-Wconf:msg=While parsing annotations in:silent", "-Xlint:strict-unsealed-patmat" ] } diff --git a/config/spotbugs/exclude.xml b/config/spotbugs/exclude.xml index d35f0a81c8a..4320270826a 100644 --- a/config/spotbugs/exclude.xml +++ b/config/spotbugs/exclude.xml @@ -263,6 +263,12 @@ + + + + + +