This is an automated email from the ASF dual-hosted git repository.
etudenhoefner pushed a commit to branch 1.10.x
in repository https://gitbox.apache.org/repos/asf/iceberg.git
The following commit(s) were added to refs/heads/1.10.x by this push:
new be18b36326 [1.10.x] Core: Fix planning request/response validations
(#14566)
be18b36326 is described below
commit be18b36326896884a3e50afa3715e650c22eb616
Author: Eduard Tudenhoefner <[email protected]>
AuthorDate: Wed Nov 12 09:22:23 2025 +0100
[1.10.x] Core: Fix planning request/response validations (#14566)
* Core: Fix validation of PlanTableScanRequest (#14561)
The OpenAPI spec says in
https://github.com/apache/iceberg/blob/main/open-api/rest-catalog-open-api.yaml#L618-L624
that if no snapshotId is provided, the server can select the latest
snapshot in the table's main branch. However, currently the request always
requires to set either the `snapshotId` or the `startSnapshotId`/`endSnapshotId`
* Core: Fix PlanTableScanResponse validation (#14562)
The OpenAPI spec indicates in
https://github.com/apache/iceberg/blob/main/open-api/rest-catalog-open-api.yaml#L3376-L3384
that a `completed` planning result can have a `planId`
---
.../rest/requests/PlanTableScanRequest.java | 23 +++++++-
.../rest/requests/PlanTableScanRequestParser.java | 2 +-
.../rest/responses/PlanTableScanResponse.java | 27 +++++++--
...st.java => TestPlanTableScanRequestParser.java} | 56 +++++++++++++++---
.../responses/TestPlanTableScanResponseParser.java | 67 ++++++++++++++++------
5 files changed, 142 insertions(+), 33 deletions(-)
diff --git
a/core/src/main/java/org/apache/iceberg/rest/requests/PlanTableScanRequest.java
b/core/src/main/java/org/apache/iceberg/rest/requests/PlanTableScanRequest.java
index 14e14eab4b..97eeb56fde 100644
---
a/core/src/main/java/org/apache/iceberg/rest/requests/PlanTableScanRequest.java
+++
b/core/src/main/java/org/apache/iceberg/rest/requests/PlanTableScanRequest.java
@@ -88,9 +88,17 @@ public class PlanTableScanRequest implements RESTRequest {
@Override
public void validate() {
- Preconditions.checkArgument(
- snapshotId != null ^ (startSnapshotId != null && endSnapshotId !=
null),
- "Either snapshotId must be provided or both startSnapshotId and
endSnapshotId must be provided");
+ if (null != snapshotId) {
+ Preconditions.checkArgument(
+ null == startSnapshotId && null == endSnapshotId,
+ "Invalid scan: cannot provide both snapshotId and
startSnapshotId/endSnapshotId");
+ }
+
+ if (null != startSnapshotId || null != endSnapshotId) {
+ Preconditions.checkArgument(
+ null != startSnapshotId && null != endSnapshotId,
+ "Invalid incremental scan: startSnapshotId and endSnapshotId is
required");
+ }
}
@Override
@@ -107,6 +115,10 @@ public class PlanTableScanRequest implements RESTRequest {
.toString();
}
+ public static Builder builder() {
+ return new Builder();
+ }
+
public static class Builder {
private Long snapshotId;
private List<String> select;
@@ -117,6 +129,11 @@ public class PlanTableScanRequest implements RESTRequest {
private Long endSnapshotId;
private List<String> statsFields;
+ /**
+ * @deprecated since 1.11.0, visibility will be reduced in 1.12.0; use
{@link
+ * PlanTableScanRequest#builder()} instead.
+ */
+ @Deprecated
public Builder() {}
public Builder withSnapshotId(Long withSnapshotId) {
diff --git
a/core/src/main/java/org/apache/iceberg/rest/requests/PlanTableScanRequestParser.java
b/core/src/main/java/org/apache/iceberg/rest/requests/PlanTableScanRequestParser.java
index 9b2eb9adb4..6801330466 100644
---
a/core/src/main/java/org/apache/iceberg/rest/requests/PlanTableScanRequestParser.java
+++
b/core/src/main/java/org/apache/iceberg/rest/requests/PlanTableScanRequestParser.java
@@ -121,7 +121,7 @@ public class PlanTableScanRequestParser {
List<String> statsFields = JsonUtil.getStringListOrNull(STATS_FIELDS,
json);
- return new PlanTableScanRequest.Builder()
+ return PlanTableScanRequest.builder()
.withSnapshotId(snapshotId)
.withSelect(select)
.withFilter(filter)
diff --git
a/core/src/main/java/org/apache/iceberg/rest/responses/PlanTableScanResponse.java
b/core/src/main/java/org/apache/iceberg/rest/responses/PlanTableScanResponse.java
index 4596f8d5cd..95f862de96 100644
---
a/core/src/main/java/org/apache/iceberg/rest/responses/PlanTableScanResponse.java
+++
b/core/src/main/java/org/apache/iceberg/rest/responses/PlanTableScanResponse.java
@@ -66,16 +66,24 @@ public class PlanTableScanResponse extends
BaseScanTaskResponse {
planStatus() != null, "Invalid response: plan status must be defined");
Preconditions.checkArgument(
planStatus() != PlanStatus.SUBMITTED || planId() != null,
- "Invalid response: plan id should be defined when status is
'submitted'");
+ "Invalid response: plan id should be defined when status is '%s'",
+ PlanStatus.SUBMITTED.status());
Preconditions.checkArgument(
planStatus() != PlanStatus.CANCELLED,
- "Invalid response: 'cancelled' is not a valid status for
planTableScan");
+ "Invalid response: '%s' is not a valid status for planTableScan",
+ PlanStatus.CANCELLED.status());
Preconditions.checkArgument(
planStatus() == PlanStatus.COMPLETED || (planTasks() == null &&
fileScanTasks() == null),
- "Invalid response: tasks can only be defined when status is
'completed'");
- Preconditions.checkArgument(
- planStatus() == PlanStatus.SUBMITTED || planId() == null,
- "Invalid response: plan id can only be defined when status is
'submitted'");
+ "Invalid response: tasks can only be defined when status is '%s'",
+ PlanStatus.COMPLETED.status());
+ if (null != planId()) {
+ Preconditions.checkArgument(
+ planStatus() == PlanStatus.SUBMITTED || planStatus() ==
PlanStatus.COMPLETED,
+ "Invalid response: plan id can only be defined when status is '%s'
or '%s'",
+ PlanStatus.SUBMITTED.status(),
+ PlanStatus.COMPLETED.status());
+ }
+
if (fileScanTasks() == null || fileScanTasks().isEmpty()) {
Preconditions.checkArgument(
(deleteFiles() == null || deleteFiles().isEmpty()),
@@ -91,6 +99,13 @@ public class PlanTableScanResponse extends
BaseScanTaskResponse {
private PlanStatus planStatus;
private String planId;
+ /**
+ * @deprecated since 1.11.0, visibility will be reduced in 1.12.0; use
{@link
+ * PlanTableScanResponse#builder()} instead.
+ */
+ @Deprecated
+ public Builder() {}
+
public Builder withPlanStatus(PlanStatus status) {
this.planStatus = status;
return this;
diff --git
a/core/src/test/java/org/apache/iceberg/rest/requests/TestPlanTableScanRequest.java
b/core/src/test/java/org/apache/iceberg/rest/requests/TestPlanTableScanRequestParser.java
similarity index 69%
rename from
core/src/test/java/org/apache/iceberg/rest/requests/TestPlanTableScanRequest.java
rename to
core/src/test/java/org/apache/iceberg/rest/requests/TestPlanTableScanRequestParser.java
index f18928a1a3..55929d53d3 100644
---
a/core/src/test/java/org/apache/iceberg/rest/requests/TestPlanTableScanRequest.java
+++
b/core/src/test/java/org/apache/iceberg/rest/requests/TestPlanTableScanRequestParser.java
@@ -26,7 +26,7 @@ import org.apache.iceberg.expressions.Expressions;
import org.apache.iceberg.relocated.com.google.common.collect.Lists;
import org.junit.jupiter.api.Test;
-public class TestPlanTableScanRequest {
+public class TestPlanTableScanRequestParser {
@Test
public void nullAndEmptyCheck() {
@@ -39,10 +39,52 @@ public class TestPlanTableScanRequest {
.hasMessage("Invalid planTableScanRequest: null");
}
+ @Test
+ public void requestWithValidSnapshotIds() {
+ PlanTableScanRequest request = PlanTableScanRequest.builder().build();
+ assertThat(request).isNotNull();
+ assertThat(request.snapshotId()).isNull();
+ assertThat(request.startSnapshotId()).isNull();
+ assertThat(request.endSnapshotId()).isNull();
+
+ request = PlanTableScanRequest.builder().withSnapshotId(1L).build();
+ assertThat(request.snapshotId()).isEqualTo(1L);
+ assertThat(request.startSnapshotId()).isNull();
+ assertThat(request.endSnapshotId()).isNull();
+
+ request =
PlanTableScanRequest.builder().withStartSnapshotId(1L).withEndSnapshotId(5L).build();
+ assertThat(request.snapshotId()).isNull();
+ assertThat(request.startSnapshotId()).isEqualTo(1L);
+ assertThat(request.endSnapshotId()).isEqualTo(5);
+ }
+
+ @Test
+ public void requestWithInvalidSnapshotIds() {
+ assertThatThrownBy(
+ () ->
PlanTableScanRequest.builder().withSnapshotId(1L).withStartSnapshotId(1L).build())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage(
+ "Invalid scan: cannot provide both snapshotId and
startSnapshotId/endSnapshotId");
+
+ assertThatThrownBy(
+ () ->
PlanTableScanRequest.builder().withSnapshotId(1L).withEndSnapshotId(5L).build())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage(
+ "Invalid scan: cannot provide both snapshotId and
startSnapshotId/endSnapshotId");
+
+ assertThatThrownBy(() ->
PlanTableScanRequest.builder().withStartSnapshotId(1L).build())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Invalid incremental scan: startSnapshotId and
endSnapshotId is required");
+
+ assertThatThrownBy(() ->
PlanTableScanRequest.builder().withEndSnapshotId(5L).build())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Invalid incremental scan: startSnapshotId and
endSnapshotId is required");
+ }
+
@Test
public void roundTripSerdeWithSelectField() {
PlanTableScanRequest request =
- new PlanTableScanRequest.Builder()
+ PlanTableScanRequest.builder()
.withSnapshotId(1L)
.withSelect(Lists.newArrayList("col1", "col2"))
.build();
@@ -62,7 +104,7 @@ public class TestPlanTableScanRequest {
@Test
public void roundTripSerdeWithFilterField() {
PlanTableScanRequest request =
- new PlanTableScanRequest.Builder()
+ PlanTableScanRequest.builder()
.withSnapshotId(1L)
.withFilter(Expressions.alwaysFalse())
.build();
@@ -83,7 +125,7 @@ public class TestPlanTableScanRequest {
public void planTableScanRequestWithAllFieldsInvalidRequest() {
assertThatThrownBy(
() ->
- new PlanTableScanRequest.Builder()
+ PlanTableScanRequest.builder()
.withSnapshotId(1L)
.withSelect(Lists.newArrayList("col1", "col2"))
.withFilter(Expressions.alwaysTrue())
@@ -95,13 +137,13 @@ public class TestPlanTableScanRequest {
.build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessage(
- "Either snapshotId must be provided or both startSnapshotId and
endSnapshotId must be provided");
+ "Invalid scan: cannot provide both snapshotId and
startSnapshotId/endSnapshotId");
}
@Test
public void roundTripSerdeWithAllFieldsExceptSnapShotId() {
PlanTableScanRequest request =
- new PlanTableScanRequest.Builder()
+ PlanTableScanRequest.builder()
.withSelect(Lists.newArrayList("col1", "col2"))
.withFilter(Expressions.alwaysTrue())
.withStartSnapshotId(1L)
@@ -129,7 +171,7 @@ public class TestPlanTableScanRequest {
@Test
public void testToStringContainsAllFields() {
PlanTableScanRequest request =
- new PlanTableScanRequest.Builder()
+ PlanTableScanRequest.builder()
.withSnapshotId(123L)
.withSelect(Lists.newArrayList("colA", "colB"))
.withFilter(Expressions.alwaysTrue())
diff --git
a/core/src/test/java/org/apache/iceberg/rest/responses/TestPlanTableScanResponseParser.java
b/core/src/test/java/org/apache/iceberg/rest/responses/TestPlanTableScanResponseParser.java
index 49c0ad1fa0..c807bc4655 100644
---
a/core/src/test/java/org/apache/iceberg/rest/responses/TestPlanTableScanResponseParser.java
+++
b/core/src/test/java/org/apache/iceberg/rest/responses/TestPlanTableScanResponseParser.java
@@ -53,7 +53,6 @@ public class TestPlanTableScanResponseParser {
@Test
public void roundTripSerdeWithEmptyObject() {
-
assertThatThrownBy(() -> PlanTableScanResponse.builder().build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Invalid response: plan status must be defined");
@@ -65,6 +64,45 @@ public class TestPlanTableScanResponseParser {
.hasMessage("Cannot parse planTableScan response from empty or null
object");
}
+ @Test
+ public void roundTripSerdeWithCompletedPlanningWithAndWithoutPlanId() {
+ PlanTableScanResponse response =
+ PlanTableScanResponse.builder()
+ .withPlanStatus(PlanStatus.COMPLETED)
+ .withSpecsById(PARTITION_SPECS_BY_ID)
+ .build();
+ assertThat(response.planStatus()).isEqualTo(PlanStatus.COMPLETED);
+ assertThat(response.planId()).isNull();
+ assertThat(PlanTableScanResponseParser.toJson(response))
+ .isEqualTo("{\"plan-status\":\"completed\"}");
+
+ response =
+ PlanTableScanResponse.builder()
+ .withPlanStatus(PlanStatus.COMPLETED)
+ .withPlanId("somePlanId")
+ .withSpecsById(PARTITION_SPECS_BY_ID)
+ .build();
+ assertThat(response.planStatus()).isEqualTo(PlanStatus.COMPLETED);
+ assertThat(response.planId()).isEqualTo("somePlanId");
+
+ assertThat(PlanTableScanResponseParser.toJson(response))
+
.isEqualTo("{\"plan-status\":\"completed\",\"plan-id\":\"somePlanId\"}");
+ }
+
+ @Test
+ public void roundTripSerdeWithSubmittedPlanningWithPlanId() {
+ PlanTableScanResponse response =
+ PlanTableScanResponse.builder()
+ .withPlanStatus(PlanStatus.SUBMITTED)
+ .withSpecsById(PARTITION_SPECS_BY_ID)
+ .withPlanId("somePlanId")
+ .build();
+ assertThat(response.planStatus()).isEqualTo(PlanStatus.SUBMITTED);
+ assertThat(response.planId()).isEqualTo("somePlanId");
+ assertThat(PlanTableScanResponseParser.toJson(response))
+
.isEqualTo("{\"plan-status\":\"submitted\",\"plan-id\":\"somePlanId\"}");
+ }
+
@Test
public void roundTripSerdeWithInvalidPlanStatus() {
String invalidStatusJson = "{\"plan-status\": \"someStatus\"}";
@@ -78,9 +116,8 @@ public class TestPlanTableScanResponseParser {
@Test
public void roundTripSerdeWithInvalidPlanStatusSubmittedWithoutPlanId() {
- PlanStatus planStatus = PlanStatus.fromName("submitted");
-
- assertThatThrownBy(() ->
PlanTableScanResponse.builder().withPlanStatus(planStatus).build())
+ assertThatThrownBy(
+ () ->
PlanTableScanResponse.builder().withPlanStatus(PlanStatus.SUBMITTED).build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Invalid response: plan id should be defined when status
is 'submitted'");
@@ -93,8 +130,8 @@ public class TestPlanTableScanResponseParser {
@Test
public void roundTripSerdeWithInvalidPlanStatusCancelled() {
- PlanStatus planStatus = PlanStatus.fromName("cancelled");
- assertThatThrownBy(() ->
PlanTableScanResponse.builder().withPlanStatus(planStatus).build())
+ assertThatThrownBy(
+ () ->
PlanTableScanResponse.builder().withPlanStatus(PlanStatus.CANCELLED).build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Invalid response: 'cancelled' is not a valid status for
planTableScan");
@@ -107,11 +144,10 @@ public class TestPlanTableScanResponseParser {
@Test
public void roundTripSerdeWithInvalidPlanStatusSubmittedWithTasksPresent() {
- PlanStatus planStatus = PlanStatus.fromName("submitted");
assertThatThrownBy(
() ->
PlanTableScanResponse.builder()
- .withPlanStatus(planStatus)
+ .withPlanStatus(PlanStatus.SUBMITTED)
.withPlanId("somePlanId")
.withPlanTasks(List.of("task1", "task2"))
.build())
@@ -131,31 +167,31 @@ public class TestPlanTableScanResponseParser {
@Test
public void roundTripSerdeWithInvalidPlanIdWithIncorrectStatus() {
- PlanStatus planStatus = PlanStatus.fromName("failed");
assertThatThrownBy(
() ->
PlanTableScanResponse.builder()
- .withPlanStatus(planStatus)
+ .withPlanStatus(PlanStatus.FAILED)
.withPlanId("somePlanId")
.build())
.isInstanceOf(IllegalArgumentException.class)
- .hasMessage("Invalid response: plan id can only be defined when status
is 'submitted'");
+ .hasMessage(
+ "Invalid response: plan id can only be defined when status is
'submitted' or 'completed'");
String invalidJson = "{\"plan-status\":\"failed\"," +
"\"plan-id\":\"somePlanId\"}";
assertThatThrownBy(
() -> PlanTableScanResponseParser.fromJson(invalidJson,
PARTITION_SPECS_BY_ID, false))
.isInstanceOf(IllegalArgumentException.class)
- .hasMessage("Invalid response: plan id can only be defined when status
is 'submitted'");
+ .hasMessage(
+ "Invalid response: plan id can only be defined when status is
'submitted' or 'completed'");
}
@Test
public void
roundTripSerdeWithInvalidPlanStatusSubmittedWithDeleteFilesNoFileScanTasksPresent()
{
- PlanStatus planStatus = PlanStatus.fromName("submitted");
assertThatThrownBy(
() ->
PlanTableScanResponse.builder()
- .withPlanStatus(planStatus)
+ .withPlanStatus(PlanStatus.SUBMITTED)
.withPlanId("somePlanId")
.withDeleteFiles(List.of(FILE_A_DELETES))
.build())
@@ -190,10 +226,9 @@ public class TestPlanTableScanResponseParser {
PartitionSpecParser.toJson(SPEC),
residualEvaluator);
- PlanStatus planStatus = PlanStatus.fromName("completed");
PlanTableScanResponse response =
PlanTableScanResponse.builder()
- .withPlanStatus(planStatus)
+ .withPlanStatus(PlanStatus.COMPLETED)
.withFileScanTasks(List.of(fileScanTask))
.withDeleteFiles(List.of(FILE_A_DELETES))
.withSpecsById(PARTITION_SPECS_BY_ID)