diff --git a/api/src/main/java/org/apache/iceberg/view/SQLViewRepresentation.java b/api/src/main/java/org/apache/iceberg/view/SQLViewRepresentation.java index 94ad390edd14..4584c2fbea0e 100644 --- a/api/src/main/java/org/apache/iceberg/view/SQLViewRepresentation.java +++ b/api/src/main/java/org/apache/iceberg/view/SQLViewRepresentation.java @@ -19,13 +19,15 @@ package org.apache.iceberg.view; import java.util.List; -import org.apache.iceberg.Schema; +import javax.annotation.Nullable; import org.apache.iceberg.catalog.Namespace; +import org.immutables.value.Value; +@Value.Immutable public interface SQLViewRepresentation extends ViewRepresentation { @Override - default Type type() { + default String type() { return Type.SQL; } @@ -36,13 +38,19 @@ default Type type() { String dialect(); /** The default catalog when the view is created. */ + @Nullable String defaultCatalog(); /** The default namespace when the view is created. */ + @Nullable Namespace defaultNamespace(); - /** The query output schema at version create time, without aliases. */ - Schema schema(); + /** + * The query output schema ID at version create time, without aliases or null if no schema is + * defined + */ + @Nullable + Integer schemaId(); /** The view field comments. */ List fieldComments(); diff --git a/api/src/main/java/org/apache/iceberg/view/ViewRepresentation.java b/api/src/main/java/org/apache/iceberg/view/ViewRepresentation.java index 88e141ef56f8..bef14df322cd 100644 --- a/api/src/main/java/org/apache/iceberg/view/ViewRepresentation.java +++ b/api/src/main/java/org/apache/iceberg/view/ViewRepresentation.java @@ -18,21 +18,16 @@ */ package org.apache.iceberg.view; -import java.util.Locale; +import org.immutables.value.Value; +@Value.Immutable public interface ViewRepresentation { - enum Type { - SQL; + class Type { + private Type() {} - public static Type fromString(String typeName) { - return valueOf(typeName.toUpperCase(Locale.ENGLISH)); - } - - public String typeName() { - return name().toLowerCase(Locale.ENGLISH); - } + public static final String SQL = "sql"; } - Type type(); + String type(); } diff --git a/build.gradle b/build.gradle index abafedb48ebe..cb0f9fedd8e5 100644 --- a/build.gradle +++ b/build.gradle @@ -245,6 +245,7 @@ project(':iceberg-api') { dependencies { implementation project(path: ':iceberg-bundled-guava', configuration: 'shadow') compileOnly "com.google.errorprone:error_prone_annotations" + compileOnly 'com.google.code.findbugs:jsr305' annotationProcessor "org.immutables:value" compileOnly "org.immutables:value" testImplementation "org.apache.avro:avro" diff --git a/core/src/main/java/org/apache/iceberg/view/SQLViewRepresentationParser.java b/core/src/main/java/org/apache/iceberg/view/SQLViewRepresentationParser.java new file mode 100644 index 000000000000..57cd5c65c6d3 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/view/SQLViewRepresentationParser.java @@ -0,0 +1,119 @@ +/* + * 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.iceberg.view; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonNode; +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.util.JsonUtil; + +class SQLViewRepresentationParser { + private static final String SQL = "sql"; + private static final String DIALECT = "dialect"; + private static final String SCHEMA_ID = "schema-id"; + private static final String DEFAULT_CATALOG = "default-catalog"; + private static final String DEFAULT_NAMESPACE = "default-namespace"; + private static final String FIELD_ALIASES = "field-aliases"; + private static final String FIELD_COMMENTS = "field-comments"; + + private SQLViewRepresentationParser() {} + + static String toJson(SQLViewRepresentation sqlViewRepresentation) { + return JsonUtil.generate(gen -> toJson(sqlViewRepresentation, gen), false); + } + + static void toJson(SQLViewRepresentation view, JsonGenerator generator) throws IOException { + Preconditions.checkArgument(view != null, "Invalid SQL view representation: null"); + generator.writeStartObject(); + generator.writeStringField(ViewRepresentationParser.TYPE, view.type()); + generator.writeStringField(SQL, view.sql()); + generator.writeStringField(DIALECT, view.dialect()); + + if (view.schemaId() != null) { + generator.writeNumberField(SCHEMA_ID, view.schemaId()); + } + + if (view.defaultCatalog() != null) { + generator.writeStringField(DEFAULT_CATALOG, view.defaultCatalog()); + } + + if (view.defaultNamespace() != null) { + JsonUtil.writeStringArray( + DEFAULT_NAMESPACE, Arrays.asList(view.defaultNamespace().levels()), generator); + } + + if (!view.fieldAliases().isEmpty()) { + JsonUtil.writeStringArray( + SQLViewRepresentationParser.FIELD_ALIASES, view.fieldAliases(), generator); + } + + if (!view.fieldComments().isEmpty()) { + JsonUtil.writeStringArray( + SQLViewRepresentationParser.FIELD_COMMENTS, view.fieldComments(), generator); + } + + generator.writeEndObject(); + } + + static SQLViewRepresentation fromJson(String json) { + return JsonUtil.parse(json, SQLViewRepresentationParser::fromJson); + } + + static SQLViewRepresentation fromJson(JsonNode node) { + Preconditions.checkArgument( + node != null, "Cannot parse SQL view representation from null object"); + Preconditions.checkArgument( + node.isObject(), "Cannot parse SQL view representation from non-object: %s", node); + ImmutableSQLViewRepresentation.Builder builder = + ImmutableSQLViewRepresentation.builder() + .sql(JsonUtil.getString(SQL, node)) + .dialect(JsonUtil.getString(DIALECT, node)); + String defaultCatalog = JsonUtil.getStringOrNull(DEFAULT_CATALOG, node); + if (defaultCatalog != null) { + builder.defaultCatalog(defaultCatalog); + } + + Integer schemaId = JsonUtil.getIntOrNull(SCHEMA_ID, node); + if (schemaId != null) { + builder.schemaId(schemaId); + } + + List namespace = JsonUtil.getStringListOrNull(DEFAULT_NAMESPACE, node); + if (namespace != null && !namespace.isEmpty()) { + builder.defaultNamespace(Namespace.of(Iterables.toArray(namespace, String.class))); + } + + List fieldAliases = JsonUtil.getStringListOrNull(FIELD_ALIASES, node); + if (fieldAliases != null) { + builder.fieldAliases(fieldAliases); + } + + List fieldComments = JsonUtil.getStringListOrNull(FIELD_COMMENTS, node); + if (fieldComments != null) { + builder.fieldComments(fieldComments); + } + + return builder.build(); + } +} diff --git a/core/src/main/java/org/apache/iceberg/view/UnknownViewRepresentation.java b/core/src/main/java/org/apache/iceberg/view/UnknownViewRepresentation.java new file mode 100644 index 000000000000..ac7d4b4d300e --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/view/UnknownViewRepresentation.java @@ -0,0 +1,24 @@ +/* + * 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.iceberg.view; + +import org.immutables.value.Value; + +@Value.Immutable +public interface UnknownViewRepresentation extends ViewRepresentation {} diff --git a/core/src/main/java/org/apache/iceberg/view/ViewRepresentationParser.java b/core/src/main/java/org/apache/iceberg/view/ViewRepresentationParser.java new file mode 100644 index 000000000000..206efebc6f64 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/view/ViewRepresentationParser.java @@ -0,0 +1,68 @@ +/* + * 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.iceberg.view; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonNode; +import java.io.IOException; +import java.util.Locale; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.util.JsonUtil; + +class ViewRepresentationParser { + static final String TYPE = "type"; + + private ViewRepresentationParser() {} + + static void toJson(ViewRepresentation representation, JsonGenerator generator) + throws IOException { + Preconditions.checkArgument(representation != null, "Invalid view representation: null"); + switch (representation.type()) { + case ViewRepresentation.Type.SQL: + SQLViewRepresentationParser.toJson((SQLViewRepresentation) representation, generator); + break; + + default: + throw new IllegalArgumentException( + String.format("Cannot serialize view representation type: %s", representation.type())); + } + } + + static String toJson(ViewRepresentation entry) { + return JsonUtil.generate(gen -> toJson(entry, gen), false); + } + + static ViewRepresentation fromJson(String json) { + return JsonUtil.parse(json, ViewRepresentationParser::fromJson); + } + + static ViewRepresentation fromJson(JsonNode node) { + Preconditions.checkArgument(node != null, "Cannot parse view representation from null object"); + Preconditions.checkArgument( + node.isObject(), "Cannot parse view representation from non-object: %s", node); + String type = JsonUtil.getString(TYPE, node).toLowerCase(Locale.ENGLISH); + switch (type) { + case ViewRepresentation.Type.SQL: + return SQLViewRepresentationParser.fromJson(node); + + default: + return ImmutableUnknownViewRepresentation.builder().type(type).build(); + } + } +} diff --git a/core/src/test/java/org/apache/iceberg/view/TestSQLViewRepresentationParser.java b/core/src/test/java/org/apache/iceberg/view/TestSQLViewRepresentationParser.java new file mode 100644 index 000000000000..23b735e6911f --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/view/TestSQLViewRepresentationParser.java @@ -0,0 +1,123 @@ +/* + * 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.iceberg.view; + +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.assertj.core.api.Assertions; +import org.junit.Assert; +import org.junit.Test; + +public class TestSQLViewRepresentationParser { + @Test + public void testParseSqlViewRepresentation() { + String requiredFields = + "{\"type\":\"sql\", \"sql\": \"select * from foo\", \"dialect\": \"spark-sql\"}"; + SQLViewRepresentation viewRepresentation = + ImmutableSQLViewRepresentation.builder() + .sql("select * from foo") + .dialect("spark-sql") + .build(); + + Assert.assertEquals( + "Should be able to parse valid SQL view representation", + viewRepresentation, + SQLViewRepresentationParser.fromJson(requiredFields)); + + String requiredAndOptionalFields = + "{\"type\":\"sql\", \"sql\": \"select * from foo\", \"schema-id\": 1, \"dialect\": \"spark-sql\", " + + "\"default-catalog\":\"cat\", " + + "\"default-namespace\":[\"part1\",\"part2\"], " + + "\"field-aliases\":[\"col1\", \"col2\"], " + + "\"field-comments\":[\"Comment col1\", \"Comment col2\"]}"; + + SQLViewRepresentation viewWithOptionalFields = + ImmutableSQLViewRepresentation.builder() + .sql("select * from foo") + .schemaId(1) + .dialect("spark-sql") + .defaultCatalog("cat") + .fieldAliases(ImmutableList.of("col1", "col2")) + .fieldComments(ImmutableList.of("Comment col1", "Comment col2")) + .defaultNamespace(Namespace.of("part1", "part2")) + .build(); + Assert.assertEquals( + "Should be able to parse valid SQL view representation", + viewWithOptionalFields, + SQLViewRepresentationParser.fromJson(requiredAndOptionalFields)); + } + + @Test + public void testParseSqlViewRepresentationMissingRequiredFields() { + String missingDialect = "{\"type\":\"sql\", \"sql\": \"select * from foo\"}"; + Assertions.assertThatThrownBy(() -> ViewRepresentationParser.fromJson(missingDialect)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot parse missing string: dialect"); + + String missingType = "{\"sql\":\"select * from foo\",\"dialect\":\"spark-sql\"}"; + Assertions.assertThatThrownBy(() -> ViewRepresentationParser.fromJson(missingType)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot parse missing string: type"); + } + + @Test + public void testViewRepresentationSerialization() { + String requiredFields = + "{\"type\":\"sql\",\"sql\":\"select * from foo\",\"dialect\":\"spark-sql\"}"; + SQLViewRepresentation viewRepresentation = + ImmutableSQLViewRepresentation.builder() + .sql("select * from foo") + .dialect("spark-sql") + .build(); + Assert.assertEquals( + "Should be able to serialize valid SQL view representation", + requiredFields, + ViewRepresentationParser.toJson(viewRepresentation)); + + String requiredAndOptionalFields = + "{\"type\":\"sql\",\"sql\":\"select * from foo\",\"dialect\":\"spark-sql\",\"schema-id\":1," + + "\"default-catalog\":\"cat\"," + + "\"default-namespace\":[\"part1\",\"part2\"]," + + "\"field-aliases\":[\"col1\",\"col2\"]," + + "\"field-comments\":[\"Comment col1\",\"Comment col2\"]}"; + + SQLViewRepresentation viewWithOptionalFields = + ImmutableSQLViewRepresentation.builder() + .sql("select * from foo") + .schemaId(1) + .dialect("spark-sql") + .defaultCatalog("cat") + .fieldAliases(ImmutableList.of("col1", "col2")) + .fieldComments(ImmutableList.of("Comment col1", "Comment col2")) + .defaultNamespace(Namespace.of("part1", "part2")) + .build(); + + Assert.assertEquals( + "Should be able to serialize valid SQL view representation", + requiredAndOptionalFields, + ViewRepresentationParser.toJson(viewWithOptionalFields)); + } + + @Test + public void testNullSqlViewRepresentation() { + Assertions.assertThatThrownBy(() -> SQLViewRepresentationParser.toJson(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid SQL view representation: null"); + } +} diff --git a/core/src/test/java/org/apache/iceberg/view/TestViewRepresentationParser.java b/core/src/test/java/org/apache/iceberg/view/TestViewRepresentationParser.java new file mode 100644 index 000000000000..4f1ce769e6d1 --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/view/TestViewRepresentationParser.java @@ -0,0 +1,54 @@ +/* + * 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.iceberg.view; + +import org.assertj.core.api.Assertions; +import org.junit.Assert; +import org.junit.Test; + +public class TestViewRepresentationParser { + + @Test + public void testParseUnknownViewRepresentation() { + String json = "{\"type\":\"unknown-sql-representation\"}"; + ViewRepresentation unknownRepresentation = ViewRepresentationParser.fromJson(json); + Assert.assertEquals( + unknownRepresentation, + ImmutableUnknownViewRepresentation.builder().type("unknown-sql-representation").build()); + + Assertions.assertThatThrownBy(() -> ViewRepresentationParser.toJson(unknownRepresentation)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot serialize view representation type: unknown-sql-representation"); + } + + @Test + public void testNullViewRepresentation() { + Assertions.assertThatThrownBy(() -> ViewRepresentationParser.toJson(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid view representation: null"); + } + + @Test + public void testViewRepresentationMissingType() { + Assertions.assertThatThrownBy( + () -> ViewRepresentationParser.fromJson("{\"sql\":\"select * from foo\"}")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot parse missing string: type"); + } +} diff --git a/versions.props b/versions.props index 99dbea48a244..74d031fe6062 100644 --- a/versions.props +++ b/versions.props @@ -8,6 +8,7 @@ org.apache.orc:* = 1.8.2 org.apache.parquet:* = 1.12.3 org.apache.pig:pig = 0.14.0 com.fasterxml.jackson.*:* = 2.14.1 +com.google.code.findbugs:jsr305 = 3.0.2 com.google.errorprone:error_prone_annotations = 2.3.3 com.google.guava:* = 31.1-jre com.github.ben-manes.caffeine:caffeine = 2.9.3