Skip to content

Commit e6f0eac

Browse files
authored
[IcebergIO] Create tables with a partition spec (#34966)
* create table with partition spec * add description and regenerate config doc; trigger tests * spotless * log partition spec * fixes
1 parent 478e4c7 commit e6f0eac

12 files changed

Lines changed: 502 additions & 68 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
22
"comment": "Modify this file in a trivial way to cause this test suite to run.",
3-
"modification": 5
3+
"modification": 4
44
}

.github/workflows/IO_Iceberg_Unit_Tests.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ jobs:
9898
arguments: |
9999
-PdisableSpotlessCheck=true \
100100
-PdisableCheckStyle=true \
101+
--info
101102
- name: Archive JUnit Test Results
102103
uses: actions/upload-artifact@v4
103104
if: ${{ !success() }}

CHANGES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@
8484
* [Python] Prism runner now auto-enabled for some Python pipelines using the direct runner ([#34921](https://github.com/apache/beam/pull/34921)).
8585
* [YAML] WriteToTFRecord and ReadFromTFRecord Beam YAML support
8686
* Python: Added JupyterLab 4.x extension compatibility for enhanced notebook integration ([#34495](https://github.com/apache/beam/pull/34495)).
87+
* [IcebergIO] Create tables with a specified partition spec ([#34966](https://github.com/apache/beam/pull/34966))
8788
* [IcebergIO] Dynamically create namespaces if needed ([#35228](https://github.com/apache/beam/pull/35228))
8889

8990
## Breaking Changes

sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IcebergTableCreateConfig.java

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@
1818
package org.apache.beam.sdk.io.iceberg;
1919

2020
import com.google.auto.value.AutoValue;
21+
import java.util.List;
22+
import org.apache.beam.sdk.schemas.Schema;
2123
import org.apache.iceberg.PartitionSpec;
22-
import org.apache.iceberg.Schema;
24+
import org.checkerframework.checker.nullness.qual.Nullable;
2325
import org.checkerframework.dataflow.qual.Pure;
2426

2527
@AutoValue
@@ -31,18 +33,27 @@ public abstract class IcebergTableCreateConfig {
3133

3234
/** Partition spec destination, in the event that it must be dynamically created. */
3335
@Pure
34-
public abstract PartitionSpec getPartitionSpec();
36+
public @Nullable PartitionSpec getPartitionSpec() {
37+
@Nullable List<String> fields = getPartitionFields();
38+
if (fields == null || fields.isEmpty()) {
39+
return null;
40+
}
41+
return PartitionUtils.toPartitionSpec(fields, getSchema());
42+
}
43+
44+
@Pure
45+
public abstract @Nullable List<String> getPartitionFields();
3546

3647
@Pure
37-
public Builder builder() {
48+
public static Builder builder() {
3849
return new AutoValue_IcebergTableCreateConfig.Builder();
3950
}
4051

4152
@AutoValue.Builder
4253
public abstract static class Builder {
4354
public abstract Builder setSchema(Schema schema);
4455

45-
public abstract Builder setPartitionSpec(PartitionSpec partitionSpec);
56+
public abstract Builder setPartitionFields(@Nullable List<String> partitionFields);
4657

4758
@Pure
4859
public abstract IcebergTableCreateConfig build();

sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IcebergWriteSchemaTransformProvider.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,20 @@ public static Builder builder() {
110110
+ "Is mutually exclusive with 'keep' and 'drop'.")
111111
public abstract @Nullable String getOnly();
112112

113+
@SchemaFieldDescription(
114+
"Fields used to create a partition spec that is applied when tables are created. For a field 'foo', "
115+
+ "the available partition transforms are:\n\n"
116+
+ "- `foo`\n"
117+
+ "- `truncate(foo, N)`\n"
118+
+ "- `bucket(foo, N)`\n"
119+
+ "- `hour(foo)`\n"
120+
+ "- `day(foo)`\n"
121+
+ "- `month(foo)`\n"
122+
+ "- `year(foo)`\n"
123+
+ "- `void(foo)`\n\n"
124+
+ "For more information on partition transforms, please visit https://iceberg.apache.org/spec/#partition-transforms.")
125+
public abstract @Nullable List<String> getPartitionFields();
126+
113127
@AutoValue.Builder
114128
public abstract static class Builder {
115129
public abstract Builder setTable(String table);
@@ -128,6 +142,8 @@ public abstract static class Builder {
128142

129143
public abstract Builder setOnly(String only);
130144

145+
public abstract Builder setPartitionFields(List<String> partitionFields);
146+
131147
public abstract Configuration build();
132148
}
133149

@@ -192,6 +208,7 @@ public PCollectionRowTuple expand(PCollectionRowTuple input) {
192208
configuration.getTable(),
193209
FileFormat.PARQUET.toString(),
194210
rows.getSchema(),
211+
configuration.getPartitionFields(),
195212
configuration.getDrop(),
196213
configuration.getKeep(),
197214
configuration.getOnly()));
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
package org.apache.beam.sdk.io.iceberg;
19+
20+
import static org.apache.beam.sdk.util.Preconditions.checkStateNotNull;
21+
22+
import java.util.List;
23+
import java.util.Map;
24+
import java.util.function.BiFunction;
25+
import java.util.regex.Matcher;
26+
import java.util.regex.Pattern;
27+
import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableMap;
28+
import org.apache.iceberg.PartitionSpec;
29+
import org.apache.iceberg.Schema;
30+
31+
class PartitionUtils {
32+
private static final Pattern HOUR = Pattern.compile("^hour\\(([a-zA-Z0-9_-]+)\\)$");
33+
private static final Pattern DAY = Pattern.compile("^day\\(([a-zA-Z0-9_-]+)\\)$");
34+
private static final Pattern MONTH = Pattern.compile("^month\\(([a-zA-Z0-9_-]+)\\)$");
35+
private static final Pattern YEAR = Pattern.compile("^year\\(([a-zA-Z0-9_-]+)\\)$");
36+
private static final Pattern TRUNCATE =
37+
Pattern.compile("^truncate\\(([a-zA-Z0-9_-]+),\\s*(\\d+)\\)$");
38+
private static final Pattern BUCKET =
39+
Pattern.compile("^bucket\\(([a-zA-Z0-9_-]+),\\s*(\\d+)\\)$");
40+
private static final Pattern VOID = Pattern.compile("^void\\(([^)]+)\\)$");
41+
private static final Pattern IDENTITY = Pattern.compile("^([a-zA-Z0-9_-]+)$");
42+
43+
private static final Map<
44+
Pattern, BiFunction<PartitionSpec.Builder, Matcher, PartitionSpec.Builder>>
45+
TRANSFORMATIONS =
46+
ImmutableMap.of(
47+
HOUR, (builder, matcher) -> builder.hour(checkStateNotNull(matcher.group(1))),
48+
DAY, (builder, matcher) -> builder.day(checkStateNotNull(matcher.group(1))),
49+
MONTH, (builder, matcher) -> builder.month(checkStateNotNull(matcher.group(1))),
50+
YEAR, (builder, matcher) -> builder.year(checkStateNotNull(matcher.group(1))),
51+
TRUNCATE,
52+
(builder, matcher) ->
53+
builder.truncate(
54+
checkStateNotNull(matcher.group(1)),
55+
Integer.parseInt(checkStateNotNull(matcher.group(2)))),
56+
BUCKET,
57+
(builder, matcher) ->
58+
builder.bucket(
59+
checkStateNotNull(matcher.group(1)),
60+
Integer.parseInt(checkStateNotNull(matcher.group(2)))),
61+
VOID, (builder, matcher) -> builder.alwaysNull(checkStateNotNull(matcher.group(1))),
62+
IDENTITY,
63+
(builder, matcher) -> builder.identity(checkStateNotNull(matcher.group(1))));
64+
65+
static PartitionSpec toPartitionSpec(
66+
List<String> fields, org.apache.beam.sdk.schemas.Schema beamSchema) {
67+
Schema schema = IcebergUtils.beamSchemaToIcebergSchema(beamSchema);
68+
PartitionSpec.Builder builder = PartitionSpec.builderFor(schema);
69+
70+
for (String field : fields) {
71+
boolean matched = false;
72+
for (Map.Entry<Pattern, BiFunction<PartitionSpec.Builder, Matcher, PartitionSpec.Builder>>
73+
entry : TRANSFORMATIONS.entrySet()) {
74+
Matcher matcher = entry.getKey().matcher(field);
75+
if (matcher.find()) {
76+
builder = entry.getValue().apply(builder, matcher);
77+
matched = true;
78+
break;
79+
}
80+
}
81+
if (!matched) {
82+
throw new IllegalArgumentException(
83+
"Could not find a partition transform for '" + field + "'.");
84+
}
85+
}
86+
87+
return builder.build();
88+
}
89+
}

sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/PortableIcebergDestinations.java

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,18 @@ class PortableIcebergDestinations implements DynamicDestinations {
3232
private final RowStringInterpolator interpolator;
3333
private final String fileFormat;
3434

35+
private final @Nullable List<String> partitionFields;
36+
3537
public PortableIcebergDestinations(
3638
String destinationTemplate,
3739
String fileFormat,
3840
Schema inputSchema,
41+
@Nullable List<String> partitionFields,
3942
@Nullable List<String> fieldsToDrop,
4043
@Nullable List<String> fieldsToKeep,
4144
@Nullable String onlyField) {
42-
interpolator = new RowStringInterpolator(destinationTemplate, inputSchema);
45+
this.interpolator = new RowStringInterpolator(destinationTemplate, inputSchema);
46+
this.partitionFields = partitionFields;
4347
RowFilter rf = new RowFilter(inputSchema);
4448

4549
if (fieldsToDrop != null) {
@@ -51,7 +55,7 @@ public PortableIcebergDestinations(
5155
if (onlyField != null) {
5256
rf = rf.only(onlyField);
5357
}
54-
rowFilter = rf;
58+
this.rowFilter = rf;
5559
this.fileFormat = fileFormat;
5660
}
5761

@@ -74,7 +78,11 @@ public String getTableStringIdentifier(ValueInSingleWindow<Row> element) {
7478
public IcebergDestination instantiateDestination(String dest) {
7579
return IcebergDestination.builder()
7680
.setTableIdentifier(TableIdentifier.parse(dest))
77-
.setTableCreateConfig(null)
81+
.setTableCreateConfig(
82+
IcebergTableCreateConfig.builder()
83+
.setSchema(getDataSchema())
84+
.setPartitionFields(partitionFields)
85+
.build())
7886
.setFileFormat(FileFormat.fromString(fileFormat))
7987
.build();
8088
}

sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/RecordWriterManager.java

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -279,14 +279,23 @@ static String getPartitionDataPath(
279279
* implementation. Although it is expected, some implementations may not support creating a table
280280
* using the Iceberg API.
281281
*/
282-
private Table getOrCreateTable(TableIdentifier identifier, Schema dataSchema) {
283-
Namespace namespace = identifier.namespace();
282+
private Table getOrCreateTable(IcebergDestination destination, Schema dataSchema) {
283+
TableIdentifier identifier = destination.getTableIdentifier();
284284
@Nullable Table table = TABLE_CACHE.getIfPresent(identifier);
285285
if (table != null) {
286+
// If fetching from cache, refresh the table to avoid working with stale metadata
287+
// (e.g. partition spec)
286288
table.refresh();
287289
return table;
288290
}
289291

292+
Namespace namespace = identifier.namespace();
293+
@Nullable IcebergTableCreateConfig createConfig = destination.getTableCreateConfig();
294+
PartitionSpec partitionSpec = PartitionSpec.unpartitioned();
295+
if (createConfig != null && createConfig.getPartitionSpec() != null) {
296+
partitionSpec = createConfig.getPartitionSpec();
297+
}
298+
290299
synchronized (TABLE_CACHE) {
291300
// Create namespace if it does not exist yet
292301
if (catalog instanceof SupportsNamespaces) {
@@ -309,9 +318,12 @@ private Table getOrCreateTable(TableIdentifier identifier, Schema dataSchema) {
309318
} catch (NoSuchTableException e) { // Otherwise, create the table
310319
org.apache.iceberg.Schema tableSchema = IcebergUtils.beamSchemaToIcebergSchema(dataSchema);
311320
try {
312-
// TODO(ahmedabu98): support creating a table with a specified partition spec
313-
table = catalog.createTable(identifier, tableSchema);
314-
LOG.info("Created Iceberg table '{}' with schema: {}", identifier, tableSchema);
321+
table = catalog.createTable(identifier, tableSchema, partitionSpec);
322+
LOG.info(
323+
"Created Iceberg table '{}' with schema: {}\n, partition spec: {}",
324+
identifier,
325+
tableSchema,
326+
partitionSpec);
315327
} catch (AlreadyExistsException ignored) {
316328
// race condition: another worker already created this table
317329
table = catalog.loadTable(identifier);
@@ -335,9 +347,9 @@ public boolean write(WindowedValue<IcebergDestination> icebergDestination, Row r
335347
destinations.computeIfAbsent(
336348
icebergDestination,
337349
destination -> {
338-
TableIdentifier identifier = destination.getValue().getTableIdentifier();
339-
Table table = getOrCreateTable(identifier, row.getSchema());
340-
return new DestinationState(destination.getValue(), table);
350+
IcebergDestination dest = destination.getValue();
351+
Table table = getOrCreateTable(dest, row.getSchema());
352+
return new DestinationState(dest, table);
341353
});
342354

343355
Record icebergRecord = IcebergUtils.beamRowToIcebergRecord(destinationState.schema, row);

0 commit comments

Comments
 (0)