diff --git a/.github/workflows/spring-batch-notion.yml b/.github/workflows/spring-batch-notion.yml
new file mode 100644
index 00000000..73c548c8
--- /dev/null
+++ b/.github/workflows/spring-batch-notion.yml
@@ -0,0 +1,99 @@
+name: Spring Batch Notion
+
+on:
+ pull_request:
+ paths:
+ - 'spring-batch-notion/**'
+ push:
+ paths:
+ - 'spring-batch-notion/**'
+
+defaults:
+ run:
+ working-directory: spring-batch-notion
+
+env:
+ MAVEN_ARGS: -B -V -ntp -e -Djansi.passthrough=true -Dstyle.color=always
+
+jobs:
+
+ java-lts:
+
+ name: Java ${{ matrix.java }}
+ strategy:
+ fail-fast: false
+ matrix:
+ java: [ 17, 21 ]
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Java
+ uses: actions/setup-java@v4
+ with:
+ java-version: ${{ matrix.java }}
+ distribution: 'zulu'
+ cache: maven
+ - name: Test
+ run: ./mvnw $MAVEN_ARGS verify
+
+ java-oracle:
+
+ name: Java ${{ matrix.java }}
+ strategy:
+ fail-fast: false
+ matrix:
+ java: [ 23, 24 ]
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Java
+ uses: oracle-actions/setup-java@v1
+ with:
+ website: jdk.java.net
+ release: ${{ matrix.java }}
+ version: latest
+ - name: Test
+ run: ./mvnw $MAVEN_ARGS verify
+
+ spring-boot:
+
+ name: Spring Boot ${{ matrix.spring }}
+ strategy:
+ fail-fast: false
+ matrix:
+ spring: [ 3.2.0, 3.2.10, 3.4.0-M3 ]
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Java
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'zulu'
+ cache: maven
+ - name: Set Spring Boot ${{ matrix.spring }}
+ run: >
+ ./mvnw $MAVEN_ARGS versions:update-parent
+ -DparentVersion=${{ matrix.spring }}
+ -DskipResolution
+ - name: Test
+ run: ./mvnw $MAVEN_ARGS -Pspring-milestone verify
+
+ javadoc:
+
+ name: Javadoc
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Java
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'zulu'
+ cache: maven
+ - name: Generate Javadoc
+ run: ./mvnw $MAVEN_ARGS javadoc:javadoc
diff --git a/spring-batch-elasticsearch/pom.xml b/spring-batch-elasticsearch/pom.xml
index 302aa897..7c14ff77 100644
--- a/spring-batch-elasticsearch/pom.xml
+++ b/spring-batch-elasticsearch/pom.xml
@@ -6,6 +6,8 @@
spring-batch-elasticsearch0.1.0-SNAPSHOT
+ Spring Batch Elasticsearch
+
UTF-81.8
diff --git a/spring-batch-notion/.editorconfig b/spring-batch-notion/.editorconfig
new file mode 100644
index 00000000..afe87677
--- /dev/null
+++ b/spring-batch-notion/.editorconfig
@@ -0,0 +1,13 @@
+root = true
+
+# Unix-style newlines with a newline ending every file
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+
+# Maven POM code convention
+[pom.xml]
+indent_size = 2
+indent_style = space
+max_line_length = 205
diff --git a/spring-batch-notion/.gitignore b/spring-batch-notion/.gitignore
new file mode 100644
index 00000000..5a71b37d
--- /dev/null
+++ b/spring-batch-notion/.gitignore
@@ -0,0 +1,32 @@
+### Maven ###
+target/
+.mvn/wrapper/maven-wrapper.jar
+.flattened-pom.xml
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/
diff --git a/spring-batch-notion/.mvn/wrapper/maven-wrapper.properties b/spring-batch-notion/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 00000000..d58dfb70
--- /dev/null
+++ b/spring-batch-notion/.mvn/wrapper/maven-wrapper.properties
@@ -0,0 +1,19 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you 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.
+wrapperVersion=3.3.2
+distributionType=only-script
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip
diff --git a/spring-batch-notion/README.md b/spring-batch-notion/README.md
new file mode 100644
index 00000000..1c1c7998
--- /dev/null
+++ b/spring-batch-notion/README.md
@@ -0,0 +1,97 @@
+# Spring Batch Notion [![Maven Central](https://img.shields.io/maven-central/v/io.github.scordio/spring-batch-notion?label=Maven%20Central)](https://mvnrepository.com/artifact/io.github.scordio/spring-batch-notion) [![javadoc](https://javadoc.io/badge2/io.github.scordio/spring-batch-notion/javadoc.svg)](https://javadoc.io/doc/io.github.scordio/spring-batch-notion)
+
+[![Spring Batch Notion](https://github.com/spring-projects/spring-batch-extensions/actions/workflows/spring-batch-notion.yml/badge.svg)](https://github.com/spring-projects/spring-batch-extensions/actions/workflows/spring-batch-notion.yml)
+
+This project provides a [Spring Batch][] extension module that adds support for [Notion][].
+
+## Compatibility
+
+Spring Batch Notion is based on Spring Batch 5 and tested on Spring Boot 3, thus requiring at least Java 17.
+
+Compatibility is guaranteed only with the Spring Boot versions under [OSS support](https://spring.io/projects/spring-boot/#support).
+
+## Getting Started
+
+### Maven
+
+```xml
+
+ org.springframework.batch.extensions
+ spring-batch-notion
+ ${spring-batch-notion.version}
+
+```
+
+### Gradle
+
+```kotlin
+implementation("org.springframework.batch.extensions:spring-batch-notion:${springBatchNotionVersion}")
+```
+
+## NotionDatabaseItemReader
+
+The `NotionDatabaseItemReader` is a restartable `ItemReader` that reads entries from a [Notion Database] via a paging technique.
+
+A minimal configuration of the item reader is as follows:
+
+```java
+NotionDatabaseItemReader itemReader() {
+ NotionDatabaseItemReader reader = new NotionDatabaseItemReader<>();
+ reader.setToken(System.getenv("NOTION_TOKEN"));
+ reader.setDatabaseId("XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"); // UUID
+ reader.setPropertiesMapper(new CustomPropertyMapper());
+ return reader;
+}
+```
+
+The following configuration options are available:
+
+| Property | Required | Default | Description |
+|------------------|----------|-----------------------------|---------------------------------------------------------------------------------------------------------------------------|
+| `baseUrl` | no | `https://api.notion.com/v1` | Base URL of the Notion API. A custom value can be provided for testing purposes (e.g., the URL of a [WireMock][] server). |
+| `databaseId` | yes | - | UUID of the database to read from. |
+| `filter` | no | `null` | `Filter` condition to limit the returned items. |
+| `pageSize` | no | `100` | Number of items to be read with each page. Must be greater than zero and less than or equal to 100. |
+| `propertyMapper` | yes | - | The `PropertyMapper` responsible for mapping properties of a Notion item into a Java object. |
+| `sorts` | no | `null` | `Sort` conditions to order the returned items. Each condition is applied following the declaration order. |
+| `token` | yes | - | The Notion integration token. |
+
+In addition to the Notion-specific configuration, all the configuration options of the Spring Batch
+[`AbstractPaginatedDataItemReader`](https://docs.spring.io/spring-batch/docs/current/api/org/springframework/batch/item/data/AbstractPaginatedDataItemReader.html)
+are supported.
+
+### PropertyMapper
+
+The `NotionDatabaseItemReader` requires a `PropertyMapper` to map the properties of a Notion item into an object.
+
+Currently, only properties of type [Title](https://developers.notion.com/reference/property-object#title)
+and [Rich Text](https://developers.notion.com/reference/property-object#rich-text) are supported,
+and both are converted to strings.
+
+The following `PropertyMapper` implementations are provided out of the box.
+
+| Name | Description |
+|-----------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `BeanWrapperPropertyMapper` | Supports JavaBeans. Requires a default constructor and expects the setter names to match the Notion item property names (case-insensitive). |
+| `ConstructorPropertyMapper` | Supports types with a constructor with arguments. Requires the constructor to be unique and its argument names to match the Notion item property names (case-insensitive). |
+| `RecordPropertyMapper` | Supports Java records. It uses the canonical constructor and requires the component names to match the Notion item property names (case-insensitive). |
+
+All implementations above offer two constructors:
+* One accepting the `Class` instance of the type to be mapped
+* One without parameters, for cases where the type to be mapped can be inferred by the generic type of the variable or method enclosing the constructor declaration
+
+In case none of the provided implementations is suitable, a custom one can be provided.
+
+## NotionDatabaseItemWriter
+
+Currently not provided but will be added in the future.
+
+## License
+
+The Spring Batch Notion is released under version 2.0 of the [Apache License][].
+
+[Apache License]: https://www.apache.org/licenses/LICENSE-2.0
+[Notion]: https://notion.so/
+[Notion Database]: https://www.notion.so/help/category/databases
+[Spring Batch]: https://github.com/spring-projects/spring-batch
+[WireMock]: https://wiremock.org/
diff --git a/spring-batch-notion/mvnw b/spring-batch-notion/mvnw
new file mode 100755
index 00000000..19529ddf
--- /dev/null
+++ b/spring-batch-notion/mvnw
@@ -0,0 +1,259 @@
+#!/bin/sh
+# ----------------------------------------------------------------------------
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you 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.
+# ----------------------------------------------------------------------------
+
+# ----------------------------------------------------------------------------
+# Apache Maven Wrapper startup batch script, version 3.3.2
+#
+# Optional ENV vars
+# -----------------
+# JAVA_HOME - location of a JDK home dir, required when download maven via java source
+# MVNW_REPOURL - repo url base for downloading maven distribution
+# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
+# ----------------------------------------------------------------------------
+
+set -euf
+[ "${MVNW_VERBOSE-}" != debug ] || set -x
+
+# OS specific support.
+native_path() { printf %s\\n "$1"; }
+case "$(uname)" in
+CYGWIN* | MINGW*)
+ [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
+ native_path() { cygpath --path --windows "$1"; }
+ ;;
+esac
+
+# set JAVACMD and JAVACCMD
+set_java_home() {
+ # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
+ 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"
+ JAVACCMD="$JAVA_HOME/jre/sh/javac"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ JAVACCMD="$JAVA_HOME/bin/javac"
+
+ if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
+ echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
+ echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
+ return 1
+ fi
+ fi
+ else
+ JAVACMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v java
+ )" || :
+ JAVACCMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v javac
+ )" || :
+
+ if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
+ echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
+ return 1
+ fi
+ fi
+}
+
+# hash string like Java String::hashCode
+hash_string() {
+ str="${1:-}" h=0
+ while [ -n "$str" ]; do
+ char="${str%"${str#?}"}"
+ h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
+ str="${str#?}"
+ done
+ printf %x\\n $h
+}
+
+verbose() { :; }
+[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
+
+die() {
+ printf %s\\n "$1" >&2
+ exit 1
+}
+
+trim() {
+ # MWRAPPER-139:
+ # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
+ # Needed for removing poorly interpreted newline sequences when running in more
+ # exotic environments such as mingw bash on Windows.
+ printf "%s" "${1}" | tr -d '[:space:]'
+}
+
+# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
+while IFS="=" read -r key value; do
+ case "${key-}" in
+ distributionUrl) distributionUrl=$(trim "${value-}") ;;
+ distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
+ esac
+done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties"
+[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties"
+
+case "${distributionUrl##*/}" in
+maven-mvnd-*bin.*)
+ MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
+ case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
+ *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
+ :Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
+ :Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
+ :Linux*x86_64*) distributionPlatform=linux-amd64 ;;
+ *)
+ echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
+ distributionPlatform=linux-amd64
+ ;;
+ esac
+ distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
+ ;;
+maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
+*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
+esac
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
+distributionUrlName="${distributionUrl##*/}"
+distributionUrlNameMain="${distributionUrlName%.*}"
+distributionUrlNameMain="${distributionUrlNameMain%-bin}"
+MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
+MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
+
+exec_maven() {
+ unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
+ exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
+}
+
+if [ -d "$MAVEN_HOME" ]; then
+ verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ exec_maven "$@"
+fi
+
+case "${distributionUrl-}" in
+*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
+*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
+esac
+
+# prepare tmp dir
+if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
+ clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
+ trap clean HUP INT TERM EXIT
+else
+ die "cannot create temp dir"
+fi
+
+mkdir -p -- "${MAVEN_HOME%/*}"
+
+# Download and Install Apache Maven
+verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+verbose "Downloading from: $distributionUrl"
+verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+# select .zip or .tar.gz
+if ! command -v unzip >/dev/null; then
+ distributionUrl="${distributionUrl%.zip}.tar.gz"
+ distributionUrlName="${distributionUrl##*/}"
+fi
+
+# verbose opt
+__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
+[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
+
+# normalize http auth
+case "${MVNW_PASSWORD:+has-password}" in
+'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+esac
+
+if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
+ verbose "Found wget ... using wget"
+ wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
+elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
+ verbose "Found curl ... using curl"
+ curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
+elif set_java_home; then
+ verbose "Falling back to use Java to download"
+ javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
+ targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
+ cat >"$javaSource" <<-END
+ public class Downloader extends java.net.Authenticator
+ {
+ protected java.net.PasswordAuthentication getPasswordAuthentication()
+ {
+ return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
+ }
+ public static void main( String[] args ) throws Exception
+ {
+ setDefault( new Downloader() );
+ java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
+ }
+ }
+ END
+ # For Cygwin/MinGW, switch paths to Windows format before running javac and java
+ verbose " - Compiling Downloader.java ..."
+ "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
+ verbose " - Running Downloader.java ..."
+ "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
+fi
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+if [ -n "${distributionSha256Sum-}" ]; then
+ distributionSha256Result=false
+ if [ "$MVN_CMD" = mvnd.sh ]; then
+ echo "Checksum validation is not supported for maven-mvnd." >&2
+ echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ elif command -v sha256sum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ elif command -v shasum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ else
+ echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
+ echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ fi
+ if [ $distributionSha256Result = false ]; then
+ echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
+ echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
+ exit 1
+ fi
+fi
+
+# unzip and move
+if command -v unzip >/dev/null; then
+ unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
+else
+ tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
+fi
+printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url"
+mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
+
+clean || :
+exec_maven "$@"
diff --git a/spring-batch-notion/mvnw.cmd b/spring-batch-notion/mvnw.cmd
new file mode 100644
index 00000000..249bdf38
--- /dev/null
+++ b/spring-batch-notion/mvnw.cmd
@@ -0,0 +1,149 @@
+<# : batch portion
+@REM ----------------------------------------------------------------------------
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements. See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership. The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License. You may obtain a copy of the License at
+@REM
+@REM http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied. See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM ----------------------------------------------------------------------------
+
+@REM ----------------------------------------------------------------------------
+@REM Apache Maven Wrapper startup batch script, version 3.3.2
+@REM
+@REM Optional ENV vars
+@REM MVNW_REPOURL - repo url base for downloading maven distribution
+@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
+@REM ----------------------------------------------------------------------------
+
+@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
+@SET __MVNW_CMD__=
+@SET __MVNW_ERROR__=
+@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
+@SET PSModulePath=
+@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
+ IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
+)
+@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
+@SET __MVNW_PSMODULEP_SAVE=
+@SET __MVNW_ARG0_NAME__=
+@SET MVNW_USERNAME=
+@SET MVNW_PASSWORD=
+@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*)
+@echo Cannot start maven from wrapper >&2 && exit /b 1
+@GOTO :EOF
+: end batch / begin powershell #>
+
+$ErrorActionPreference = "Stop"
+if ($env:MVNW_VERBOSE -eq "true") {
+ $VerbosePreference = "Continue"
+}
+
+# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
+$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
+if (!$distributionUrl) {
+ Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+}
+
+switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
+ "maven-mvnd-*" {
+ $USE_MVND = $true
+ $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
+ $MVN_CMD = "mvnd.cmd"
+ break
+ }
+ default {
+ $USE_MVND = $false
+ $MVN_CMD = $script -replace '^mvnw','mvn'
+ break
+ }
+}
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+if ($env:MVNW_REPOURL) {
+ $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" }
+ $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')"
+}
+$distributionUrlName = $distributionUrl -replace '^.*/',''
+$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
+$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain"
+if ($env:MAVEN_USER_HOME) {
+ $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain"
+}
+$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
+$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
+
+if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
+ Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+ exit $?
+}
+
+if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
+ Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
+}
+
+# prepare tmp dir
+$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
+$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
+$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
+trap {
+ if ($TMP_DOWNLOAD_DIR.Exists) {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+ }
+}
+
+New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
+
+# Download and Install Apache Maven
+Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+Write-Verbose "Downloading from: $distributionUrl"
+Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+$webclient = New-Object System.Net.WebClient
+if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
+ $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
+}
+[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
+$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
+if ($distributionSha256Sum) {
+ if ($USE_MVND) {
+ Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
+ }
+ Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
+ if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
+ Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
+ }
+}
+
+# unzip and move
+Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
+Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null
+try {
+ Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
+} catch {
+ if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
+ Write-Error "fail to move MAVEN_HOME"
+ }
+} finally {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+}
+
+Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
diff --git a/spring-batch-notion/pom.xml b/spring-batch-notion/pom.xml
new file mode 100644
index 00000000..c6f448dc
--- /dev/null
+++ b/spring-batch-notion/pom.xml
@@ -0,0 +1,295 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.3.4
+
+
+
+ org.springframework.batch.extensions
+ spring-batch-notion
+ 0.1.0-SNAPSHOT
+
+ Spring Batch Notion
+ Spring Batch extension for Notion
+ https://github.com/spring-projects/spring-batch-extensions/spring-batch-notion
+ 2023
+
+
+ Apache 2.0
+ https://www.apache.org/licenses/LICENSE-2.0.txt
+ repo
+
+
+
+
+
+ scordio
+ Stefano Cordio
+ stefano.cordio@gmail.com
+ https://github.com/scordio
+
+
+
+
+ https://github.com/spring-projects/spring-batch-extensions/spring-batch-notion
+
+
+
+ 17
+ 1.11.1
+
+
+
+
+
+ com.github.seratch
+ notion-sdk-jvm-core
+ ${notion-sdk-jvm.version}
+
+
+ com.github.seratch
+ notion-sdk-jvm-httpclient
+ ${notion-sdk-jvm.version}
+
+
+ com.github.seratch
+ notion-sdk-jvm-slf4j2
+ ${notion-sdk-jvm.version}
+
+
+ org.springframework
+ spring-beans
+
+
+ org.springframework.batch
+ spring-batch-infrastructure
+
+
+
+ com.h2database
+ h2
+ test
+
+
+ com.tngtech.archunit
+ archunit-junit5
+ 1.3.0
+ test
+
+
+ org.springframework.batch
+ spring-batch-test
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-batch
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.wiremock.integrations
+ wiremock-spring-boot
+ 3.0.2
+ test
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-javadoc-plugin
+
+
+
+ com.google.code.findbugs
+ jsr305
+ 3.0.2
+
+
+ true
+
+ https://docs.spring.io/spring-batch/docs/${spring-batch.version}/api/
+ https://docs.spring.io/spring-framework/docs/${spring-framework.version}/api/
+
+
+
+
+ org.jreleaser
+ jreleaser-maven-plugin
+ 1.14.0
+
+
+
+
+
+ com.mycila
+ license-maven-plugin
+ 4.6
+
+
+
+
+
+ src/**/*.java
+
+
+
+
+ 2024
+
+
+
+
+
+ check
+
+
+
+
+
+ io.spring.javaformat
+ spring-javaformat-maven-plugin
+ 0.0.43
+
+
+
+ validate
+
+
+
+
+
+ org.codehaus.mojo
+ flatten-maven-plugin
+ 1.6.0
+
+
+ flatten
+ process-resources
+
+ flatten
+
+
+ ossrh
+
+ remove
+
+
+
+
+ flatten-clean
+ clean
+
+ clean
+
+
+
+
+
+
+
+
+
+ release
+
+ local::file:./target/staging-deploy
+
+
+
+
+ org.apache.maven.plugins
+ maven-javadoc-plugin
+
+
+ attach-javadoc
+
+ jar
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-source-plugin
+
+
+ attach-source
+
+ jar-no-fork
+
+
+
+
+
+ org.jreleaser
+ jreleaser-maven-plugin
+
+
+ deploy
+
+ deploy
+
+
+
+
+ ALWAYS
+ true
+
+
+
+
+
+ ALWAYS
+ https://s01.oss.sonatype.org/service/local
+ true
+ true
+ target/staging-deploy
+
+
+
+
+
+
+
+
+
+
+
+
+
+ spring-milestone
+
+
+ spring-milestone
+ Spring Milestones
+ https://repo.spring.io/milestone
+
+
+
+
+
diff --git a/spring-batch-notion/src/main/java/org/springframework/batch/extensions/notion/Filter.java b/spring-batch-notion/src/main/java/org/springframework/batch/extensions/notion/Filter.java
new file mode 100644
index 00000000..29b052b9
--- /dev/null
+++ b/spring-batch-notion/src/main/java/org/springframework/batch/extensions/notion/Filter.java
@@ -0,0 +1,613 @@
+/*
+ * Copyright 2002-2024 the original author or 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.
+ */
+package org.springframework.batch.extensions.notion;
+
+import notion.api.v1.model.databases.query.filter.CompoundFilterElement;
+import notion.api.v1.model.databases.query.filter.QueryTopLevelFilter;
+import notion.api.v1.model.databases.query.filter.condition.CheckboxFilter;
+import notion.api.v1.model.databases.query.filter.condition.MultiSelectFilter;
+import notion.api.v1.model.databases.query.filter.condition.NumberFilter;
+import notion.api.v1.model.databases.query.filter.condition.SelectFilter;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.function.BiConsumer;
+import java.util.function.BiFunction;
+import java.util.function.Consumer;
+
+/**
+ * Filtering conditions to limit the entries returned from a database query.
+ *
+ * Filters operate on property values or entry timestamps, and can be combined.
+ *
+ * A filter definition starts with {@link #where()}, entry point for a fluent API that
+ * mimics the database filter option in the Notion UI.
+ */
+public abstract sealed class Filter {
+
+ /**
+ * Entry point that starts the definition of a filter.
+ * @return a new {@link FilterConditionBuilder} instance
+ */
+ public static FilterConditionBuilder where() {
+ return new FilterConditionBuilder<>(PropertyFilter::new);
+ }
+
+ /**
+ * Entry point that starts the definition of a filter group.
+ * @param filter the filter representing the group
+ * @return a new {@link TopLevelFilter} instance that delegates to the given filter
+ */
+ public static TopLevelFilter where(Filter filter) {
+ return new DelegateFilter(filter);
+ }
+
+ private Filter() {
+ }
+
+ abstract QueryTopLevelFilter toQueryTopLevelFilter();
+
+ abstract CompoundFilterElement toCompoundFilterElement();
+
+ /**
+ * Base class for top level filters that support filters composition via the
+ * {@link TopLevelFilter#and} and {@link TopLevelFilter#or} methods.
+ *
+ * @see AndFilter
+ * @see OrFilter
+ */
+ public static abstract sealed class TopLevelFilter extends Filter {
+
+ private TopLevelFilter() {
+ }
+
+ /**
+ * Start the definition of a new filter that is composed with the current filter
+ * via a logical {@code and}.
+ * @return a {@link FilterConditionBuilder} instance for an {@link AndFilter}
+ */
+ public FilterConditionBuilder and() {
+ return new FilterConditionBuilder<>(
+ (property, customizer) -> new AndFilter(this, new PropertyFilter(property, customizer)));
+ }
+
+ /**
+ * Compose the current filter and the given filter via a logical {@code and}.
+ * @param filter the filter to compose with the current filter
+ * @return a new {@link AndFilter} instance
+ */
+ public AndFilter and(Filter filter) {
+ return new AndFilter(this, Objects.requireNonNull(filter));
+ }
+
+ /**
+ * Start the definition of a new filter that is composed with the current filter
+ * via a logical {@code or}.
+ * @return a {@link FilterConditionBuilder} instance for an {@link OrFilter}
+ */
+ public FilterConditionBuilder or() {
+ return new FilterConditionBuilder<>(
+ (property, customizer) -> new OrFilter(this, new PropertyFilter(property, customizer)));
+ }
+
+ /**
+ * Compose the current filter and the given filter via a logical {@code or}.
+ * @param filter the filter to compose with the current filter
+ * @return a new {@link OrFilter} instance
+ */
+ public OrFilter or(Filter filter) {
+ return new OrFilter(this, Objects.requireNonNull(filter));
+ }
+
+ }
+
+ private static final class DelegateFilter extends TopLevelFilter {
+
+ private final Filter delegate;
+
+ private DelegateFilter(Filter delegate) {
+ this.delegate = Objects.requireNonNull(delegate);
+ }
+
+ @Override
+ QueryTopLevelFilter toQueryTopLevelFilter() {
+ return delegate.toQueryTopLevelFilter();
+ }
+
+ @Override
+ CompoundFilterElement toCompoundFilterElement() {
+ return delegate.toCompoundFilterElement();
+ }
+
+ }
+
+ private static final class PropertyFilter extends TopLevelFilter {
+
+ private final String property;
+
+ private final NotionPropertyFilterCustomizer customizer;
+
+ private PropertyFilter(String property, NotionPropertyFilterCustomizer customizer) {
+ this.property = property;
+ this.customizer = customizer;
+ }
+
+ @Override
+ QueryTopLevelFilter toQueryTopLevelFilter() {
+ return toNotionPropertyFilter();
+ }
+
+ @Override
+ CompoundFilterElement toCompoundFilterElement() {
+ return toNotionPropertyFilter();
+ }
+
+ private notion.api.v1.model.databases.query.filter.PropertyFilter toNotionPropertyFilter() {
+ var notionPropertyFilter = new notion.api.v1.model.databases.query.filter.PropertyFilter(property);
+ customizer.accept(notionPropertyFilter);
+ return notionPropertyFilter;
+ }
+
+ }
+
+ @FunctionalInterface
+ private interface NotionPropertyFilterFactory
+ extends BiFunction {
+
+ }
+
+ @FunctionalInterface
+ private interface NotionPropertyFilterCustomizer
+ extends Consumer {
+
+ }
+
+ static abstract sealed class CompoundFilter extends Filter {
+
+ final List filters = new ArrayList<>();
+
+ private final NotionCompoundFilterSetter setter;
+
+ private CompoundFilter(NotionCompoundFilterSetter setter) {
+ this.setter = setter;
+ }
+
+ @Override
+ QueryTopLevelFilter toQueryTopLevelFilter() {
+ return toNotionCompoundFilter();
+ }
+
+ @Override
+ CompoundFilterElement toCompoundFilterElement() {
+ return toNotionCompoundFilter();
+ }
+
+ private notion.api.v1.model.databases.query.filter.CompoundFilter toNotionCompoundFilter() {
+ var notionCompoundFilter = new notion.api.v1.model.databases.query.filter.CompoundFilter();
+ var notionCompoundFilterElements = filters.stream().map(Filter::toCompoundFilterElement).toList();
+ setter.accept(notionCompoundFilter, notionCompoundFilterElements);
+ return notionCompoundFilter;
+ }
+
+ }
+
+ @FunctionalInterface
+ private interface NotionCompoundFilterSetter
+ extends BiConsumer> {
+
+ }
+
+ /**
+ * Compound filter that supports filters composition via the {@link AndFilter#and}
+ * methods.
+ *
+ * Returns entries that match all of the provided filters.
+ */
+ public static final class AndFilter extends CompoundFilter {
+
+ private AndFilter(Filter first, Filter second) {
+ super(notion.api.v1.model.databases.query.filter.CompoundFilter::setAnd);
+ filters.addAll(List.of(first, second));
+ }
+
+ /**
+ * Start the definition of a new filter that is composed with the current filter
+ * via a logical {@code and}.
+ * @return a {@link FilterConditionBuilder} instance for an {@link AndFilter}
+ */
+ public FilterConditionBuilder and() {
+ return new FilterConditionBuilder<>((property, customizer) -> {
+ filters.add(new PropertyFilter(property, customizer));
+ return this;
+ });
+ }
+
+ /**
+ * Compose the current filter and the given filter via a logical {@code and}.
+ * @param filter the filter to compose with the current filter
+ * @return a new {@link AndFilter} instance
+ */
+ public AndFilter and(Filter filter) {
+ filters.add(Objects.requireNonNull(filter));
+ return this;
+ }
+
+ }
+
+ /**
+ * Compound filter that supports filters composition via the {@link OrFilter#or}
+ * methods.
+ *
+ * Returns entries that match any of the provided filters.
+ */
+ public static final class OrFilter extends CompoundFilter {
+
+ private OrFilter(Filter first, Filter second) {
+ super(notion.api.v1.model.databases.query.filter.CompoundFilter::setOr);
+ filters.addAll(List.of(first, second));
+ }
+
+ /**
+ * Start the definition of a new filter that is composed with the current filter
+ * via a logical {@code or}.
+ * @return a {@link FilterConditionBuilder} instance for an {@link OrFilter}
+ */
+ public FilterConditionBuilder or() {
+ return new FilterConditionBuilder<>((property, customizer) -> {
+ filters.add(new PropertyFilter(property, customizer));
+ return this;
+ });
+ }
+
+ /**
+ * Compose the current filter and the given filter via a logical {@code or}.
+ * @param filter the filter to compose with the current filter
+ * @return a new {@link OrFilter} instance
+ */
+ public OrFilter or(Filter filter) {
+ filters.add(Objects.requireNonNull(filter));
+ return this;
+ }
+
+ }
+
+ /**
+ * Builder for {@link Filter} conditions.
+ *
+ * @param the type of the target {@code Filter}
+ */
+ public static final class FilterConditionBuilder {
+
+ private final NotionPropertyFilterFactory factory;
+
+ private FilterConditionBuilder(NotionPropertyFilterFactory factory) {
+ this.factory = factory;
+ }
+
+ /**
+ * Start the definition of the filter condition for a {@code checkbox} property.
+ * @param property The name of the property as it appears in the database, or the
+ * property ID
+ * @return a new {@link CheckboxCondition} instance
+ */
+ public CheckboxCondition checkbox(String property) {
+ return new CheckboxCondition<>(property, factory);
+ }
+
+ /**
+ * Start the definition of the filter condition for a {@code multi-select}
+ * property.
+ * @param property The name of the property as it appears in the database, or the
+ * property ID
+ * @return a new {@link MultiSelectCondition} instance
+ */
+ public MultiSelectCondition multiSelect(String property) {
+ return new MultiSelectCondition<>(property, factory);
+ }
+
+ /**
+ * Start the definition of the filter condition for a {@code number} property.
+ * @param property The name of the property as it appears in the database, or the
+ * property ID
+ * @return a new {@link NumberCondition} instance
+ */
+ public NumberCondition number(String property) {
+ return new NumberCondition<>(property, factory);
+ }
+
+ /**
+ * Start the definition of the filter condition for a {@code select} property.
+ * @param property The name of the property as it appears in the database, or the
+ * property ID
+ * @return a new {@link SelectCondition} instance
+ */
+ public SelectCondition select(String property) {
+ return new SelectCondition<>(property, factory);
+ }
+
+ static abstract sealed class Condition {
+
+ private final String property;
+
+ private final NotionPropertyFilterFactory factory;
+
+ private Condition(String property, NotionPropertyFilterFactory factory) {
+ this.property = property;
+ this.factory = factory;
+ }
+
+ T toFilter(NotionPropertyFilterCustomizer customizer) {
+ return factory.apply(property, customizer);
+ }
+
+ }
+
+ /**
+ * Filter condition for a {@code checkbox} property.
+ *
+ * @param the type of the target filter
+ */
+ public static final class CheckboxCondition extends Condition {
+
+ private CheckboxCondition(String property, NotionPropertyFilterFactory factory) {
+ super(property, factory);
+ }
+
+ /**
+ * Return all database entries with an exact value match.
+ * @param value the value to match
+ * @return a filter with the newly defined condition
+ */
+ public T isEqualTo(boolean value) {
+ CheckboxFilter checkboxFilter = new CheckboxFilter();
+ checkboxFilter.setEquals(value);
+ return toFilter(notionPropertyFilter -> notionPropertyFilter.setCheckbox(checkboxFilter));
+ }
+
+ /**
+ * Return all database entries without an exact value match.
+ * @param value the value to differ with
+ * @return a filter with the newly defined condition
+ */
+ public T isNotEqualTo(boolean value) {
+ CheckboxFilter checkboxFilter = new CheckboxFilter();
+ checkboxFilter.setDoesNotEqual(value);
+ return toFilter(notionPropertyFilter -> notionPropertyFilter.setCheckbox(checkboxFilter));
+ }
+
+ }
+
+ /**
+ * Filter condition for a {@code multi-select} property.
+ *
+ * @param the type of the target filter
+ */
+ public static final class MultiSelectCondition extends Condition {
+
+ private MultiSelectCondition(String property, NotionPropertyFilterFactory factory) {
+ super(property, factory);
+ }
+
+ /**
+ * Return database entries where the provided value is part of the property
+ * values.
+ * @param value the value to compare the property values against
+ * @return a filter with the newly defined condition
+ */
+ public T contains(String value) {
+ MultiSelectFilter multiSelectFilter = new MultiSelectFilter();
+ multiSelectFilter.setContains(value);
+ return toFilter(notionPropertyFilter -> notionPropertyFilter.setMultiSelect(multiSelectFilter));
+ }
+
+ /**
+ * Return database entries where the provided value is not contained in the
+ * property values.
+ * @param value the value to compare the property values against
+ * @return a filter with the newly defined condition
+ */
+ public T doesNotContain(String value) {
+ MultiSelectFilter multiSelectFilter = new MultiSelectFilter();
+ multiSelectFilter.setDoesNotContain(value);
+ return toFilter(notionPropertyFilter -> notionPropertyFilter.setMultiSelect(multiSelectFilter));
+ }
+
+ /**
+ * Return database entries where the property value does not contain any data.
+ * @return a filter with the newly defined condition
+ */
+ public T isEmpty() {
+ MultiSelectFilter multiSelectFilter = new MultiSelectFilter();
+ multiSelectFilter.setEmpty(true);
+ return toFilter(notionPropertyFilter -> notionPropertyFilter.setMultiSelect(multiSelectFilter));
+ }
+
+ /**
+ * Return database entries where the property value contains data.
+ * @return a filter with the newly defined condition
+ */
+ public T isNotEmpty() {
+ MultiSelectFilter multiSelectFilter = new MultiSelectFilter();
+ multiSelectFilter.setNotEmpty(true);
+ return toFilter(notionPropertyFilter -> notionPropertyFilter.setMultiSelect(multiSelectFilter));
+ }
+
+ }
+
+ /**
+ * Filter condition for a {@code number} property.
+ *
+ * @param the type of the target filter
+ */
+ public static final class NumberCondition extends Condition {
+
+ private NumberCondition(String property, NotionPropertyFilterFactory factory) {
+ super(property, factory);
+ }
+
+ /**
+ * Return database entries where the property value is the same as the
+ * provided one.
+ * @param value the value to compare the property value against
+ * @return a filter with the newly defined condition
+ */
+ public T isEqualTo(int value) {
+ NumberFilter numberFilter = new NumberFilter();
+ numberFilter.setEquals(value);
+ return toFilter(notionPropertyFilter -> notionPropertyFilter.setNumber(numberFilter));
+ }
+
+ /**
+ * Return database entries where the property value differs from the provided
+ * one.
+ * @param value the value to compare the property value against
+ * @return a filter with the newly defined condition
+ */
+ public T isNotEqualTo(int value) {
+ NumberFilter numberFilter = new NumberFilter();
+ numberFilter.setDoesNotEqual(value);
+ return toFilter(notionPropertyFilter -> notionPropertyFilter.setNumber(numberFilter));
+ }
+
+ /**
+ * Return database entries where the property value exceeds the provided one.
+ * @param value the value to compare the property value against
+ * @return a filter with the newly defined condition
+ */
+ public T isGreaterThan(int value) {
+ NumberFilter numberFilter = new NumberFilter();
+ numberFilter.setGreaterThan(value);
+ return toFilter(notionPropertyFilter -> notionPropertyFilter.setNumber(numberFilter));
+ }
+
+ /**
+ * Return database entries where the property value is equal to or exceeds the
+ * provided one.
+ * @param value the value to compare the property value against
+ * @return a filter with the newly defined condition
+ */
+ public T isGreaterThanOrEqualTo(int value) {
+ NumberFilter numberFilter = new NumberFilter();
+ numberFilter.setGreaterThanOrEqualTo(value);
+ return toFilter(notionPropertyFilter -> notionPropertyFilter.setNumber(numberFilter));
+ }
+
+ /**
+ * Return database entries where the property value is less than the provided
+ * one.
+ * @param value the value to compare the property value against
+ * @return a filter with the newly defined condition
+ */
+ public T isLessThan(int value) {
+ NumberFilter numberFilter = new NumberFilter();
+ numberFilter.setLessThan(value);
+ return toFilter(notionPropertyFilter -> notionPropertyFilter.setNumber(numberFilter));
+ }
+
+ /**
+ * Return database entries where the property value is equal to or is less
+ * than the provided one.
+ * @param value the value to compare the property value against
+ * @return a filter with the newly defined condition
+ */
+ public T isLessThanOrEqualTo(int value) {
+ NumberFilter numberFilter = new NumberFilter();
+ numberFilter.setLessThanOrEqualTo(value);
+ return toFilter(notionPropertyFilter -> notionPropertyFilter.setNumber(numberFilter));
+ }
+
+ /**
+ * Return database entries where the property value does not contain any data.
+ * @return a filter with the newly defined condition
+ */
+ public T isEmpty() {
+ NumberFilter numberFilter = new NumberFilter();
+ numberFilter.setEmpty(true);
+ return toFilter(notionPropertyFilter -> notionPropertyFilter.setNumber(numberFilter));
+ }
+
+ /**
+ * Return database entries where the property value contains data.
+ * @return a filter with the newly defined condition
+ */
+ public T isNotEmpty() {
+ NumberFilter numberFilter = new NumberFilter();
+ numberFilter.setNotEmpty(true);
+ return toFilter(notionPropertyFilter -> notionPropertyFilter.setNumber(numberFilter));
+ }
+
+ }
+
+ /**
+ * Filter condition for a {@code select} property.
+ *
+ * @param the type of the target filter
+ */
+ public static final class SelectCondition extends Condition {
+
+ private SelectCondition(String property, NotionPropertyFilterFactory factory) {
+ super(property, factory);
+ }
+
+ /**
+ * Return database entries where the property value matches the provided one.
+ * @param value the value to compare the property value against
+ * @return a filter with the newly defined condition
+ */
+ public T isEqualTo(String value) {
+ SelectFilter selectFilter = new SelectFilter();
+ selectFilter.setEquals(value);
+ return toFilter(notionPropertyFilter -> notionPropertyFilter.setSelect(selectFilter));
+ }
+
+ /**
+ * Return database entries where the property value does not match the
+ * provided one.
+ * @param value the value to compare the property value against
+ * @return a filter with the newly defined condition
+ */
+ public T isNotEqualTo(String value) {
+ SelectFilter selectFilter = new SelectFilter();
+ selectFilter.setDoesNotEqual(value);
+ return toFilter(notionPropertyFilter -> notionPropertyFilter.setSelect(selectFilter));
+ }
+
+ /**
+ * Return database entries where the property value does not contain any data.
+ * @return a filter with the newly defined condition
+ */
+ public T isEmpty() {
+ SelectFilter selectFilter = new SelectFilter();
+ selectFilter.setEmpty(true);
+ return toFilter(notionPropertyFilter -> notionPropertyFilter.setSelect(selectFilter));
+ }
+
+ /**
+ * Return database entries where the property value contains data.
+ * @return a filter with the newly defined condition
+ */
+ public T isNotEmpty() {
+ SelectFilter selectFilter = new SelectFilter();
+ selectFilter.setNotEmpty(true);
+ return toFilter(notionPropertyFilter -> notionPropertyFilter.setSelect(selectFilter));
+ }
+
+ }
+
+ }
+
+}
diff --git a/spring-batch-notion/src/main/java/org/springframework/batch/extensions/notion/NotionDatabaseItemReader.java b/spring-batch-notion/src/main/java/org/springframework/batch/extensions/notion/NotionDatabaseItemReader.java
new file mode 100644
index 00000000..5b26153d
--- /dev/null
+++ b/spring-batch-notion/src/main/java/org/springframework/batch/extensions/notion/NotionDatabaseItemReader.java
@@ -0,0 +1,266 @@
+/*
+ * Copyright 2002-2024 the original author or 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.
+ */
+package org.springframework.batch.extensions.notion;
+
+import org.springframework.batch.extensions.notion.mapping.PropertyMapper;
+import notion.api.v1.NotionClient;
+import notion.api.v1.http.JavaNetHttpClient;
+import notion.api.v1.logging.Slf4jLogger;
+import notion.api.v1.model.databases.QueryResults;
+import notion.api.v1.model.databases.query.filter.QueryTopLevelFilter;
+import notion.api.v1.model.databases.query.sort.QuerySort;
+import notion.api.v1.model.pages.Page;
+import notion.api.v1.model.pages.PageProperty;
+import notion.api.v1.model.pages.PageProperty.RichText;
+import notion.api.v1.request.databases.QueryDatabaseRequest;
+import org.springframework.batch.item.ExecutionContext;
+import org.springframework.batch.item.ItemReader;
+import org.springframework.batch.item.data.AbstractPaginatedDataItemReader;
+import org.springframework.beans.factory.InitializingBean;
+import org.springframework.util.Assert;
+
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Objects;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * Restartable {@link ItemReader} that reads entries from a Notion database via a paging
+ * technique.
+ *
+ * The query is executed using paged requests of a size specified in
+ * {@link #setPageSize(int)}, which defaults to {@value #DEFAULT_PAGE_SIZE}. Additional
+ * pages are requested as needed when the {@link #read()} method is called. On restart,
+ * the reader will begin again at the same number item it left off at.
+ *
+ * This implementation is thread-safe between calls to {@link #open(ExecutionContext)},
+ * but remember to set saveState to false if used in a
+ * multi-threaded environment (no restart available).
+ *
+ * @param Type of item to be read
+ */
+public class NotionDatabaseItemReader extends AbstractPaginatedDataItemReader implements InitializingBean {
+
+ private static final String DEFAULT_BASE_URL = "https://api.notion.com/v1";
+
+ private static final int DEFAULT_PAGE_SIZE = 100;
+
+ private String baseUrl;
+
+ private String token;
+
+ private String databaseId;
+
+ private PropertyMapper propertyMapper;
+
+ private QueryTopLevelFilter filter;
+
+ private List sorts;
+
+ private NotionClient client;
+
+ private boolean hasMore;
+
+ private String nextCursor;
+
+ /**
+ * Create a new {@link NotionDatabaseItemReader} with the following defaults:
+ *
+ *
{@code baseUrl} = {@value #DEFAULT_BASE_URL}
+ *
{@code pageSize} = {@value #DEFAULT_PAGE_SIZE}
+ *
+ */
+ public NotionDatabaseItemReader() {
+ this.baseUrl = DEFAULT_BASE_URL;
+ this.pageSize = DEFAULT_PAGE_SIZE;
+ }
+
+ /**
+ * The base URL of the Notion API.
+ *
+ * Defaults to {@value #DEFAULT_BASE_URL}.
+ *
+ * A custom value can be provided for testing purposes (e.g., the URL of a WireMock
+ * server).
+ * @param baseUrl the base URL
+ */
+ public void setBaseUrl(String baseUrl) {
+ this.baseUrl = Objects.requireNonNull(baseUrl);
+ }
+
+ /**
+ * The Notion integration token.
+ *
+ * Always required.
+ * @param token the token
+ */
+ public void setToken(String token) {
+ this.token = Objects.requireNonNull(token);
+ }
+
+ /**
+ * UUID of the database to read from.
+ *
+ * Always required.
+ * @param databaseId the database UUID
+ */
+ public void setDatabaseId(String databaseId) {
+ this.databaseId = Objects.requireNonNull(databaseId);
+ }
+
+ /**
+ * The {@link PropertyMapper} responsible for mapping Notion item properties into a
+ * Java object.
+ *
+ * Always required.
+ * @param propertyMapper the property mapper
+ */
+ public void setPropertyMapper(PropertyMapper propertyMapper) {
+ this.propertyMapper = Objects.requireNonNull(propertyMapper);
+ }
+
+ /**
+ * {@link Filter} condition to limit the returned items.
+ *
+ * If no filter is provided, all the items in the database will be returned.
+ * @param filter the {@link Filter} conditions
+ * @see Filter#where()
+ * @see Filter#where(Filter)
+ */
+ public void setFilter(Filter filter) {
+ this.filter = filter.toQueryTopLevelFilter();
+ }
+
+ /**
+ * {@link Sort} conditions to order the returned items.
+ *
+ * Each condition is applied following the declaration order, i.e., earlier sorts take
+ * precedence over later ones.
+ * @param sorts the {@link Sort} conditions
+ * @see Sort#by(String)
+ * @see Sort#by(Sort.Timestamp)
+ */
+ public void setSorts(Sort... sorts) {
+ this.sorts = Stream.of(sorts).map(Sort::toQuerySort).toList();
+ }
+
+ /**
+ * The number of items to be read with each page.
+ *
+ * Defaults to {@value #DEFAULT_PAGE_SIZE}.
+ * @param pageSize the number of items. Must be greater than 0 and less than or equal
+ * to 100.
+ */
+ @Override
+ public void setPageSize(int pageSize) {
+ Assert.isTrue(pageSize <= 100, "pageSize must be less than or equal to 100");
+ super.setPageSize(pageSize);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ protected Iterator doPageRead() {
+ if (!hasMore) {
+ return null;
+ }
+
+ QueryDatabaseRequest request = new QueryDatabaseRequest(databaseId);
+ request.setFilter(filter);
+ request.setSorts(sorts);
+ request.setStartCursor(nextCursor);
+ request.setPageSize(pageSize);
+
+ QueryResults queryResults = client.queryDatabase(request);
+
+ hasMore = queryResults.getHasMore();
+ nextCursor = queryResults.getNextCursor();
+
+ return queryResults.getResults()
+ .stream()
+ .map(NotionDatabaseItemReader::getProperties)
+ .map(properties -> propertyMapper.map(properties))
+ .iterator();
+ }
+
+ private static Map getProperties(Page element) {
+ return element.getProperties()
+ .entrySet()
+ .stream()
+ .collect(Collectors.toUnmodifiableMap(Entry::getKey, entry -> getPropertyValue(entry.getValue())));
+ }
+
+ private static String getPropertyValue(PageProperty property) {
+ return switch (property.getType()) {
+ case RichText -> getPlainText(property.getRichText());
+ case Title -> getPlainText(property.getTitle());
+ default -> throw new IllegalArgumentException("Unsupported type: " + property.getType());
+ };
+ }
+
+ private static String getPlainText(List texts) {
+ return texts.isEmpty() ? "" : texts.get(0).getPlainText();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ protected void doOpen() {
+ client = new NotionClient(token);
+ client.setHttpClient(new JavaNetHttpClient());
+ client.setLogger(new Slf4jLogger());
+ client.setBaseUrl(baseUrl);
+
+ hasMore = true;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ protected void doClose() {
+ client.close();
+ client = null;
+
+ hasMore = false;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ protected void jumpToItem(int itemIndex) throws Exception {
+ for (int i = 0; i < itemIndex; i++) {
+ read();
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void afterPropertiesSet() {
+ Assert.state(token != null, "'token' must be set");
+ Assert.state(databaseId != null, "'databaseId' must be set");
+ Assert.state(propertyMapper != null, "'propertyMapper' must be set");
+ }
+
+}
diff --git a/spring-batch-notion/src/main/java/org/springframework/batch/extensions/notion/Sort.java b/spring-batch-notion/src/main/java/org/springframework/batch/extensions/notion/Sort.java
new file mode 100644
index 00000000..ac54e93e
--- /dev/null
+++ b/spring-batch-notion/src/main/java/org/springframework/batch/extensions/notion/Sort.java
@@ -0,0 +1,184 @@
+/*
+ * Copyright 2002-2024 the original author or 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.
+ */
+package org.springframework.batch.extensions.notion;
+
+import notion.api.v1.model.databases.query.sort.QuerySort;
+import notion.api.v1.model.databases.query.sort.QuerySortDirection;
+import notion.api.v1.model.databases.query.sort.QuerySortTimestamp;
+
+import java.util.Objects;
+
+/**
+ * Sort conditions to order the entries returned from a database query.
+ *
+ * Sorts operate on property values or entry timestamps, and can be combined.
+ *
+ * The direction defaults to {@link Direction#DEFAULT_DIRECTION}.
+ */
+public abstract sealed class Sort {
+
+ /**
+ * Default direction of {@link Sort} conditions.
+ */
+ public static final Direction DEFAULT_DIRECTION = Direction.ASCENDING;
+
+ /**
+ * Sort condition that orders the database query by a particular property.
+ * @param property the name of the property to sort against
+ * @param direction the {@code Direction} to sort
+ * @return the {@code Sort} instance
+ */
+ public static Sort by(String property, Direction direction) {
+ return new PropertySort(property, direction);
+ }
+
+ /**
+ * Sort condition that orders the database query by a particular property, in
+ * ascending direction.
+ * @param property the name of the property to sort against
+ * @return the {@code Sort} instance
+ */
+ public static Sort by(String property) {
+ return new PropertySort(property, DEFAULT_DIRECTION);
+ }
+
+ /**
+ * Sort condition that orders the database query by the timestamp associated with a
+ * database entry.
+ * @param timestamp the {@code Timestamp} to sort against
+ * @param direction the {@code Direction} to sort
+ * @return the {@code Sort} instance
+ */
+ public static Sort by(Timestamp timestamp, Direction direction) {
+ return new TimestampSort(timestamp, direction);
+ }
+
+ /**
+ * Sort condition that orders the database query by the timestamp associated with a
+ * database entry, in ascending direction.
+ * @param timestamp the {@code Timestamp timestamp} to sort against
+ * @return the {@code Sort} instance
+ */
+ public static Sort by(Timestamp timestamp) {
+ return new TimestampSort(timestamp, DEFAULT_DIRECTION);
+ }
+
+ /**
+ * Timestamps associated with database entries.
+ */
+ public enum Timestamp {
+
+ /**
+ * The time the entry was created.
+ */
+ CREATED_TIME(QuerySortTimestamp.CreatedTime),
+
+ /**
+ * The time the entry was last edited.
+ */
+ LAST_EDITED_TIME(QuerySortTimestamp.LastEditedTime);
+
+ private final QuerySortTimestamp querySortTimestamp;
+
+ Timestamp(QuerySortTimestamp querySortTimestamp) {
+ this.querySortTimestamp = querySortTimestamp;
+ }
+
+ private QuerySortTimestamp getQuerySortTimestamp() {
+ return querySortTimestamp;
+ }
+
+ }
+
+ /**
+ * Sort directions.
+ */
+ public enum Direction {
+
+ /**
+ * Ascending direction.
+ */
+ ASCENDING(QuerySortDirection.Ascending),
+
+ /**
+ * Descending direction.
+ */
+ DESCENDING(QuerySortDirection.Descending);
+
+ private final QuerySortDirection querySortDirection;
+
+ Direction(QuerySortDirection querySortDirection) {
+ this.querySortDirection = querySortDirection;
+ }
+
+ private QuerySortDirection getQuerySortDirection() {
+ return querySortDirection;
+ }
+
+ }
+
+ private Sort() {
+ }
+
+ abstract QuerySort toQuerySort();
+
+ private static final class PropertySort extends Sort {
+
+ private final String property;
+
+ private final Direction direction;
+
+ private PropertySort(String property, Direction direction) {
+ this.property = Objects.requireNonNull(property);
+ this.direction = Objects.requireNonNull(direction);
+ }
+
+ @Override
+ QuerySort toQuerySort() {
+ return new QuerySort(property, null, direction.getQuerySortDirection());
+ }
+
+ @Override
+ public String toString() {
+ return "%s: %s".formatted(property, direction);
+ }
+
+ }
+
+ private static final class TimestampSort extends Sort {
+
+ private final Timestamp timestamp;
+
+ private final Direction direction;
+
+ private TimestampSort(Timestamp timestamp, Direction direction) {
+ this.timestamp = Objects.requireNonNull(timestamp);
+ this.direction = Objects.requireNonNull(direction);
+ }
+
+ @Override
+ QuerySort toQuerySort() {
+ return new QuerySort(null, timestamp.getQuerySortTimestamp(), direction.getQuerySortDirection());
+ }
+
+ @Override
+ public String toString() {
+ return "%s: %s".formatted(timestamp, direction);
+ }
+
+ }
+
+}
diff --git a/spring-batch-notion/src/main/java/org/springframework/batch/extensions/notion/mapping/BeanWrapperPropertyMapper.java b/spring-batch-notion/src/main/java/org/springframework/batch/extensions/notion/mapping/BeanWrapperPropertyMapper.java
new file mode 100644
index 00000000..b9ef0fce
--- /dev/null
+++ b/spring-batch-notion/src/main/java/org/springframework/batch/extensions/notion/mapping/BeanWrapperPropertyMapper.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2002-2024 the original author or 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.
+ */
+package org.springframework.batch.extensions.notion.mapping;
+
+import org.springframework.beans.BeanUtils;
+import org.springframework.beans.BeanWrapper;
+import org.springframework.beans.PropertyAccessorFactory;
+import org.springframework.util.LinkedCaseInsensitiveMap;
+
+import java.lang.reflect.Constructor;
+
+/**
+ * {@link PropertyMapper} implementation for JavaBeans.
+ *
+ * It requires a default constructor and expects the setter names to match the Notion item
+ * property names (case-insensitive).
+ *
+ * @param the target type
+ */
+public class BeanWrapperPropertyMapper extends CaseInsensitivePropertyMapper {
+
+ private final Constructor constructor;
+
+ /**
+ * Create a new {@link BeanWrapperPropertyMapper} for the given target type.
+ * @param type type of the target object
+ */
+ public BeanWrapperPropertyMapper(Class type) {
+ this.constructor = BeanUtils.getResolvableConstructor(type);
+ }
+
+ /**
+ * Create a new {@link BeanWrapperPropertyMapper}, inferring the target type.
+ * @param reified don't pass any values to it. It's a trick to detect the target type.
+ */
+ @SafeVarargs
+ public BeanWrapperPropertyMapper(T... reified) {
+ this(ClassResolver.getClassOf(reified));
+ }
+
+ @Override
+ T mapCaseInsensitive(LinkedCaseInsensitiveMap properties) {
+ T instance = BeanUtils.instantiateClass(constructor);
+ BeanWrapper beanWrapper = PropertyAccessorFactory.forBeanPropertyAccess(instance);
+ beanWrapper.setPropertyValues(properties);
+ return instance;
+ }
+
+}
diff --git a/spring-batch-notion/src/main/java/org/springframework/batch/extensions/notion/mapping/CaseInsensitivePropertyMapper.java b/spring-batch-notion/src/main/java/org/springframework/batch/extensions/notion/mapping/CaseInsensitivePropertyMapper.java
new file mode 100644
index 00000000..db20c0aa
--- /dev/null
+++ b/spring-batch-notion/src/main/java/org/springframework/batch/extensions/notion/mapping/CaseInsensitivePropertyMapper.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2002-2024 the original author or 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.
+ */
+package org.springframework.batch.extensions.notion.mapping;
+
+import org.springframework.util.LinkedCaseInsensitiveMap;
+
+import java.util.Map;
+
+abstract class CaseInsensitivePropertyMapper implements PropertyMapper {
+
+ @Override
+ public T map(Map properties) {
+ LinkedCaseInsensitiveMap caseInsensitiveProperties = new LinkedCaseInsensitiveMap<>(properties.size());
+ caseInsensitiveProperties.putAll(properties);
+ return mapCaseInsensitive(caseInsensitiveProperties);
+ }
+
+ abstract T mapCaseInsensitive(LinkedCaseInsensitiveMap properties);
+
+}
diff --git a/spring-batch-notion/src/main/java/org/springframework/batch/extensions/notion/mapping/ClassResolver.java b/spring-batch-notion/src/main/java/org/springframework/batch/extensions/notion/mapping/ClassResolver.java
new file mode 100644
index 00000000..bae251bc
--- /dev/null
+++ b/spring-batch-notion/src/main/java/org/springframework/batch/extensions/notion/mapping/ClassResolver.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2002-2024 the original author or 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.
+ */
+package org.springframework.batch.extensions.notion.mapping;
+
+import org.springframework.util.Assert;
+
+class ClassResolver {
+
+ @SuppressWarnings("unchecked")
+ static Class getClassOf(T[] reified) {
+ Assert.isTrue(reified.length == 0,
+ "Please don't pass any values here. The type will be detected automagically.");
+
+ return (Class) reified.getClass().getComponentType();
+ }
+
+}
diff --git a/spring-batch-notion/src/main/java/org/springframework/batch/extensions/notion/mapping/ConstructorBasedPropertyMapper.java b/spring-batch-notion/src/main/java/org/springframework/batch/extensions/notion/mapping/ConstructorBasedPropertyMapper.java
new file mode 100644
index 00000000..bc4c5e71
--- /dev/null
+++ b/spring-batch-notion/src/main/java/org/springframework/batch/extensions/notion/mapping/ConstructorBasedPropertyMapper.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2002-2024 the original author or 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.
+ */
+package org.springframework.batch.extensions.notion.mapping;
+
+import org.springframework.beans.BeanUtils;
+import org.springframework.util.LinkedCaseInsensitiveMap;
+
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Parameter;
+import java.util.Arrays;
+
+abstract class ConstructorBasedPropertyMapper extends CaseInsensitivePropertyMapper {
+
+ private final Constructor constructor;
+
+ ConstructorBasedPropertyMapper(Class type) {
+ try {
+ this.constructor = getConstructor(type);
+ }
+ catch (NoSuchMethodException e) {
+ throw new IllegalArgumentException(e);
+ }
+ }
+
+ abstract Constructor getConstructor(Class type) throws NoSuchMethodException;
+
+ @Override
+ T mapCaseInsensitive(LinkedCaseInsensitiveMap properties) {
+ Object[] parameterValues = Arrays.stream(constructor.getParameters()) //
+ .map(Parameter::getName) //
+ .map(properties::get) //
+ .toArray();
+
+ return BeanUtils.instantiateClass(constructor, parameterValues);
+
+ }
+
+}
diff --git a/spring-batch-notion/src/main/java/org/springframework/batch/extensions/notion/mapping/ConstructorPropertyMapper.java b/spring-batch-notion/src/main/java/org/springframework/batch/extensions/notion/mapping/ConstructorPropertyMapper.java
new file mode 100644
index 00000000..08748ce5
--- /dev/null
+++ b/spring-batch-notion/src/main/java/org/springframework/batch/extensions/notion/mapping/ConstructorPropertyMapper.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2002-2024 the original author or 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.
+ */
+package org.springframework.batch.extensions.notion.mapping;
+
+import java.lang.reflect.Constructor;
+import java.util.Arrays;
+
+/**
+ * {@link PropertyMapper} implementation for types with a constructor with arguments.
+ *
+ * It requires the constructor to be unique and its parameter names to match the Notion
+ * item property names (case-insensitive).
+ *
+ * @param the target type
+ */
+public class ConstructorPropertyMapper extends ConstructorBasedPropertyMapper {
+
+ /**
+ * Create a new {@link ConstructorPropertyMapper} for the given target type.
+ * @param type type of the target object
+ */
+ public ConstructorPropertyMapper(Class type) {
+ super(type);
+ }
+
+ /**
+ * Create a new {@link ConstructorPropertyMapper}, inferring the target type.
+ * @param reified don't pass any values to it. It's a trick to detect the target type.
+ */
+ @SafeVarargs
+ public ConstructorPropertyMapper(T... reified) {
+ this(ClassResolver.getClassOf(reified));
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ Constructor getConstructor(Class type) throws NoSuchMethodException {
+ Constructor>[] constructors = type.getDeclaredConstructors();
+
+ if (constructors.length == 0) {
+ throw new NoSuchMethodException("No constructor found for type: " + type);
+ }
+
+ if (constructors.length > 1) {
+ throw new NoSuchMethodException("Multiple constructors available: " + Arrays.toString(constructors));
+ }
+
+ return (Constructor) constructors[0];
+ }
+
+}
diff --git a/spring-batch-notion/src/main/java/org/springframework/batch/extensions/notion/mapping/PropertyMapper.java b/spring-batch-notion/src/main/java/org/springframework/batch/extensions/notion/mapping/PropertyMapper.java
new file mode 100644
index 00000000..09365ebf
--- /dev/null
+++ b/spring-batch-notion/src/main/java/org/springframework/batch/extensions/notion/mapping/PropertyMapper.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2002-2024 the original author or 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.
+ */
+package org.springframework.batch.extensions.notion.mapping;
+
+import java.util.Map;
+
+/**
+ * Strategy interface for mapping the properties of a Notion item into a Java object.
+ *
+ * @param the object type
+ */
+@FunctionalInterface
+public interface PropertyMapper {
+
+ /**
+ * Map the given item properties into an object of type {@code T}.
+ * @param properties unmodifiable map containing the property value objects, keyed by
+ * property name
+ * @return the populated object
+ */
+ T map(Map properties);
+
+}
diff --git a/spring-batch-notion/src/main/java/org/springframework/batch/extensions/notion/mapping/RecordPropertyMapper.java b/spring-batch-notion/src/main/java/org/springframework/batch/extensions/notion/mapping/RecordPropertyMapper.java
new file mode 100644
index 00000000..50130a88
--- /dev/null
+++ b/spring-batch-notion/src/main/java/org/springframework/batch/extensions/notion/mapping/RecordPropertyMapper.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2002-2024 the original author or 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.
+ */
+package org.springframework.batch.extensions.notion.mapping;
+
+import org.springframework.util.ReflectionUtils;
+
+import java.lang.reflect.Constructor;
+import java.lang.reflect.RecordComponent;
+import java.util.Arrays;
+
+/**
+ * {@link PropertyMapper} implementation for {@link Record Java records}.
+ *
+ * It uses the record's canonical constructor and requires the record's component names to
+ * match the Notion item property names (case-insensitive).
+ *
+ * @param the target type — must be a {@link Record}
+ */
+public class RecordPropertyMapper extends ConstructorBasedPropertyMapper {
+
+ /**
+ * Create a new {@link RecordPropertyMapper} for the given target type.
+ * @param type type of the target record
+ */
+ public RecordPropertyMapper(Class type) {
+ super(type);
+ }
+
+ /**
+ * Create a new {@link RecordPropertyMapper}, inferring the target type.
+ * @param reified don't pass any values to it. It's a trick to detect the target type.
+ */
+ @SafeVarargs
+ public RecordPropertyMapper(T... reified) {
+ this(ClassResolver.getClassOf(reified));
+ }
+
+ @Override
+ Constructor getConstructor(Class type) throws NoSuchMethodException {
+ Class>[] parameterTypes = Arrays.stream(type.getRecordComponents()) //
+ .map(RecordComponent::getType) //
+ .toArray(Class[]::new);
+
+ return ReflectionUtils.accessibleConstructor(type, parameterTypes);
+ }
+
+}
diff --git a/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/FilterTests.java b/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/FilterTests.java
new file mode 100644
index 00000000..7e613991
--- /dev/null
+++ b/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/FilterTests.java
@@ -0,0 +1,526 @@
+/*
+ * Copyright 2002-2024 the original author or 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.
+ */
+package org.springframework.batch.extensions.notion;
+
+import notion.api.v1.model.databases.query.filter.CompoundFilter;
+import notion.api.v1.model.databases.query.filter.PropertyFilter;
+import notion.api.v1.model.databases.query.filter.QueryTopLevelFilter;
+import notion.api.v1.model.databases.query.filter.condition.CheckboxFilter;
+import notion.api.v1.model.databases.query.filter.condition.MultiSelectFilter;
+import notion.api.v1.model.databases.query.filter.condition.NumberFilter;
+import notion.api.v1.model.databases.query.filter.condition.SelectFilter;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.util.List;
+import java.util.function.Supplier;
+import java.util.stream.Stream;
+
+import static org.springframework.batch.extensions.notion.Filter.where;
+import static java.util.function.Function.identity;
+import static org.assertj.core.api.BDDAssertions.then;
+import static org.junit.jupiter.params.provider.Arguments.arguments;
+
+class FilterTests {
+
+ @ParameterizedTest
+ @MethodSource({ "propertyFilters", "compoundFilters", "nestedFilters" })
+ void toQueryTopLevelFilter(Filter underTest, QueryTopLevelFilter expected) {
+ // WHEN
+ QueryTopLevelFilter result = underTest.toQueryTopLevelFilter();
+ // THEN
+ then(result).usingRecursiveComparison().isEqualTo(expected);
+ }
+
+ static Stream propertyFilters() {
+ return Stream.of( //
+ checkboxFilters(), //
+ multiSelectFilters(), //
+ numberFilters(), //
+ selectFilters()) //
+ .flatMap(identity());
+ }
+
+ static Stream checkboxFilters() {
+ return Stream.of(true, false)
+ .flatMap(value -> Stream.of( //
+ arguments( //
+ where().checkbox("property").isEqualTo(value), //
+ supply(() -> {
+ CheckboxFilter checkboxFilter = new CheckboxFilter();
+ checkboxFilter.setEquals(value);
+ PropertyFilter propertyFilter = new PropertyFilter("property");
+ propertyFilter.setCheckbox(checkboxFilter);
+ return propertyFilter;
+ })),
+ arguments( //
+ where().checkbox("property").isNotEqualTo(value), //
+ supply(() -> {
+ CheckboxFilter checkboxFilter = new CheckboxFilter();
+ checkboxFilter.setDoesNotEqual(value);
+ PropertyFilter propertyFilter = new PropertyFilter("property");
+ propertyFilter.setCheckbox(checkboxFilter);
+ return propertyFilter;
+ }))));
+ }
+
+ static Stream multiSelectFilters() {
+ return Stream.of( //
+ arguments( //
+ where().multiSelect("property").contains("value"), //
+ supply(() -> {
+ MultiSelectFilter multiSelectFilter = new MultiSelectFilter();
+ multiSelectFilter.setContains("value");
+ PropertyFilter propertyFilter = new PropertyFilter("property");
+ propertyFilter.setMultiSelect(multiSelectFilter);
+ return propertyFilter;
+ })),
+ arguments( //
+ where().multiSelect("property").doesNotContain("value"), //
+ supply(() -> {
+ MultiSelectFilter multiSelectFilter = new MultiSelectFilter();
+ multiSelectFilter.setDoesNotContain("value");
+ PropertyFilter propertyFilter = new PropertyFilter("property");
+ propertyFilter.setMultiSelect(multiSelectFilter);
+ return propertyFilter;
+ })),
+ arguments( //
+ where().multiSelect("property").isEmpty(), //
+ supply(() -> {
+ MultiSelectFilter multiSelectFilter = new MultiSelectFilter();
+ multiSelectFilter.setEmpty(true);
+ PropertyFilter propertyFilter = new PropertyFilter("property");
+ propertyFilter.setMultiSelect(multiSelectFilter);
+ return propertyFilter;
+ })),
+ arguments( //
+ where().multiSelect("property").isNotEmpty(), //
+ supply(() -> {
+ MultiSelectFilter multiSelectFilter = new MultiSelectFilter();
+ multiSelectFilter.setNotEmpty(true);
+ PropertyFilter propertyFilter = new PropertyFilter("property");
+ propertyFilter.setMultiSelect(multiSelectFilter);
+ return propertyFilter;
+ })));
+ }
+
+ static Stream numberFilters() {
+ return Stream.of( //
+ arguments( //
+ where().number("property").isEqualTo(42), //
+ supply(() -> {
+ NumberFilter numberFilter = new NumberFilter();
+ numberFilter.setEquals(42);
+ PropertyFilter propertyFilter = new PropertyFilter("property");
+ propertyFilter.setNumber(numberFilter);
+ return propertyFilter;
+ })),
+ arguments( //
+ where().number("property").isNotEqualTo(42), //
+ supply(() -> {
+ NumberFilter numberFilter = new NumberFilter();
+ numberFilter.setDoesNotEqual(42);
+ PropertyFilter propertyFilter = new PropertyFilter("property");
+ propertyFilter.setNumber(numberFilter);
+ return propertyFilter;
+ })),
+ arguments( //
+ where().number("property").isGreaterThan(42), //
+ supply(() -> {
+ NumberFilter numberFilter = new NumberFilter();
+ numberFilter.setGreaterThan(42);
+ PropertyFilter propertyFilter = new PropertyFilter("property");
+ propertyFilter.setNumber(numberFilter);
+ return propertyFilter;
+ })),
+ arguments( //
+ where().number("property").isGreaterThanOrEqualTo(42), //
+ supply(() -> {
+ NumberFilter numberFilter = new NumberFilter();
+ numberFilter.setGreaterThanOrEqualTo(42);
+ PropertyFilter propertyFilter = new PropertyFilter("property");
+ propertyFilter.setNumber(numberFilter);
+ return propertyFilter;
+ })),
+ arguments( //
+ where().number("property").isLessThan(42), //
+ supply(() -> {
+ NumberFilter numberFilter = new NumberFilter();
+ numberFilter.setLessThan(42);
+ PropertyFilter propertyFilter = new PropertyFilter("property");
+ propertyFilter.setNumber(numberFilter);
+ return propertyFilter;
+ })),
+ arguments( //
+ where().number("property").isLessThanOrEqualTo(42), //
+ supply(() -> {
+ NumberFilter numberFilter = new NumberFilter();
+ numberFilter.setLessThanOrEqualTo(42);
+ PropertyFilter propertyFilter = new PropertyFilter("property");
+ propertyFilter.setNumber(numberFilter);
+ return propertyFilter;
+ })),
+ arguments( //
+ where().number("property").isEmpty(), //
+ supply(() -> {
+ NumberFilter numberFilter = new NumberFilter();
+ numberFilter.setEmpty(true);
+ PropertyFilter propertyFilter = new PropertyFilter("property");
+ propertyFilter.setNumber(numberFilter);
+ return propertyFilter;
+ })),
+ arguments( //
+ where().number("property").isNotEmpty(), //
+ supply(() -> {
+ NumberFilter numberFilter = new NumberFilter();
+ numberFilter.setNotEmpty(true);
+ PropertyFilter propertyFilter = new PropertyFilter("property");
+ propertyFilter.setNumber(numberFilter);
+ return propertyFilter;
+ })));
+ }
+
+ static Stream selectFilters() {
+ return Stream.of( //
+ arguments( //
+ where().select("property").isEqualTo("value"), //
+ supply(() -> {
+ SelectFilter selectFilter = new SelectFilter();
+ selectFilter.setEquals("value");
+ PropertyFilter propertyFilter = new PropertyFilter("property");
+ propertyFilter.setSelect(selectFilter);
+ return propertyFilter;
+ })),
+ arguments( //
+ where().select("property").isNotEqualTo("value"), //
+ supply(() -> {
+ SelectFilter selectFilter = new SelectFilter();
+ selectFilter.setDoesNotEqual("value");
+ PropertyFilter propertyFilter = new PropertyFilter("property");
+ propertyFilter.setSelect(selectFilter);
+ return propertyFilter;
+ })),
+ arguments( //
+ where().select("property").isEmpty(), //
+ supply(() -> {
+ SelectFilter selectFilter = new SelectFilter();
+ selectFilter.setEmpty(true);
+ PropertyFilter propertyFilter = new PropertyFilter("property");
+ propertyFilter.setSelect(selectFilter);
+ return propertyFilter;
+ })),
+ arguments( //
+ where().select("property").isNotEmpty(), //
+ supply(() -> {
+ SelectFilter selectFilter = new SelectFilter();
+ selectFilter.setNotEmpty(true);
+ PropertyFilter propertyFilter = new PropertyFilter("property");
+ propertyFilter.setSelect(selectFilter);
+ return propertyFilter;
+ })));
+ }
+
+ static Stream compoundFilters() {
+ return Stream.of(andFilters(), orFilters()).flatMap(identity());
+ }
+
+ static Stream andFilters() {
+ return Stream.of( //
+ arguments( // @formatter:off
+ where().checkbox("active").isEqualTo(false)
+ .and().select("another").isNotEmpty(), //
+ // @formatter:on
+ supply(() -> {
+ CompoundFilter compoundFilter = new CompoundFilter();
+
+ compoundFilter.setAnd(List.of( //
+ supply(() -> {
+ CheckboxFilter checkboxFilter = new CheckboxFilter();
+ checkboxFilter.setEquals(false);
+ PropertyFilter propertyFilter = new PropertyFilter("active");
+ propertyFilter.setCheckbox(checkboxFilter);
+ return propertyFilter;
+ }), supply(() -> {
+ SelectFilter selectFilter = new SelectFilter();
+ selectFilter.setNotEmpty(true);
+ PropertyFilter propertyFilter = new PropertyFilter("another");
+ propertyFilter.setSelect(selectFilter);
+ return propertyFilter;
+ })));
+
+ return compoundFilter;
+ })),
+ arguments( // @formatter:off
+ where().checkbox("active").isEqualTo(false)
+ .and(where().select("another").isNotEmpty()),
+ // @formatter:on
+ supply(() -> {
+ CompoundFilter compoundFilter = new CompoundFilter();
+
+ compoundFilter.setAnd(List.of( //
+ supply(() -> {
+ CheckboxFilter checkboxFilter = new CheckboxFilter();
+ checkboxFilter.setEquals(false);
+ PropertyFilter propertyFilter = new PropertyFilter("active");
+ propertyFilter.setCheckbox(checkboxFilter);
+ return propertyFilter;
+ }), supply(() -> {
+ SelectFilter selectFilter = new SelectFilter();
+ selectFilter.setNotEmpty(true);
+ PropertyFilter propertyFilter = new PropertyFilter("another");
+ propertyFilter.setSelect(selectFilter);
+ return propertyFilter;
+ })));
+
+ return compoundFilter;
+ })),
+ arguments( // @formatter:off
+ where().checkbox("active").isEqualTo(false)
+ .and(where().select("another").isNotEmpty())
+ .and().checkbox("one-more").isNotEqualTo(true)
+ .and(where().select("another-more").isEmpty()),
+ // @formatter:on
+ supply(() -> {
+ CompoundFilter compoundFilter = new CompoundFilter();
+
+ compoundFilter.setAnd(List.of( //
+ supply(() -> {
+ CheckboxFilter checkboxFilter = new CheckboxFilter();
+ checkboxFilter.setEquals(false);
+ PropertyFilter propertyFilter = new PropertyFilter("active");
+ propertyFilter.setCheckbox(checkboxFilter);
+ return propertyFilter;
+ }), //
+ supply(() -> {
+ SelectFilter selectFilter = new SelectFilter();
+ selectFilter.setNotEmpty(true);
+ PropertyFilter propertyFilter = new PropertyFilter("another");
+ propertyFilter.setSelect(selectFilter);
+ return propertyFilter;
+ }), //
+ supply(() -> {
+ CheckboxFilter checkboxFilter = new CheckboxFilter();
+ checkboxFilter.setDoesNotEqual(true);
+ PropertyFilter propertyFilter = new PropertyFilter("one-more");
+ propertyFilter.setCheckbox(checkboxFilter);
+ return propertyFilter;
+ }), //
+ supply(() -> {
+ SelectFilter selectFilter = new SelectFilter();
+ selectFilter.setEmpty(true);
+ PropertyFilter propertyFilter = new PropertyFilter("another-more");
+ propertyFilter.setSelect(selectFilter);
+ return propertyFilter;
+ })));
+
+ return compoundFilter;
+ })));
+ }
+
+ static Stream orFilters() {
+ return Stream.of( //
+ arguments( // @formatter:off
+ where().checkbox("active").isEqualTo(false)
+ .or().select("another").isNotEmpty(),
+ // @formatter:on
+ supply(() -> {
+ CompoundFilter compoundFilter = new CompoundFilter();
+
+ compoundFilter.setOr(List.of( //
+ supply(() -> {
+ CheckboxFilter checkboxFilter = new CheckboxFilter();
+ checkboxFilter.setEquals(false);
+ PropertyFilter propertyFilter = new PropertyFilter("active");
+ propertyFilter.setCheckbox(checkboxFilter);
+ return propertyFilter;
+ }), //
+ supply(() -> {
+ SelectFilter selectFilter = new SelectFilter();
+ selectFilter.setNotEmpty(true);
+ PropertyFilter propertyFilter = new PropertyFilter("another");
+ propertyFilter.setSelect(selectFilter);
+ return propertyFilter;
+ })));
+
+ return compoundFilter;
+ })),
+ arguments( // @formatter:off
+ where().checkbox("active").isEqualTo(false)
+ .or(where().select("another").isNotEmpty()),
+ // @formatter:on
+ supply(() -> {
+ CompoundFilter compoundFilter = new CompoundFilter();
+
+ compoundFilter.setOr(List.of( //
+ supply(() -> {
+ CheckboxFilter checkboxFilter = new CheckboxFilter();
+ checkboxFilter.setEquals(false);
+ PropertyFilter propertyFilter = new PropertyFilter("active");
+ propertyFilter.setCheckbox(checkboxFilter);
+ return propertyFilter;
+ }), //
+ supply(() -> {
+ SelectFilter selectFilter = new SelectFilter();
+ selectFilter.setNotEmpty(true);
+ PropertyFilter propertyFilter = new PropertyFilter("another");
+ propertyFilter.setSelect(selectFilter);
+ return propertyFilter;
+ })));
+
+ return compoundFilter;
+ })),
+ arguments( // @formatter:off
+ where().checkbox("active").isEqualTo(false)
+ .or(where().select("another").isNotEmpty())
+ .or().checkbox("one-more").isNotEqualTo(true)
+ .or(where().select("another-more").isEmpty()),
+ // @formatter:on
+ supply(() -> {
+ CompoundFilter compoundFilter = new CompoundFilter();
+
+ compoundFilter.setOr(List.of( //
+ supply(() -> {
+ CheckboxFilter checkboxFilter = new CheckboxFilter();
+ checkboxFilter.setEquals(false);
+ PropertyFilter propertyFilter = new PropertyFilter("active");
+ propertyFilter.setCheckbox(checkboxFilter);
+ return propertyFilter;
+ }), //
+ supply(() -> {
+ SelectFilter selectFilter = new SelectFilter();
+ selectFilter.setNotEmpty(true);
+ PropertyFilter propertyFilter = new PropertyFilter("another");
+ propertyFilter.setSelect(selectFilter);
+ return propertyFilter;
+ }), //
+ supply(() -> {
+ CheckboxFilter checkboxFilter = new CheckboxFilter();
+ checkboxFilter.setDoesNotEqual(true);
+ PropertyFilter propertyFilter = new PropertyFilter("one-more");
+ propertyFilter.setCheckbox(checkboxFilter);
+ return propertyFilter;
+ }), //
+ supply(() -> {
+ SelectFilter selectFilter = new SelectFilter();
+ selectFilter.setEmpty(true);
+ PropertyFilter propertyFilter = new PropertyFilter("another-more");
+ propertyFilter.setSelect(selectFilter);
+ return propertyFilter;
+ })));
+
+ return compoundFilter;
+ })));
+ }
+
+ static Stream nestedFilters() {
+ return Stream.of( //
+ arguments( // @formatter:off
+ where(where().checkbox("active").isEqualTo(true)),
+ // @formatter:on
+ supply(() -> {
+ CheckboxFilter checkboxFilter = new CheckboxFilter();
+ checkboxFilter.setEquals(true);
+ PropertyFilter propertyFilter = new PropertyFilter("active");
+ propertyFilter.setCheckbox(checkboxFilter);
+ return propertyFilter;
+ })),
+ arguments( // @formatter:off
+ where().checkbox("active").isEqualTo(true)
+ .and(where().select("another").isEmpty().or().select("another").isEqualTo("value")),
+ // @formatter:on
+ supply(() -> {
+ CompoundFilter compoundFilter = new CompoundFilter();
+
+ compoundFilter.setAnd(List.of( //
+ supply(() -> {
+ CheckboxFilter checkboxFilter = new CheckboxFilter();
+ checkboxFilter.setEquals(true);
+ PropertyFilter propertyFilter = new PropertyFilter("active");
+ propertyFilter.setCheckbox(checkboxFilter);
+ return propertyFilter;
+ }), //
+ supply(() -> {
+ CompoundFilter innerFilter = new CompoundFilter();
+
+ innerFilter.setOr(List.of( //
+ supply(() -> {
+ SelectFilter selectFilter = new SelectFilter();
+ selectFilter.setEmpty(true);
+ PropertyFilter propertyFilter = new PropertyFilter("another");
+ propertyFilter.setSelect(selectFilter);
+ return propertyFilter;
+ }), //
+ supply(() -> {
+ SelectFilter selectFilter = new SelectFilter();
+ selectFilter.setEquals("value");
+ PropertyFilter propertyFilter = new PropertyFilter("another");
+ propertyFilter.setSelect(selectFilter);
+ return propertyFilter;
+ })));
+
+ return innerFilter;
+ })));
+
+ return compoundFilter;
+ })),
+ arguments( // @formatter:off
+ where(where().checkbox("active").isEqualTo(false)
+ .or().select("another").isNotEmpty())
+ .and().select("one-more").isEmpty(),
+ // @formatter:on
+ supply(() -> {
+ CompoundFilter compoundFilter = new CompoundFilter();
+
+ compoundFilter.setAnd(List.of( //
+ supply(() -> {
+ CompoundFilter innerFilter = new CompoundFilter();
+
+ innerFilter.setOr(List.of( //
+ supply(() -> {
+ CheckboxFilter checkboxFilter = new CheckboxFilter();
+ checkboxFilter.setEquals(false);
+ PropertyFilter propertyFilter = new PropertyFilter("active");
+ propertyFilter.setCheckbox(checkboxFilter);
+ return propertyFilter;
+ }), //
+ supply(() -> {
+ SelectFilter selectFilter = new SelectFilter();
+ selectFilter.setNotEmpty(true);
+ PropertyFilter propertyFilter = new PropertyFilter("another");
+ propertyFilter.setSelect(selectFilter);
+ return propertyFilter;
+ })));
+
+ return innerFilter;
+ }), //
+ supply(() -> {
+ SelectFilter selectFilter = new SelectFilter();
+ selectFilter.setEmpty(true);
+ PropertyFilter propertyFilter = new PropertyFilter("one-more");
+ propertyFilter.setSelect(selectFilter);
+ return propertyFilter;
+ })));
+
+ return compoundFilter;
+ })));
+ }
+
+ private static T supply(Supplier supplier) {
+ return supplier.get();
+ }
+
+}
diff --git a/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/NotionJvmSdkTests.java b/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/NotionJvmSdkTests.java
new file mode 100644
index 00000000..66cfd4dc
--- /dev/null
+++ b/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/NotionJvmSdkTests.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2002-2024 the original author or 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.
+ */
+package org.springframework.batch.extensions.notion;
+
+import com.tngtech.archunit.base.DescribedPredicate;
+import com.tngtech.archunit.core.domain.JavaClass;
+import com.tngtech.archunit.core.domain.JavaClasses;
+import com.tngtech.archunit.junit.AnalyzeClasses;
+import com.tngtech.archunit.junit.ArchTest;
+import com.tngtech.archunit.lang.ArchRule;
+
+import static com.tngtech.archunit.base.DescribedPredicate.anyElementThat;
+import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage;
+import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods;
+
+@AnalyzeClasses(packagesOf = NotionDatabaseItemReader.class)
+class NotionJvmSdkTests {
+
+ private static final DescribedPredicate RESIDE_IN_NOTION_JVM_SDK_PACKAGE = //
+ resideInAPackage("notion.api..");
+
+ @ArchTest
+ void library_types_should_not_be_exposed(JavaClasses classes) {
+ // @formatter:off
+ ArchRule rule = methods()
+ .that().arePublic().or().areProtected()
+ .should().notHaveRawReturnType(RESIDE_IN_NOTION_JVM_SDK_PACKAGE)
+ .andShould().notHaveRawParameterTypes(anyElementThat(RESIDE_IN_NOTION_JVM_SDK_PACKAGE));
+ // @formatter:on
+ rule.check(classes);
+ }
+
+}
diff --git a/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/SortTests.java b/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/SortTests.java
new file mode 100644
index 00000000..0cd480d7
--- /dev/null
+++ b/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/SortTests.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2002-2024 the original author or 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.
+ */
+package org.springframework.batch.extensions.notion;
+
+import notion.api.v1.model.databases.query.sort.QuerySort;
+import notion.api.v1.model.databases.query.sort.QuerySortDirection;
+import notion.api.v1.model.databases.query.sort.QuerySortTimestamp;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.util.stream.Stream;
+
+import static org.springframework.batch.extensions.notion.Sort.Direction.ASCENDING;
+import static org.springframework.batch.extensions.notion.Sort.Direction.DESCENDING;
+import static org.springframework.batch.extensions.notion.Sort.Timestamp.CREATED_TIME;
+import static org.springframework.batch.extensions.notion.Sort.Timestamp.LAST_EDITED_TIME;
+import static notion.api.v1.model.databases.query.sort.QuerySortDirection.Ascending;
+import static notion.api.v1.model.databases.query.sort.QuerySortDirection.Descending;
+import static notion.api.v1.model.databases.query.sort.QuerySortTimestamp.CreatedTime;
+import static notion.api.v1.model.databases.query.sort.QuerySortTimestamp.LastEditedTime;
+import static org.assertj.core.api.BDDAssertions.from;
+import static org.assertj.core.api.BDDAssertions.then;
+import static org.junit.jupiter.params.provider.Arguments.arguments;
+
+class SortTests {
+
+ @ParameterizedTest
+ @MethodSource
+ void toQuerySort(Sort underTest, String property, QuerySortTimestamp timestamp, QuerySortDirection direction) {
+ // WHEN
+ QuerySort result = underTest.toQuerySort();
+ // THEN
+ then(result) //
+ .returns(direction, from(QuerySort::getDirection))
+ .returns(property, from(QuerySort::getProperty))
+ .returns(timestamp, from(QuerySort::getTimestamp));
+ }
+
+ static Stream toQuerySort() {
+ return Stream.of( //
+ arguments(Sort.by("property"), "property", null, Ascending),
+ arguments(Sort.by("property", ASCENDING), "property", null, Ascending),
+ arguments(Sort.by("property", DESCENDING), "property", null, Descending),
+ arguments(Sort.by(CREATED_TIME), null, CreatedTime, Ascending),
+ arguments(Sort.by(CREATED_TIME, ASCENDING), null, CreatedTime, Ascending),
+ arguments(Sort.by(CREATED_TIME, DESCENDING), null, CreatedTime, Descending),
+ arguments(Sort.by(LAST_EDITED_TIME), null, LastEditedTime, Ascending),
+ arguments(Sort.by(LAST_EDITED_TIME, ASCENDING), null, LastEditedTime, Ascending),
+ arguments(Sort.by(LAST_EDITED_TIME, DESCENDING), null, LastEditedTime, Descending));
+ }
+
+ @ParameterizedTest
+ @MethodSource
+ void testToString(Sort underTest, String expected) {
+ // WHEN
+ String result = underTest.toString();
+ // THEN
+ then(result).isEqualTo(expected);
+ }
+
+ static Stream testToString() {
+ return Stream.of( //
+ arguments(Sort.by("property"), "property: ASCENDING"),
+ arguments(Sort.by("property", ASCENDING), "property: ASCENDING"),
+ arguments(Sort.by("property", DESCENDING), "property: DESCENDING"),
+ arguments(Sort.by(CREATED_TIME), "CREATED_TIME: ASCENDING"),
+ arguments(Sort.by(CREATED_TIME, ASCENDING), "CREATED_TIME: ASCENDING"),
+ arguments(Sort.by(CREATED_TIME, DESCENDING), "CREATED_TIME: DESCENDING"),
+ arguments(Sort.by(LAST_EDITED_TIME), "LAST_EDITED_TIME: ASCENDING"),
+ arguments(Sort.by(LAST_EDITED_TIME, ASCENDING), "LAST_EDITED_TIME: ASCENDING"),
+ arguments(Sort.by(LAST_EDITED_TIME, DESCENDING), "LAST_EDITED_TIME: DESCENDING"));
+ }
+
+}
diff --git a/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/it/IntegrationTest.java b/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/it/IntegrationTest.java
new file mode 100644
index 00000000..5487ac1c
--- /dev/null
+++ b/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/it/IntegrationTest.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2002-2024 the original author or 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.
+ */
+package org.springframework.batch.extensions.notion.it;
+
+import org.springframework.batch.test.context.SpringBatchTest;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.wiremock.spring.EnableWireMock;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+@Target(TYPE)
+@Retention(RUNTIME)
+@SpringBootTest(properties = "spring.batch.job.enabled=false")
+@SpringBatchTest
+@EnableWireMock
+public @interface IntegrationTest {
+
+}
diff --git a/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/it/RequestBodies.java b/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/it/RequestBodies.java
new file mode 100644
index 00000000..31c869b6
--- /dev/null
+++ b/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/it/RequestBodies.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2002-2024 the original author or 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.
+ */
+package org.springframework.batch.extensions.notion.it;
+
+import org.springframework.batch.extensions.notion.Sort.Direction;
+import org.springframework.batch.extensions.notion.Sort.Timestamp;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.UUID;
+
+public class RequestBodies {
+
+ public static String queryRequest(int pageSize, JSONObject... sorts) {
+ return queryRequest(null, pageSize, sorts);
+ }
+
+ public static String queryRequest(UUID startCursor, int pageSize, JSONObject... sorts) {
+ try {
+ JSONObject jsonObject = new JSONObject();
+
+ if (sorts.length > 0) {
+ jsonObject.put("sorts", new JSONArray(sorts));
+ }
+
+ return jsonObject //
+ .put("page_size", pageSize)
+ .putOpt("start_cursor", startCursor != null ? startCursor.toString() : null)
+ .toString();
+ }
+ catch (JSONException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public static JSONObject sortByProperty(String property, Direction direction) {
+ try {
+ return new JSONObject() //
+ .put("property", property)
+ .put("direction", direction.name().toLowerCase());
+ }
+ catch (JSONException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public static JSONObject sortByTimestamp(String property, Timestamp timestamp) {
+ try {
+ return new JSONObject() //
+ .put("property", property)
+ .put("timestamp", timestamp.name().toLowerCase());
+ }
+ catch (JSONException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+}
diff --git a/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/it/RequestHeaders.java b/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/it/RequestHeaders.java
new file mode 100644
index 00000000..66008d62
--- /dev/null
+++ b/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/it/RequestHeaders.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2002-2024 the original author or 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.
+ */
+package org.springframework.batch.extensions.notion.it;
+
+public class RequestHeaders {
+
+ public static final String NOTION_VERSION = "Notion-Version";
+
+ public static final String NOTION_VERSION_VALUE = "2022-06-28";
+
+}
diff --git a/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/it/ResponseBodies.java b/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/it/ResponseBodies.java
new file mode 100644
index 00000000..2436beb0
--- /dev/null
+++ b/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/it/ResponseBodies.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2002-2024 the original author or 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.
+ */
+package org.springframework.batch.extensions.notion.it;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.time.Instant;
+import java.util.Map;
+import java.util.UUID;
+
+import static java.util.UUID.randomUUID;
+
+public class ResponseBodies {
+
+ public static String queryResponse(JSONObject... results) {
+ return queryResponse(null, results);
+ }
+
+ public static String queryResponse(UUID nextCursor, JSONObject... results) {
+ try {
+ return new JSONObject() //
+ .put("object", "list")
+ .put("results", new JSONArray(results))
+ .put("next_cursor", nextCursor != null ? nextCursor.toString() : null)
+ .put("has_more", nextCursor != null)
+ .put("type", "page")
+ .put("page", new JSONObject())
+ .toString();
+ }
+ catch (JSONException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public static JSONObject result(UUID id, UUID databaseId, Map, ?> properties) {
+ try {
+ Instant now = Instant.now();
+
+ return new JSONObject() //
+ .put("object", "page")
+ .put("id", id.toString())
+ .put("created_time", now.toString())
+ .put("last_edited_time", now.toString())
+ .put("created_by", new JSONObject())
+ .put("last_edited_by", new JSONObject())
+ .put("parent", new JSONObject() //
+ .put("type", "database_id")
+ .put("database_id", databaseId.toString()))
+ .put("archived", false)
+ .put("properties", new JSONObject(properties))
+ .put("url", "https://www.notion.so/" + randomUUID().toString().replace("-", ""));
+ }
+ catch (JSONException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public static JSONObject title(String value) {
+ try {
+ JSONArray jsonArray = new JSONArray();
+
+ if (value != null) {
+ jsonArray.put(new JSONObject() //
+ .put("type", "text")
+ .put("text", new JSONObject() //
+ .put("content", value))
+ .put("annotations", new JSONObject() //
+ .put("bold", false)
+ .put("italic", false)
+ .put("strikethrough", false)
+ .put("underline", false)
+ .put("code", false)
+ .put("color", "default"))
+ .put("plain_text", value));
+ }
+
+ return new JSONObject() //
+ .put("id", "title")
+ .put("type", "title")
+ .put("title", jsonArray);
+ }
+ catch (JSONException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public static JSONObject richText(String value) {
+ try {
+ return new JSONObject() //
+ .put("id", "JV%3B%3F")
+ .put("type", "rich_text")
+ .put("rich_text", new JSONArray() //
+ .put(new JSONObject() //
+ .put("type", "text")
+ .put("text", new JSONObject() //
+ .put("content", value))
+ .put("annotations", new JSONObject() //
+ .put("bold", false)
+ .put("italic", false)
+ .put("strikethrough", false)
+ .put("underline", false)
+ .put("code", false)
+ .put("color", "default"))
+ .put("plain_text", value)));
+ }
+ catch (JSONException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+}
diff --git a/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/it/pagination/MultiplePagesDescendingTests.java b/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/it/pagination/MultiplePagesDescendingTests.java
new file mode 100644
index 00000000..8d6c5e1f
--- /dev/null
+++ b/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/it/pagination/MultiplePagesDescendingTests.java
@@ -0,0 +1,163 @@
+/*
+ * Copyright 2002-2024 the original author or 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.
+ */
+package org.springframework.batch.extensions.notion.it.pagination;
+
+import org.springframework.batch.extensions.notion.NotionDatabaseItemReader;
+import org.springframework.batch.extensions.notion.Sort;
+import org.springframework.batch.extensions.notion.it.IntegrationTest;
+import org.springframework.batch.extensions.notion.mapping.RecordPropertyMapper;
+import org.json.JSONObject;
+import org.junit.jupiter.api.Test;
+import org.springframework.batch.core.Job;
+import org.springframework.batch.core.JobExecution;
+import org.springframework.batch.core.Step;
+import org.springframework.batch.core.job.builder.JobBuilder;
+import org.springframework.batch.core.repository.JobRepository;
+import org.springframework.batch.core.step.builder.StepBuilder;
+import org.springframework.batch.item.support.ListItemWriter;
+import org.springframework.batch.test.JobLauncherTestUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.annotation.Bean;
+import org.springframework.transaction.PlatformTransactionManager;
+
+import java.util.Map;
+import java.util.UUID;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.containing;
+import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson;
+import static com.github.tomakehurst.wiremock.client.WireMock.givenThat;
+import static com.github.tomakehurst.wiremock.client.WireMock.matching;
+import static com.github.tomakehurst.wiremock.client.WireMock.okJson;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static org.springframework.batch.extensions.notion.Sort.Direction.DESCENDING;
+import static org.springframework.batch.extensions.notion.it.RequestBodies.queryRequest;
+import static org.springframework.batch.extensions.notion.it.RequestBodies.sortByProperty;
+import static org.springframework.batch.extensions.notion.it.RequestHeaders.NOTION_VERSION;
+import static org.springframework.batch.extensions.notion.it.RequestHeaders.NOTION_VERSION_VALUE;
+import static org.springframework.batch.extensions.notion.it.ResponseBodies.queryResponse;
+import static org.springframework.batch.extensions.notion.it.ResponseBodies.result;
+import static org.springframework.batch.extensions.notion.it.ResponseBodies.richText;
+import static org.springframework.batch.extensions.notion.it.ResponseBodies.title;
+import static java.util.UUID.randomUUID;
+import static org.assertj.core.api.BDDAssertions.then;
+import static org.assertj.core.api.InstanceOfAssertFactories.LIST;
+import static org.springframework.batch.core.ExitStatus.COMPLETED;
+import static wiremock.com.google.common.net.HttpHeaders.AUTHORIZATION;
+import static wiremock.com.google.common.net.HttpHeaders.CONTENT_TYPE;
+
+@IntegrationTest
+class MultiplePagesDescendingTests {
+
+ private static final UUID DATABASE_ID = randomUUID();
+
+ private static final int PAGE_SIZE = 2;
+
+ @Autowired
+ JobLauncherTestUtils launcher;
+
+ @Autowired
+ ListItemWriter itemWriter;
+
+ @Test
+ void should_succeed() throws Exception {
+ // GIVEN
+ UUID thirdResultId = randomUUID();
+
+ JSONObject firstResult = result(randomUUID(), DATABASE_ID,
+ Map.of("Name", title("Name string"), "Value", richText("123456")));
+ JSONObject secondResult = result(randomUUID(), DATABASE_ID,
+ Map.of("Name", title("Another name string"), "Value", richText("0987654321")));
+ JSONObject thirdResult = result(thirdResultId, DATABASE_ID,
+ Map.of("Name", title(""), "Value", richText("abc-1234")));
+
+ givenThat(post("/databases/%s/query".formatted(DATABASE_ID)) //
+ .withHeader(AUTHORIZATION, matching("Bearer .+"))
+ .withHeader(CONTENT_TYPE, containing("application/json"))
+ .withHeader(NOTION_VERSION, equalTo(NOTION_VERSION_VALUE))
+ .withRequestBody(equalToJson(queryRequest(PAGE_SIZE, sortByProperty("Name", DESCENDING))))
+ .willReturn(okJson(queryResponse(thirdResultId, firstResult, secondResult))));
+
+ givenThat(post("/databases/%s/query".formatted(DATABASE_ID)) //
+ .withHeader(AUTHORIZATION, matching("Bearer .+"))
+ .withHeader(CONTENT_TYPE, containing("application/json"))
+ .withHeader(NOTION_VERSION, equalTo(NOTION_VERSION_VALUE))
+ .withRequestBody(equalToJson(queryRequest(thirdResultId, PAGE_SIZE, sortByProperty("Name", DESCENDING))))
+ .willReturn(okJson(queryResponse(thirdResult))));
+
+ // WHEN
+ JobExecution jobExecution = launcher.launchJob();
+
+ // THEN
+ then(jobExecution.getExitStatus()).isEqualTo(COMPLETED);
+
+ then(itemWriter.getWrittenItems()).asInstanceOf(LIST)
+ .containsExactly( //
+ new PaginatedDescendingJob.Item("Name string", "123456"), //
+ new PaginatedDescendingJob.Item("Another name string", "0987654321"), //
+ new PaginatedDescendingJob.Item("", "abc-1234"));
+ }
+
+ @SpringBootApplication
+ static class PaginatedDescendingJob {
+
+ @Value("${wiremock.server.baseUrl}")
+ private String wiremockBaseUrl;
+
+ @Bean
+ Job job(JobRepository jobRepository, Step step) {
+ return new JobBuilder("TEST-JOB", jobRepository).start(step).build();
+ }
+
+ @Bean
+ Step step(JobRepository jobRepository, PlatformTransactionManager transactionManager) {
+ return new StepBuilder("TEST-STEP", jobRepository) //
+ .chunk(PAGE_SIZE, transactionManager) //
+ .reader(itemReader()) //
+ .writer(itemWriter()) //
+ .build();
+ }
+
+ @Bean
+ NotionDatabaseItemReader itemReader() {
+ NotionDatabaseItemReader reader = new NotionDatabaseItemReader<>();
+
+ reader.setSaveState(false);
+
+ reader.setToken("token");
+ reader.setBaseUrl(wiremockBaseUrl);
+ reader.setDatabaseId(DATABASE_ID.toString());
+
+ reader.setPageSize(PAGE_SIZE);
+ reader.setSorts(Sort.by("Name", DESCENDING));
+ reader.setPropertyMapper(new RecordPropertyMapper<>());
+
+ return reader;
+ }
+
+ @Bean
+ ListItemWriter itemWriter() {
+ return new ListItemWriter<>();
+ }
+
+ record Item(String name, String value) {
+ }
+
+ }
+
+}
diff --git a/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/it/pagination/MultiplePagesTests.java b/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/it/pagination/MultiplePagesTests.java
new file mode 100644
index 00000000..2d47b94f
--- /dev/null
+++ b/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/it/pagination/MultiplePagesTests.java
@@ -0,0 +1,159 @@
+/*
+ * Copyright 2002-2024 the original author or 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.
+ */
+package org.springframework.batch.extensions.notion.it.pagination;
+
+import org.springframework.batch.extensions.notion.NotionDatabaseItemReader;
+import org.springframework.batch.extensions.notion.it.IntegrationTest;
+import org.springframework.batch.extensions.notion.mapping.RecordPropertyMapper;
+import org.json.JSONObject;
+import org.junit.jupiter.api.Test;
+import org.springframework.batch.core.Job;
+import org.springframework.batch.core.JobExecution;
+import org.springframework.batch.core.Step;
+import org.springframework.batch.core.job.builder.JobBuilder;
+import org.springframework.batch.core.repository.JobRepository;
+import org.springframework.batch.core.step.builder.StepBuilder;
+import org.springframework.batch.item.support.ListItemWriter;
+import org.springframework.batch.test.JobLauncherTestUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.annotation.Bean;
+import org.springframework.transaction.PlatformTransactionManager;
+
+import java.util.Map;
+import java.util.UUID;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.containing;
+import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson;
+import static com.github.tomakehurst.wiremock.client.WireMock.givenThat;
+import static com.github.tomakehurst.wiremock.client.WireMock.matching;
+import static com.github.tomakehurst.wiremock.client.WireMock.okJson;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static org.springframework.batch.extensions.notion.it.RequestBodies.queryRequest;
+import static org.springframework.batch.extensions.notion.it.RequestHeaders.NOTION_VERSION;
+import static org.springframework.batch.extensions.notion.it.RequestHeaders.NOTION_VERSION_VALUE;
+import static org.springframework.batch.extensions.notion.it.ResponseBodies.queryResponse;
+import static org.springframework.batch.extensions.notion.it.ResponseBodies.result;
+import static org.springframework.batch.extensions.notion.it.ResponseBodies.richText;
+import static org.springframework.batch.extensions.notion.it.ResponseBodies.title;
+import static java.util.UUID.randomUUID;
+import static org.assertj.core.api.BDDAssertions.then;
+import static org.assertj.core.api.InstanceOfAssertFactories.LIST;
+import static org.springframework.batch.core.ExitStatus.COMPLETED;
+import static wiremock.com.google.common.net.HttpHeaders.AUTHORIZATION;
+import static wiremock.com.google.common.net.HttpHeaders.CONTENT_TYPE;
+
+@IntegrationTest
+class MultiplePagesTests {
+
+ private static final UUID DATABASE_ID = randomUUID();
+
+ private static final int PAGE_SIZE = 2;
+
+ @Autowired
+ JobLauncherTestUtils launcher;
+
+ @Autowired
+ ListItemWriter itemWriter;
+
+ @Test
+ void should_succeed() throws Exception {
+ // GIVEN
+ UUID thirdResultId = randomUUID();
+
+ JSONObject firstResult = result(randomUUID(), DATABASE_ID,
+ Map.of("Name", title("Another name string"), "Value", richText("0987654321")));
+ JSONObject secondResult = result(randomUUID(), DATABASE_ID,
+ Map.of("Name", title("Name string"), "Value", richText("123456")));
+ JSONObject thirdResult = result(thirdResultId, DATABASE_ID,
+ Map.of("Name", title(""), "Value", richText("abc-1234")));
+
+ givenThat(post("/databases/%s/query".formatted(DATABASE_ID)) //
+ .withHeader(AUTHORIZATION, matching("Bearer .+"))
+ .withHeader(CONTENT_TYPE, containing("application/json"))
+ .withHeader(NOTION_VERSION, equalTo(NOTION_VERSION_VALUE))
+ .withRequestBody(equalToJson(queryRequest(PAGE_SIZE)))
+ .willReturn(okJson(queryResponse(thirdResultId, firstResult, secondResult))));
+
+ givenThat(post("/databases/%s/query".formatted(DATABASE_ID)) //
+ .withHeader(AUTHORIZATION, matching("Bearer .+"))
+ .withHeader(CONTENT_TYPE, containing("application/json"))
+ .withHeader(NOTION_VERSION, equalTo(NOTION_VERSION_VALUE))
+ .withRequestBody(equalToJson(queryRequest(thirdResultId, PAGE_SIZE)))
+ .willReturn(okJson(queryResponse(thirdResult))));
+
+ // WHEN
+ JobExecution jobExecution = launcher.launchJob();
+
+ // THEN
+ then(jobExecution.getExitStatus()).isEqualTo(COMPLETED);
+
+ then(itemWriter.getWrittenItems()).asInstanceOf(LIST)
+ .containsExactly( //
+ new PaginatedJob.Item("Another name string", "0987654321"), //
+ new PaginatedJob.Item("Name string", "123456"), //
+ new PaginatedJob.Item("", "abc-1234"));
+ }
+
+ @SpringBootApplication
+ static class PaginatedJob {
+
+ @Value("${wiremock.server.baseUrl}")
+ private String wiremockBaseUrl;
+
+ @Bean
+ Job job(JobRepository jobRepository, Step step) {
+ return new JobBuilder("TEST-JOB", jobRepository).start(step).build();
+ }
+
+ @Bean
+ Step step(JobRepository jobRepository, PlatformTransactionManager transactionManager) {
+ return new StepBuilder("TEST-STEP", jobRepository) //
+ .chunk(PAGE_SIZE, transactionManager) //
+ .reader(itemReader()) //
+ .writer(itemWriter()) //
+ .build();
+ }
+
+ @Bean
+ NotionDatabaseItemReader itemReader() {
+ NotionDatabaseItemReader reader = new NotionDatabaseItemReader<>();
+
+ reader.setSaveState(false);
+
+ reader.setToken("token");
+ reader.setBaseUrl(wiremockBaseUrl);
+ reader.setDatabaseId(DATABASE_ID.toString());
+
+ reader.setPageSize(PAGE_SIZE);
+ reader.setPropertyMapper(new RecordPropertyMapper<>());
+
+ return reader;
+ }
+
+ @Bean
+ ListItemWriter itemWriter() {
+ return new ListItemWriter<>();
+ }
+
+ record Item(String name, String value) {
+ }
+
+ }
+
+}
diff --git a/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/mapping/BeanWrapperPropertyMapperTests.java b/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/mapping/BeanWrapperPropertyMapperTests.java
new file mode 100644
index 00000000..2811ea87
--- /dev/null
+++ b/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/mapping/BeanWrapperPropertyMapperTests.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright 2002-2024 the original author or 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.
+ */
+package org.springframework.batch.extensions.notion.mapping;
+
+import org.springframework.batch.extensions.notion.mapping.TestData.AllPropertiesSource;
+import org.springframework.batch.extensions.notion.mapping.TestData.PartialPropertiesSource;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+
+import java.util.Map;
+
+import static org.assertj.core.api.BDDAssertions.catchThrowable;
+import static org.assertj.core.api.BDDAssertions.from;
+import static org.assertj.core.api.BDDAssertions.then;
+
+class BeanWrapperPropertyMapperTests {
+
+ @ParameterizedTest
+ @AllPropertiesSource
+ void should_map_all_properties(Map properties) {
+ // GIVEN
+ PropertyMapper underTest = new BeanWrapperPropertyMapper<>(TestBean.class);
+ // WHEN
+ TestBean result = underTest.map(properties);
+ // THEN
+ then(result) //
+ .returns("Value1", from(TestBean::getField1)) //
+ .returns("Value2", from(TestBean::getField2));
+ }
+
+ @ParameterizedTest
+ @PartialPropertiesSource
+ void should_map_partial_properties(Map properties) {
+ // GIVEN
+ PropertyMapper underTest = new BeanWrapperPropertyMapper<>(TestBean.class);
+ // WHEN
+ TestBean result = underTest.map(properties);
+ // THEN
+ then(result) //
+ .returns("Value1", from(TestBean::getField1)) //
+ .returns(null, from(TestBean::getField2));
+ }
+
+ @ParameterizedTest
+ @AllPropertiesSource
+ void should_map_all_properties_without_type_parameter(Map properties) {
+ // GIVEN
+ PropertyMapper underTest = new BeanWrapperPropertyMapper<>();
+ // WHEN
+ TestBean result = underTest.map(properties);
+ // THEN
+ then(result) //
+ .returns("Value1", from(TestBean::getField1)) //
+ .returns("Value2", from(TestBean::getField2));
+ }
+
+ @Test
+ void should_fail_with_vararg_constructor_parameter() {
+ // WHEN
+ Throwable thrown = catchThrowable(() -> new BeanWrapperPropertyMapper<>(new TestBean()));
+ // THEN
+ then(thrown) //
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Please don't pass any values here. The type will be detected automagically.");
+ }
+
+ private static class TestBean {
+
+ private String field1;
+
+ private String field2;
+
+ public String getField1() {
+ return field1;
+ }
+
+ @SuppressWarnings("unused")
+ public void setField1(String field1) {
+ this.field1 = field1;
+ }
+
+ public String getField2() {
+ return field2;
+ }
+
+ @SuppressWarnings("unused")
+ public void setField2(String field2) {
+ this.field2 = field2;
+ }
+
+ }
+
+}
diff --git a/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/mapping/ConstructorPropertyMapperTests.java b/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/mapping/ConstructorPropertyMapperTests.java
new file mode 100644
index 00000000..a4d969ab
--- /dev/null
+++ b/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/mapping/ConstructorPropertyMapperTests.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright 2002-2024 the original author or 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.
+ */
+package org.springframework.batch.extensions.notion.mapping;
+
+import org.springframework.batch.extensions.notion.mapping.TestData.AllPropertiesSource;
+import org.springframework.batch.extensions.notion.mapping.TestData.PartialPropertiesSource;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+
+import java.util.Map;
+
+import static org.assertj.core.api.BDDAssertions.catchThrowable;
+import static org.assertj.core.api.BDDAssertions.from;
+import static org.assertj.core.api.BDDAssertions.then;
+
+class ConstructorPropertyMapperTests {
+
+ @Nested
+ class using_record_without_additional_constructors {
+
+ private record TestRecord(String field1, String field2) {
+ }
+
+ @ParameterizedTest
+ @AllPropertiesSource
+ void should_map_all_properties(Map properties) {
+ // GIVEN
+ PropertyMapper underTest = new ConstructorPropertyMapper<>(TestRecord.class);
+ // WHEN
+ TestRecord result = underTest.map(properties);
+ // THEN
+ then(result) //
+ .returns("Value1", from(TestRecord::field1)) //
+ .returns("Value2", from(TestRecord::field2));
+ }
+
+ @ParameterizedTest
+ @AllPropertiesSource
+ void should_map_all_properties_without_type_parameter(Map properties) {
+ // GIVEN
+ PropertyMapper underTest = new ConstructorPropertyMapper<>();
+ // WHEN
+ TestRecord result = underTest.map(properties);
+ // THEN
+ then(result) //
+ .returns("Value1", from(TestRecord::field1)) //
+ .returns("Value2", from(TestRecord::field2));
+ }
+
+ @Test
+ void should_fail_with_vararg_constructor_parameter() {
+ // WHEN
+ Throwable thrown = catchThrowable(() -> new ConstructorPropertyMapper<>(new TestRecord("value", "value")));
+ // THEN
+ then(thrown) //
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Please don't pass any values here. The type will be detected automagically.");
+ }
+
+ @ParameterizedTest
+ @PartialPropertiesSource
+ void should_map_partial_properties(Map properties) {
+ // GIVEN
+ PropertyMapper underTest = new ConstructorPropertyMapper<>(TestRecord.class);
+ // WHEN
+ TestRecord result = underTest.map(properties);
+ // THEN
+ then(result) //
+ .returns("Value1", from(TestRecord::field1)) //
+ .returns(null, from(TestRecord::field2));
+ }
+
+ }
+
+ @Nested
+ class using_record_with_additional_constructors {
+
+ private record TestRecord(String field1, String field2) {
+
+ @SuppressWarnings("unused")
+ private TestRecord() {
+ this(null, null);
+ }
+
+ @SuppressWarnings("unused")
+ private TestRecord(String field1) {
+ this(field1, null);
+ }
+
+ }
+
+ @Test
+ void should_fail() {
+ // WHEN
+ Throwable thrown = catchThrowable(() -> new ConstructorPropertyMapper<>(TestRecord.class));
+ // THEN
+ then(thrown).isInstanceOf(IllegalArgumentException.class) //
+ .cause() //
+ .isInstanceOf(NoSuchMethodException.class)
+ .hasMessageStartingWith("Multiple constructors available: ");
+ }
+
+ }
+
+}
diff --git a/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/mapping/RecordPropertyMapperTests.java b/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/mapping/RecordPropertyMapperTests.java
new file mode 100644
index 00000000..4f79ef4a
--- /dev/null
+++ b/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/mapping/RecordPropertyMapperTests.java
@@ -0,0 +1,121 @@
+/*
+ * Copyright 2002-2024 the original author or 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.
+ */
+package org.springframework.batch.extensions.notion.mapping;
+
+import org.springframework.batch.extensions.notion.mapping.TestData.AllPropertiesSource;
+import org.springframework.batch.extensions.notion.mapping.TestData.PartialPropertiesSource;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+
+import java.util.Map;
+
+import static org.assertj.core.api.BDDAssertions.catchThrowable;
+import static org.assertj.core.api.BDDAssertions.from;
+import static org.assertj.core.api.BDDAssertions.then;
+
+class RecordPropertyMapperTests {
+
+ @Nested
+ class using_record_without_additional_constructors {
+
+ private record TestRecord(String field1, String field2) {
+ }
+
+ @ParameterizedTest
+ @AllPropertiesSource
+ void should_map_all_properties(Map properties) {
+ // GIVEN
+ PropertyMapper underTest = new RecordPropertyMapper<>(TestRecord.class);
+ // WHEN
+ TestRecord result = underTest.map(properties);
+ // THEN
+ then(result) //
+ .returns("Value1", from(TestRecord::field1)) //
+ .returns("Value2", from(TestRecord::field2));
+ }
+
+ @ParameterizedTest
+ @AllPropertiesSource
+ void should_map_all_properties_without_type_parameter(Map properties) {
+ // GIVEN
+ PropertyMapper underTest = new RecordPropertyMapper<>();
+ // WHEN
+ TestRecord result = underTest.map(properties);
+ // THEN
+ then(result) //
+ .returns("Value1", from(TestRecord::field1)) //
+ .returns("Value2", from(TestRecord::field2));
+ }
+
+ @Test
+ void should_fail_with_vararg_constructor_parameter() {
+ // WHEN
+ Throwable thrown = catchThrowable(() -> new RecordPropertyMapper<>(new TestRecord("value", "value")));
+ // THEN
+ then(thrown) //
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Please don't pass any values here. The type will be detected automagically.");
+ }
+
+ @ParameterizedTest
+ @PartialPropertiesSource
+ void should_map_partial_properties(Map properties) {
+ // GIVEN
+ PropertyMapper underTest = new RecordPropertyMapper<>(TestRecord.class);
+ // WHEN
+ TestRecord result = underTest.map(properties);
+ // THEN
+ then(result) //
+ .returns("Value1", from(TestRecord::field1)) //
+ .returns(null, from(TestRecord::field2));
+ }
+
+ }
+
+ @Nested
+ class using_record_with_additional_constructors {
+
+ private record TestRecord(String field1, String field2) {
+
+ @SuppressWarnings("unused")
+ private TestRecord() {
+ this(null, null);
+ }
+
+ @SuppressWarnings("unused")
+ private TestRecord(String field1) {
+ this(field1, null);
+ }
+
+ }
+
+ @ParameterizedTest
+ @AllPropertiesSource
+ void should_map_all_properties(Map properties) {
+ // GIVEN
+ PropertyMapper underTest = new RecordPropertyMapper<>(TestRecord.class);
+ // WHEN
+ TestRecord result = underTest.map(properties);
+ // THEN
+ then(result) //
+ .returns("Value1", from(TestRecord::field1)) //
+ .returns("Value2", from(TestRecord::field2));
+ }
+
+ }
+
+}
diff --git a/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/mapping/TestData.java b/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/mapping/TestData.java
new file mode 100644
index 00000000..1c009a88
--- /dev/null
+++ b/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/mapping/TestData.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2002-2024 the original author or 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.
+ */
+package org.springframework.batch.extensions.notion.mapping;
+
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.lang.annotation.Retention;
+import java.util.Map;
+import java.util.stream.Stream;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+class TestData {
+
+ @Retention(RUNTIME)
+ @MethodSource("org.springframework.batch.extensions.notion.mapping.TestData#all_properties")
+ @interface AllPropertiesSource {
+
+ }
+
+ static Stream