Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
* (Python, Java, Go) Added `--dataflow_container_base_repository` (Python, Go) and `--dataflowContainerBaseRepository` (Java) to set the repository (e.g. `docker.example.com/my-prefix/cloud-dataflow/v1beta3`) from which the Dataflow runner pulls its default SDK container images, including the default images of cross-language Apache Beam environments. Defaults to `gcr.io/cloud-dataflow/v1beta3` ([#40569](https://github.com/apache/beam/issues/40569)).
* (Go) GCS and Dataflow clients now support non-default universes such as Trusted Partner Cloud (TPC), using the universe domain from `GOOGLE_CLOUD_UNIVERSE_DOMAIN` or the default credentials. Added a `--gcs_endpoint` pipeline option to override the GCS endpoint ([#40569](https://github.com/apache/beam/issues/40569)).
* (Java) `GcsUtil` now uses the java-storage client (GcsUtil V2) for GCS operations by default instead of gcsio (GcsUtil V1). To go back to V1, pass `--experiments=disable_gcsutil_v2`. Pipelines with the `use_grpc_for_gcs` experiment keep using V1. The `use_gcsutil_v2` experiment is no longer needed and is now ignored ([#40712](https://github.com/apache/beam/issues/40712)).
* [Beam SQL] Added a `WITH (...)` clause to set per-statement connector options in `FROM` and `INSERT INTO` ([#40848](https://github.com/apache/beam/issues/40848)).

## Breaking Changes

Expand Down
28 changes: 28 additions & 0 deletions sdks/java/extensions/sql/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,24 @@ task copyFmppTemplatesFromSrc(type: Copy) {
from "src/main/codegen"
into "${project.buildDir}/templates-fmpp/codegen"
}
// Calcite's Parser.jj has no extension points for the WITH clause of a table, so each anchor
// below gets Beam's grammar (parserImpls.ftl) appended on the same line.
def calciteParserExtensions = [
// TableRef3, after FOR SYSTEM_TIME AS OF: WITH of a table in FROM.
'[ tableRef = Snapshot(tableRef) ]': 'tableRef = TableClauseOpt(tableRef, TableClause.Role.READ)',
// SqlInsert, before the target column list: WITH of the INSERT target.
'[ LOOKAHEAD(5) tableRef = ExtendTable(tableRef) ]': 'tableRef = TableClauseOpt(tableRef, TableClause.Role.WRITE)',
// End of TableRef3: let a user alias replace the implicit alias of a table with WITH.
'[ tableRef = Tablesample(tableRef) ]': '{ tableRef = TableClause.collapseImplicitAlias(tableRef); }',
// Every WITH query: reject WITH options on its common table expressions.
'JAVACODE SqlNode addWith(SqlNodeList withList, SqlNode e) {': 'TableClause.checkCteReferences(withList, e);',
// OrderByLimitOpt, which attaches an ORDER BY to a WITH query after addWith: check it too.
'if (orderBy != null || offsetFetch[0] != null || offsetFetch[1] != null) {': 'TableClause.checkCteReferencesInOrderBy(e, orderBy);',
]

task copyFmppTemplatesFromCalciteCore(type: Copy) {
dependsOn configurations.fmppTemplates
inputs.property("calciteParserExtensions", calciteParserExtensions)
File calciteCoreJar = files(configurations.fmppTemplates.files).filter {
it.name.startsWith("beam-vendor-calcite")
}.singleFile
Expand All @@ -170,6 +186,18 @@ task copyFmppTemplatesFromCalciteCore(type: Copy) {
line ->
line.replace('import com.google.common.', 'import org.apache.beam.vendor.calcite.v1_40_0.com.google.common.')
}
doLast {
def parserJj = file("${project.buildDir}/templates-fmpp/codegen/templates/Parser.jj")
def grammar = parserJj.text
calciteParserExtensions.each { anchor, extension ->
def count = grammar.count(anchor)
if (count != 1) {
throw new GradleException("Expected '${anchor}' once in Calcite's Parser.jj, found it ${count} times")
}
grammar = grammar.replace(anchor, "${anchor} ${extension}")
}
parserJj.text = grammar
}
}

// Generate the FMPP sources from the FMPP templates.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,24 @@
package org.apache.beam.sdk.extensions.sql.meta.provider.iceberg;

import static org.apache.beam.sdk.util.Preconditions.checkStateNotNull;
import static org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkArgument;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.IntFunction;
import org.apache.beam.sdk.extensions.sql.meta.BeamSqlTable;
import org.apache.beam.sdk.extensions.sql.meta.BeamSqlTableFilter;
import org.apache.beam.sdk.extensions.sql.meta.DefaultTableFilter;
import org.apache.beam.sdk.extensions.sql.meta.ProjectSupport;
import org.apache.beam.sdk.extensions.sql.meta.SchemaBaseBeamTable;
import org.apache.beam.sdk.extensions.sql.meta.Table;
import org.apache.beam.sdk.extensions.sql.meta.provider.bigquery.BeamSqlUnparseContext;
import org.apache.beam.sdk.io.iceberg.IcebergCatalogConfig;
import org.apache.beam.sdk.io.iceberg.IcebergWriteSchemaTransformProvider;
import org.apache.beam.sdk.io.iceberg.cdc.IcebergCdcMetadataColumns;
import org.apache.beam.sdk.managed.Managed;
import org.apache.beam.sdk.schemas.Schema;
import org.apache.beam.sdk.values.PBegin;
Expand Down Expand Up @@ -63,11 +67,37 @@ final class IcebergTable extends SchemaBaseBeamTable {
@VisibleForTesting
static final String TRIGGERING_FREQUENCY_FIELD = "triggering_frequency_seconds";

private static final Schema WRITE_CONFIG_SCHEMA =
new IcebergWriteSchemaTransformProvider().configurationSchema();
private static final Map<String, String> WRITE_OWNED_OPTIONS =
ImmutableMap.<String, String>builder()
.putAll(
ManagedOptions.owned(
"the SQL table sets it",
"table",
"catalog_name",
"catalog_properties",
"config_properties"))
.putAll(
ManagedOptions.owned(
"the INSERT writes all of the table's columns", "keep", "drop", "only"))
.putAll(
ManagedOptions.owned(
"it applies only when the write creates the table",
"partition_fields",
"sort_fields",
"table_properties"))
.putAll(ManagedOptions.owned("an INSERT has no error output", "error_handling"))
.build();

@VisibleForTesting final String tableIdentifier;
@VisibleForTesting final IcebergCatalogConfig catalogConfig;
@VisibleForTesting @Nullable Integer triggeringFrequency;
@VisibleForTesting final @Nullable List<String> partitionFields;

/** The options of an INSERT's {@code WITH} clause, or empty for the table itself. */
private final Map<String, Object> writeOptions;

IcebergTable(String tableIdentifier, Table table, IcebergCatalogConfig catalogConfig) {
super(table.getSchema());
this.schema = table.getSchema();
Expand All @@ -90,19 +120,70 @@ final class IcebergTable extends SchemaBaseBeamTable {
}

this.partitionFields = table.getPartitionFields();
this.writeOptions = ImmutableMap.of();
}

private IcebergTable(IcebergTable table, Schema schema, Map<String, Object> writeOptions) {
super(schema);
this.tableIdentifier = table.tableIdentifier;
this.catalogConfig = table.catalogConfig;
this.triggeringFrequency = table.triggeringFrequency;
this.partitionFields = table.partitionFields;
this.writeOptions = writeOptions;
}

/**
* Returns this table as the target of an INSERT with {@code WITH} options, which are options of
* the Managed Iceberg write. With {@code 'mode' = 'merge-on-read'}, the INSERT also supplies the
* change type column, if {@code change_type_column} names one, and the sequence number column.
*/
@Override
public BeamSqlTable forWrite(Map<String, Object> options) {
Map<String, Object> checked =
ManagedOptions.check(options, WRITE_CONFIG_SCHEMA, WRITE_OWNED_OPTIONS, "an Iceberg write");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low: ManagedOptions.check validates option names and types against WRITE_CONFIG_SCHEMA, but IcebergWriteSchemaTransformProvider.Configuration#validateModeOptions() (which checks that mode is valid and that mode-specific options like equality_columns or autosharding match the chosen mode) is only invoked later during Managed.write expansion in buildIOWriter. Consider validating mode compatibility in forWrite as well so invalid mode/option combinations fail fast during parseQuery like other invalid WITH options.

Schema schema = getSchema();
if ("merge-on-read".equalsIgnoreCase((String) checked.get("mode"))) {
@Nullable String changeTypeColumn = (String) checked.get("change_type_column");
if (changeTypeColumn != null) {
schema = withControlColumn(schema, changeTypeColumn, Schema.FieldType.STRING);
}
Object sequenceNumberColumn =
checked.getOrDefault(
"sequence_number_column", IcebergCdcMetadataColumns.COMMIT_SNAPSHOT_SEQUENCE_NUMBER);
schema = withControlColumn(schema, (String) sequenceNumberColumn, Schema.FieldType.INT64);
}
return new IcebergTable(this, schema, checked);
}

private static Schema withControlColumn(Schema schema, String name, Schema.FieldType type) {
checkArgument(
!schema.hasField(name),
"Merge-on-read control column '%s' collides with another column",
name);
return Schema.builder().addFields(schema.getFields()).addField(name, type).build();
}

@Override
public POutput buildIOWriter(PCollection<Row> input) {
return input.apply(Managed.write(Managed.ICEBERG).withConfig(writeConfig()));
}

/**
* The Managed write configuration: the table's, its stored triggering frequency, the INSERT's
* {@code WITH} options, which override it, and its partition fields.
*/
@VisibleForTesting
Map<String, Object> writeConfig() {
ImmutableMap.Builder<String, Object> configBuilder = ImmutableMap.builder();
configBuilder.putAll(getBaseConfig());
if (triggeringFrequency != null) {
configBuilder.put(TRIGGERING_FREQUENCY_FIELD, triggeringFrequency);
}
configBuilder.putAll(writeOptions);
if (partitionFields != null) {
configBuilder.put("partition_fields", partitionFields);
}
return input.apply(Managed.write(Managed.ICEBERG).withConfig(configBuilder.build()));
return configBuilder.buildKeepingLast();
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/*
* 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.
*/
package org.apache.beam.sdk.extensions.sql.meta.provider.iceberg;

import static org.apache.beam.sdk.util.Preconditions.checkStateNotNull;
import static org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkArgument;

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.apache.beam.sdk.schemas.Schema;
import org.apache.beam.sdk.schemas.Schema.FieldType;
import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableList;
import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableMap;
import org.checkerframework.checker.nullness.qual.Nullable;

/** Checks the options of a {@code WITH} clause against a Managed configuration schema. */
final class ManagedOptions {
private ManagedOptions() {}

/** Maps each key to {@code reason}, why {@code WITH} cannot set it. */
static Map<String, String> owned(String reason, String... keys) {
ImmutableMap.Builder<String, String> owned = ImmutableMap.builder();
for (String key : keys) {
owned.put(key, reason);
}
return owned.build();
}

/**
* Returns the options as the Java values {@code configSchema} expects.
*
* @param owned the keys that {@code WITH} cannot set, each mapped to the reason
* @param use what the options configure, for error messages
* @throws IllegalArgumentException for an owned or unknown key, or a value of the wrong type
*/
static Map<String, Object> check(
Map<String, Object> options, Schema configSchema, Map<String, String> owned, String use) {
ImmutableMap.Builder<String, Object> checked = ImmutableMap.builder();
for (Map.Entry<String, Object> option : options.entrySet()) {
String key = option.getKey();
@Nullable String reason = owned.get(key);
checkArgument(reason == null, "Option '%s' cannot be set in WITH: %s", key, reason);
if (!configSchema.hasField(key)) {
List<String> accepted =
configSchema.getFieldNames().stream()
.filter(name -> !owned.containsKey(name))
.sorted()
.collect(Collectors.toList());
throw new IllegalArgumentException(
String.format("Unknown option '%s' for %s. Accepted options: %s", key, use, accepted));
}
FieldType type = configSchema.getField(key).getType();
@Nullable Object value = convert(option.getValue(), type);
if (value == null) {
throw new IllegalArgumentException(
String.format("Option '%s' must be of type %s", key, sqlType(type)));
}
checked.put(key, value);
}
return checked.build();
}

/** Returns {@code value} as the Java type of {@code type}, or null if it is not of that type. */
private static @Nullable Object convert(@Nullable Object value, FieldType type) {
switch (type.getTypeName()) {
case STRING:
return value instanceof String ? value : null;
case BOOLEAN:
return value instanceof Boolean ? value : null;
case INT32:
return value instanceof Long && (Long) value == ((Long) value).intValue()
? ((Long) value).intValue()
: null;
case ARRAY:
if (!(value instanceof List)) {
return null;
}
FieldType elementType = checkStateNotNull(type.getCollectionElementType());
ImmutableList.Builder<Object> list = ImmutableList.builder();
for (Object element : (List<?>) value) {
@Nullable Object converted = convert(element, elementType);
if (converted == null) {
return null;
}
list.add(converted);
}
return list.build();
case MAP:
if (!(value instanceof Map)) {
return null;
}
FieldType keyType = checkStateNotNull(type.getMapKeyType());
FieldType valueType = checkStateNotNull(type.getMapValueType());
ImmutableMap.Builder<Object, Object> map = ImmutableMap.builder();
for (Map.Entry<?, ?> entry : ((Map<?, ?>) value).entrySet()) {
@Nullable Object convertedKey = convert(entry.getKey(), keyType);
@Nullable Object convertedValue = convert(entry.getValue(), valueType);
if (convertedKey == null || convertedValue == null) {
return null;
}
map.put(convertedKey, convertedValue);
}
return map.build();
default:
return null;
}
}

private static String sqlType(FieldType type) {
switch (type.getTypeName()) {
case STRING:
return "VARCHAR";
case INT32:
return "INTEGER";
case ARRAY:
return "ARRAY<" + sqlType(checkStateNotNull(type.getCollectionElementType())) + ">";
case MAP:
return "MAP<"
+ sqlType(checkStateNotNull(type.getMapKeyType()))
+ ", "
+ sqlType(checkStateNotNull(type.getMapValueType()))
+ ">";
default:
return type.getTypeName().name();
}
}
}
Loading
Loading