diff --git a/spring-batch-notion/README.md b/spring-batch-notion/README.md index 6be1b043..be0f48ac 100644 --- a/spring-batch-notion/README.md +++ b/spring-batch-notion/README.md @@ -53,12 +53,12 @@ The following constructor parameters should be provided: and 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). | -| `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. | -| `sorts` | no | `null` | `Sort` conditions to order the returned items. Each condition is applied following the declaration order. | +| 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). | +| `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. | +| `sorts` | no | `Empty Sort Array(new Sort[0])` | `Sort` conditions to order the returned items. Each condition is applied following the declaration order. | 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) 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 index 04e0df37..39bbcb8d 100644 --- 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 @@ -85,9 +85,12 @@ public class NotionDatabaseItemReader extends AbstractPaginatedDataItemReader * of a Notion item into a Java object */ public NotionDatabaseItemReader(String token, String databaseId, PropertyMapper propertyMapper) { - this.token = Objects.requireNonNull(token); - this.databaseId = Objects.requireNonNull(databaseId); - this.propertyMapper = Objects.requireNonNull(propertyMapper); + Assert.notNull(token, "token is required"); + Assert.notNull(databaseId, "databaseId is required"); + Assert.notNull(propertyMapper, "propertyMapper is required"); + this.token = token; + this.databaseId = databaseId; + this.propertyMapper = propertyMapper; this.pageSize = DEFAULT_PAGE_SIZE; } diff --git a/spring-batch-notion/src/main/java/org/springframework/batch/extensions/notion/NotionDatabaseItemReaderBuilder.java b/spring-batch-notion/src/main/java/org/springframework/batch/extensions/notion/NotionDatabaseItemReaderBuilder.java new file mode 100644 index 00000000..f6373fe3 --- /dev/null +++ b/spring-batch-notion/src/main/java/org/springframework/batch/extensions/notion/NotionDatabaseItemReaderBuilder.java @@ -0,0 +1,220 @@ +/* + * Copyright 2024-2025 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.jspecify.annotations.Nullable; +import org.springframework.batch.extensions.notion.mapping.PropertyMapper; +import org.springframework.batch.infrastructure.item.ExecutionContext; +import org.springframework.util.Assert; + +/** + * A builder for the {@link NotionDatabaseItemReader}. + * + * @param Type of item to be read + * @author Jaeung Ha + * @see NotionDatabaseItemReader + */ +public class NotionDatabaseItemReaderBuilder { + + private static final int DEFAULT_PAGE_SIZE = 100; + + private @Nullable String token; + + private @Nullable String databaseId; + + private @Nullable PropertyMapper propertyMapper; + + private @Nullable String baseUrl; + + private @Nullable Filter filter; + + private @Nullable String name; + + private Sort[] sorts = new Sort[0]; + + private int pageSize = DEFAULT_PAGE_SIZE; + + private boolean saveState = true; + + private int maxItemCount = Integer.MAX_VALUE; + + private int currentItemCount = 0; + + /** + * Default constructor for {@link NotionDatabaseItemReaderBuilder}. + */ + public NotionDatabaseItemReaderBuilder() { + } + + /** + * Sets the Notion integration token. + * @param token the token + * @return this builder + * @see NotionDatabaseItemReader#NotionDatabaseItemReader(String, String, + * PropertyMapper) + */ + public NotionDatabaseItemReaderBuilder token(String token) { + this.token = token; + return this; + } + + /** + * Sets the UUID of the database to read from. + * @param databaseId the database UUID + * @return this builder + * @see NotionDatabaseItemReader#NotionDatabaseItemReader(String, String, + * PropertyMapper) + */ + public NotionDatabaseItemReaderBuilder databaseId(String databaseId) { + this.databaseId = databaseId; + return this; + } + + /** + * Sets the {@link PropertyMapper} to use. + * @param propertyMapper the property mapper + * @return this builder + * @see NotionDatabaseItemReader#NotionDatabaseItemReader(String, String, + * PropertyMapper) + */ + public NotionDatabaseItemReaderBuilder propertyMapper(PropertyMapper propertyMapper) { + this.propertyMapper = propertyMapper; + return this; + } + + /** + * Sets the base URL of the Notion API. + * @param baseUrl the base URL + * @return this builder + * @see NotionDatabaseItemReader#setBaseUrl(String) + */ + public NotionDatabaseItemReaderBuilder baseUrl(String baseUrl) { + this.baseUrl = baseUrl; + return this; + } + + /** + * Sets the {@link Filter} to apply. + * @param filter the filter + * @return this builder + * @see NotionDatabaseItemReader#setFilter(Filter) + */ + public NotionDatabaseItemReaderBuilder filter(Filter filter) { + this.filter = filter; + return this; + } + + /** + * Sets the {@link Sort}s to apply. + * @param sorts the sorts + * @return this builder + * @see NotionDatabaseItemReader#setSorts(Sort...) + */ + public NotionDatabaseItemReaderBuilder sorts(Sort... sorts) { + this.sorts = sorts; + return this; + } + + /** + * Sets the number of items to be read with each page. + * @param pageSize the page size + * @return this builder + * @see NotionDatabaseItemReader#setPageSize(int) + */ + public NotionDatabaseItemReaderBuilder pageSize(int pageSize) { + this.pageSize = pageSize; + return this; + } + + /** + * Sets the flag that determines whether to save the state of the reader for restarts. + * @param saveState the save state flag + * @return this builder + * @see NotionDatabaseItemReader#setSaveState(boolean) + */ + public NotionDatabaseItemReaderBuilder saveState(boolean saveState) { + this.saveState = saveState; + return this; + } + + /** + * The name used to calculate the key within the {@link ExecutionContext}. Required if + * {@link #saveState(boolean)} is set to true.
+ * @param name the unique name of the component + * @return this builder + * @see NotionDatabaseItemReader#setName(String) + */ + public NotionDatabaseItemReaderBuilder name(String name) { + this.name = name; + return this; + } + + /** + * Sets the maximum number of items to read. + * @param maxItemCount the maximum item count + * @return this builder + * @see NotionDatabaseItemReader#setMaxItemCount(int) + */ + public NotionDatabaseItemReaderBuilder maxItemCount(int maxItemCount) { + this.maxItemCount = maxItemCount; + return this; + } + + /** + * Sets the index of the item to start reading from. + * @param currentItemCount the current item count + * @return this builder + * @see NotionDatabaseItemReader#setCurrentItemCount(int) + */ + public NotionDatabaseItemReaderBuilder currentItemCount(int currentItemCount) { + this.currentItemCount = currentItemCount; + return this; + } + + /** + * Builds the {@link NotionDatabaseItemReader}. + * @return the built reader + * @throws IllegalArgumentException if required fields are missing + */ + public NotionDatabaseItemReader build() { + if (this.saveState) { + Assert.hasText(this.name, "A name is required when saveState is set to true."); + } + + if (token == null || databaseId == null || propertyMapper == null) { + throw new IllegalArgumentException("token, databaseId, and propertyMapper must not be null"); + } + NotionDatabaseItemReader reader = new NotionDatabaseItemReader<>(token, databaseId, propertyMapper); + + reader.setSaveState(saveState); + if (baseUrl != null) { + reader.setBaseUrl(baseUrl); + } + if (name != null) { + reader.setName(name); + } + if (filter != null) { + reader.setFilter(filter); + } + reader.setSorts(sorts); + reader.setPageSize(pageSize); + reader.setMaxItemCount(maxItemCount); + reader.setCurrentItemCount(currentItemCount); + + return reader; + } + +} diff --git a/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/NotionDatabaseItemReaderBuilderTests.java b/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/NotionDatabaseItemReaderBuilderTests.java new file mode 100644 index 00000000..6e72992a --- /dev/null +++ b/spring-batch-notion/src/test/java/org/springframework/batch/extensions/notion/NotionDatabaseItemReaderBuilderTests.java @@ -0,0 +1,233 @@ +/* + * Copyright 2024-2025 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.junit.jupiter.api.Test; +import org.springframework.batch.extensions.notion.mapping.PropertyMapper; + +import static org.assertj.core.api.BDDAssertions.catchThrowable; +import static org.assertj.core.api.BDDAssertions.then; + +@SuppressWarnings({ "DataFlowIssue", "null", "NullAway" }) +class NotionDatabaseItemReaderBuilderTests { + + @Test + void should_succeed() { + // GIVEN + String token = "FOO TOKEN"; + String databaseId = "FOO DATABASE ID"; + PropertyMapper propertyMapper = properties -> "FOO PROPERTY"; + Filter filter = Filter.where().checkbox("IsActive").isEqualTo(true); + String name = "FOO NAME"; + Sort[] sorts = new Sort[0]; + int pageSize = 50; + boolean saveState = true; + int maxItemCount = 1000; + int currentItemCount = 10; + String baseUrl = "https://example.com"; + + // WHEN + NotionDatabaseItemReader reader = new NotionDatabaseItemReaderBuilder().token(token) + .databaseId(databaseId) + .propertyMapper(propertyMapper) + .filter(filter) + .name(name) + .sorts(sorts) + .pageSize(pageSize) + .saveState(saveState) + .maxItemCount(maxItemCount) + .currentItemCount(currentItemCount) + .baseUrl(baseUrl) + .build(); + + // THEN + then(reader).extracting("token").isEqualTo(token); + then(reader).extracting("databaseId").isEqualTo(databaseId); + then(reader).extracting("propertyMapper").isEqualTo(propertyMapper); + then(reader).extracting("filter").isEqualTo(filter); + then(reader).extracting("sorts").isEqualTo(sorts); + then(reader).extracting("pageSize").isEqualTo(pageSize); + then(reader).extracting("saveState").isEqualTo(saveState); + then(reader).extracting("name").isEqualTo(name); + then(reader).extracting("maxItemCount").isEqualTo(maxItemCount); + then(reader).extracting("currentItemCount").isEqualTo(currentItemCount); + then(reader).extracting("baseUrl").isEqualTo(baseUrl); + } + + @Test + void should_succeed_when_saveStateIsFalse_and_nameIsNull() { + // GIVEN + String token = "FOO TOKEN"; + String databaseId = "FOO DATABASE ID"; + PropertyMapper propertyMapper = properties -> "FOO PROPERTY"; + boolean saveState = false; + String name = null; + + NotionDatabaseItemReaderBuilder builder = new NotionDatabaseItemReaderBuilder().token(token) + .propertyMapper(propertyMapper) + .databaseId(databaseId) + .saveState(saveState) + .name(name); + + // WHEN + NotionDatabaseItemReader reader = builder.build(); + + // THEN + then(reader).extracting("saveState").isEqualTo(false); + then(reader).extracting("name").isEqualTo(NotionDatabaseItemReader.class.getSimpleName()); + } + + @Test + void should_fail_when_tokenIsNull() { + // GIVEN + NotionDatabaseItemReaderBuilder builder = new NotionDatabaseItemReaderBuilder<>().token(null) + .databaseId("FOO DATABASE ID") + .propertyMapper(properties -> "FOO PROPERTY") + .saveState(false); + + // WHEN + Throwable exception = catchThrowable(builder::build); + + // THEN + then(exception).isInstanceOf(IllegalArgumentException.class); + then(exception).hasMessage("token, databaseId, and propertyMapper must not be null"); + } + + @Test + void should_fail_when_databaseIdIsNull() { + // GIVEN + NotionDatabaseItemReaderBuilder builder = new NotionDatabaseItemReaderBuilder<>().token("FOO TOKEN") + .databaseId(null) + .propertyMapper(properties -> "FOO PROPERTY") + .saveState(false); + + // WHEN + Throwable exception = catchThrowable(builder::build); + + // THEN + then(exception).isInstanceOf(IllegalArgumentException.class); + then(exception).hasMessage("token, databaseId, and propertyMapper must not be null"); + } + + @Test + void should_fail_when_propertyMapperIsNull() { + // GIVEN + NotionDatabaseItemReaderBuilder builder = new NotionDatabaseItemReaderBuilder<>().token("FOO TOKEN") + .databaseId("FOO DATABASE ID") + .propertyMapper(null) + .saveState(false); + + // WHEN + Throwable exception = catchThrowable(builder::build); + + // THEN + then(exception).isInstanceOf(IllegalArgumentException.class); + then(exception).hasMessage("token, databaseId, and propertyMapper must not be null"); + } + + @Test + void should_fail_when_saveStateIsTrue_and_nameIsBlank() { + // GIVEN + String token = "FOO TOKEN"; + String databaseId = "FOO DATABASE ID"; + PropertyMapper propertyMapper = properties -> "FOO PROPERTY"; + boolean saveState = true; + String name = ""; + + NotionDatabaseItemReaderBuilder builder = new NotionDatabaseItemReaderBuilder().token(token) + .propertyMapper(propertyMapper) + .databaseId(databaseId) + .saveState(saveState) + .name(name); + + // WHEN + Throwable exception = catchThrowable(builder::build); + + // THEN + then(exception).isInstanceOf(IllegalArgumentException.class) + .hasMessage("A name is required when saveState is set to true."); + } + + @Test + void should_fail_when_saveStateIsTrue_and_nameIsNull() { + // GIVEN + String token = "FOO TOKEN"; + String databaseId = "FOO DATABASE ID"; + PropertyMapper propertyMapper = properties -> "FOO PROPERTY"; + boolean saveState = true; + String name = null; + + NotionDatabaseItemReaderBuilder builder = new NotionDatabaseItemReaderBuilder().token(token) + .propertyMapper(propertyMapper) + .databaseId(databaseId) + .saveState(saveState) + .name(name); + + // WHEN + Throwable exception = catchThrowable(builder::build); + + // THEN + then(exception).isInstanceOf(IllegalArgumentException.class) + .hasMessage("A name is required when saveState is set to true."); + } + + @Test + void should_fail_when_pageSizeIsGreaterThan100() { + // GIVEN + String token = "FOO TOKEN"; + String databaseId = "FOO DATABASE ID"; + PropertyMapper propertyMapper = properties -> "FOO PROPERTY"; + String name = "FOO NAME"; + int pageSize = 101; + + NotionDatabaseItemReaderBuilder builder = new NotionDatabaseItemReaderBuilder().token(token) + .propertyMapper(propertyMapper) + .databaseId(databaseId) + .name(name) + .pageSize(pageSize); + + // WHEN + Throwable exception = catchThrowable(builder::build); + + // THEN + then(exception).isInstanceOf(IllegalArgumentException.class) + .hasMessage("pageSize must be less than or equal to 100"); + } + + @Test + void should_fail_when_pageSizeIsSmallerThanZero() { + // GIVEN + String token = "FOO TOKEN"; + String databaseId = "FOO DATABASE ID"; + PropertyMapper propertyMapper = properties -> "FOO PROPERTY"; + String name = "FOO NAME"; + int pageSize = -1; + + NotionDatabaseItemReaderBuilder builder = new NotionDatabaseItemReaderBuilder().token(token) + .propertyMapper(propertyMapper) + .databaseId(databaseId) + .name(name) + .pageSize(pageSize); + + // WHEN + Throwable exception = catchThrowable(builder::build); + + // THEN + then(exception).isInstanceOf(IllegalArgumentException.class).hasMessage("pageSize must be greater than zero"); + } + +}