This is an automated email from the ASF dual-hosted git repository.
hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new cdff02ffdf Issue #7544 : Add a native Spark SQL transform (#7859)
cdff02ffdf is described below
commit cdff02ffdf8cd4d093fb50d6c70fc9f4199b3fc6
Author: vbhanuchander-lang <[email protected]>
AuthorDate: Thu Aug 13 03:10:30 2026 -0400
Issue #7544 : Add a native Spark SQL transform (#7859)
* Issue #7544 : Add a native Spark SQL transform
Adds a Spark SQL transform for the native Spark pipeline engine. Each
incoming transform is registered as a temporary view, so one statement
can join, union, filter, window and aggregate them under a single
Catalyst plan instead of chaining Hop transforms that Catalyst cannot
optimize across.
Unlike the classic SQL transforms (Execute SQL script, Table input,
Dynamic SQL row) this compiles SQL against the pipeline's in-flight
Datasets rather than sending it to an RDBMS over JDBC, so no database
connection is involved.
- Multi-input: one temp view per enabled incoming hop, named after the
transform (sanitized to a SQL-safe identifier) or by an explicit
per-input override. Colliding view names are reported rather than
silently shadowing each other.
- Zero-input statements are allowed for catalog tables and VALUES.
- Hop variables are resolved driver-side before the statement runs.
- The declared output field list drives the projection: fields are
selected in declared order and cast to the Spark type for their Hop
type, so the Dataset schema matches the row layout getFields()
reports at design time. A non-empty list is required in v1.
lookupPreviousDataset() becomes public so the handler reuses the
converter's target-stream rules instead of duplicating them.
Includes unit tests on a local SparkSession covering joins, unions
across partitions, view naming and overrides, zero-input statements,
variable substitution, declared-schema projection and the error paths,
plus user and dev manual documentation.
* Issue #7544 : Fix the javadoc reference that broke the build
The class javadoc linked {@link #getViews()}, which Lombok generates from
the @Getter on the field, so javadoc cannot resolve it:
SparkSqlMeta.java:92: error: reference not found
maven-javadoc-plugin fails the build on that, so the Code job went red
while the Documentation job passed. Replaced the link with a {@code views}
reference, which needs no resolution.
javadoc:javadoc on plugins/engines/spark is green again, and the module's
Spark SQL tests still pass (18).
---
.../assets/images/transforms/icons/spark-sql.svg | 5 +
docs/hop-user-manual/modules/ROOT/nav.adoc | 1 +
.../spark/getting-started-with-native-spark.adoc | 5 +
.../modules/ROOT/pages/pipeline/transforms.adoc | 1 +
.../ROOT/pages/pipeline/transforms/spark-sql.adoc | 132 ++++++++
.../pipeline/HopPipelineMetaToSparkConverter.java | 10 +-
.../spark/pipeline/handler/SparkSqlHandler.java | 254 ++++++++++++++
.../apache/hop/spark/transforms/sql/SparkSql.java | 48 +++
.../hop/spark/transforms/sql/SparkSqlData.java | 27 ++
.../hop/spark/transforms/sql/SparkSqlDialog.java | 289 ++++++++++++++++
.../hop/spark/transforms/sql/SparkSqlMeta.java | 148 ++++++++
.../hop/spark/transforms/sql/SparkSqlView.java | 48 +++
.../java/org/apache/hop/spark/util/SparkConst.java | 3 +
.../sql/messages/messages_en_US.properties | 34 ++
.../engines/spark/src/main/resources/spark-sql.svg | 5 +
.../pipeline/handler/SparkSqlHandlerTest.java | 372 +++++++++++++++++++++
.../hop/spark/transforms/sql/SparkSqlMetaTest.java | 106 ++++++
17 files changed, 1485 insertions(+), 3 deletions(-)
diff --git
a/docs/hop-user-manual/modules/ROOT/assets/images/transforms/icons/spark-sql.svg
b/docs/hop-user-manual/modules/ROOT/assets/images/transforms/icons/spark-sql.svg
new file mode 100644
index 0000000000..24990f42cb
--- /dev/null
+++
b/docs/hop-user-manual/modules/ROOT/assets/images/transforms/icons/spark-sql.svg
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24">
+ <path d="M 2,4 H 22 V 20 H 2 Z M 4,6 V 9 H 20 V 6 Z M 4,11 v 3 h 7 v -3 z
M 13,11 v 3 h 7 v -3 z M 4,16 v 3 h 7 v -3 z M 13,16 v 3 h 7 v -3 z"
+ style="fill:#0e3a5a;fill-opacity:1;fill-rule:evenodd"/>
+</svg>
diff --git a/docs/hop-user-manual/modules/ROOT/nav.adoc
b/docs/hop-user-manual/modules/ROOT/nav.adoc
index 3370f79813..b02455a43e 100644
--- a/docs/hop-user-manual/modules/ROOT/nav.adoc
+++ b/docs/hop-user-manual/modules/ROOT/nav.adoc
@@ -268,6 +268,7 @@ under the License.
*** xref:pipeline/transforms/spark-lake-table-maintenance.adoc[Spark lake
table maintenance]
*** xref:pipeline/transforms/spark-lake-table-merge.adoc[Spark lake table
merge]
*** xref:pipeline/transforms/spark-lake-table-output.adoc[Spark lake table
output]
+*** xref:pipeline/transforms/spark-sql.adoc[Spark SQL]
*** xref:pipeline/transforms/splitfields.adoc[Split Fields]
*** xref:pipeline/transforms/splitfieldtorows.adoc[Split fields to rows]
*** xref:pipeline/transforms/splunkinput.adoc[Splunk input]
diff --git
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/spark/getting-started-with-native-spark.adoc
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/spark/getting-started-with-native-spark.adoc
index f0fa23c38f..4e01a73626 100644
---
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/spark/getting-started-with-native-spark.adoc
+++
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/spark/getting-started-with-native-spark.adoc
@@ -546,8 +546,13 @@ These use Spark Dataset APIs on the driver graph (no
per-row Hop mini-pipeline f
|Spark file input / output
|`spark.read` / `df.write`
+
+|xref:pipeline/transforms/spark-sql.adoc[Spark SQL]
+|`spark.sql` over the incoming Datasets, each registered as a temporary view
|===
+TIP: When a chain of Join / Filter / Calculator transforms exists only to
express analytic logic, a single xref:pipeline/transforms/spark-sql.adoc[Spark
SQL] transform lets Catalyst plan the whole thing as one query instead.
+
=== Generic mapPartitions transforms
Most other transforms — Filter Rows, Switch/Case, Stream Lookup, Calculator,
Select Values, Dummy, classic file transforms, Excel, and many plugins — run
inside `mapPartitions`:
diff --git a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms.adoc
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms.adoc
index c120097ef5..3e1a4b1064 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms.adoc
@@ -216,6 +216,7 @@ The pages nested under this topic contain information on
how to use the transfor
* xref:pipeline/transforms/spark-lake-table-maintenance.adoc[Spark lake table
maintenance]
* xref:pipeline/transforms/spark-lake-table-merge.adoc[Spark lake table merge]
* xref:pipeline/transforms/spark-lake-table-output.adoc[Spark lake table
output]
+* xref:pipeline/transforms/spark-sql.adoc[Spark SQL]
* xref:pipeline/transforms/splitfields.adoc[Split Fields]
* xref:pipeline/transforms/splitfieldtorows.adoc[Split fields to rows]
* xref:pipeline/transforms/splunkinput.adoc[Splunk input]
diff --git
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/spark-sql.adoc
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/spark-sql.adoc
new file mode 100644
index 0000000000..c3c04303de
--- /dev/null
+++ b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/spark-sql.adoc
@@ -0,0 +1,132 @@
+////
+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.
+////
+:documentationPath: /pipeline/transforms/
+:language: en_US
+:description: Spark SQL runs a SQL statement over the Datasets of the incoming
transforms on the native Spark pipeline engine, so joins, unions and
aggregations are planned as one Catalyst query.
+
+= image:transforms/icons/spark-sql.svg[Spark SQL transform Icon,
role="image-doc-icon"] Spark SQL
+
+[%noheader,cols="3a,1a", role="table-no-borders" ]
+|===
+|
+== Description
+
+*Spark SQL* runs a https://spark.apache.org/sql/[Spark SQL] statement on the
xref:pipeline/spark/getting-started-with-native-spark.adoc[native Spark
pipeline engine].
+
+Every incoming transform is registered as a temporary view, so a single
statement can join, union, filter, window and aggregate them. Spark's Catalyst
optimizer plans the whole statement at once, rather than the pipeline chaining
separate Hop Join, Filter and Calculator transforms that Catalyst cannot see
across.
+
+This is *not* the same as the classic
xref:pipeline/transforms/execsqlrow.adoc[Execute SQL script],
xref:pipeline/transforms/tableinput.adoc[Table input] or
xref:pipeline/transforms/dynamicsqlrow.adoc[Dynamic SQL row] transforms: those
send SQL to a relational database over JDBC. Spark SQL compiles the statement
against the pipeline's in-flight Datasets instead, and needs no database
connection.
+
+This transform is *not* available on the local Hop engine or on Beam engines.
+
+|
+== Supported Engines
+[%noheader,cols="2,1a",frame=none, role="table-supported-engines"]
+!===
+!Hop Engine! image:cross.svg[Not Supported, 24]
+!Single Threaded! image:cross.svg[Not Supported, 24]
+!Native Spark! image:check_mark.svg[Supported, 24]
+!Beam Spark! image:cross.svg[Not Supported, 24]
+!Beam Flink! image:cross.svg[Not Supported, 24]
+!Beam Dataflow! image:cross.svg[Not Supported, 24]
+!===
+
+NOTE: *Spark* here means Hop's *native* Spark pipeline engine only (not Beam
Spark).
+|===
+
+== Options
+
+[options="header",cols="1,3"]
+|===
+|Option |Description
+
+|Transform name
+|Unique name of the transform in the pipeline.
+
+|SQL statement
+|The statement passed to `SparkSession.sql()`. Hop variables (`${...}`) are
resolved on the driver before the statement runs — see <<Variables>>.
+
+|Input views
+|Optional per-input overrides of the temporary view name. Leave empty to use
the default derived from each incoming transform's name — see <<Input views>>.
+
+|Output fields
+|The fields the statement returns. Required — see <<Output fields>>.
+|===
+
+== Input views
+
+Each enabled incoming hop contributes one temporary view. By default the view
name is derived from the incoming transform name: every character outside
`A-Z`, `a-z`, `0-9` and `_` becomes an underscore, and a leading digit is
prefixed with an underscore.
+
+For example, a transform called `Read orders (raw)` is available as
`Read_orders__raw_`.
+
+Because that is rarely what you want to type in a statement, set an explicit
name in the *Input views* grid instead:
+
+[options="header",cols="1,1"]
+|===
+|Incoming transform |View name
+
+|Read orders (raw)
+|orders
+
+|Read customers
+|customers
+|===
+
+The statement can then read naturally:
+
+[source,sql]
+----
+SELECT c.name AS name,
+ SUM(o.amount) AS total
+FROM orders o
+JOIN customers c ON o.customer_id = c.id
+GROUP BY c.name
+----
+
+If two incoming transforms resolve to the same view name, the pipeline fails
at build time with both transform names, rather than one silently shadowing the
other.
+
+A statement with no incoming hops is allowed, so catalog tables and literals
work too:
+
+[source,sql]
+----
+SELECT * FROM VALUES (1, 'a'), (2, 'b') AS t(id, label)
+----
+
+== Output fields
+
+The output field list is required and must not be empty. Hop resolves field
names and types at design time, before any Spark session exists, so the
statement's result schema is not available to it. Declaring the fields keeps
design-time metadata correct and gives downstream transforms a row layout that
matches the Dataset exactly.
+
+The declared list also drives the output: fields are selected in the order you
declare them and cast to the Spark type for the Hop type you choose. Declaring
a field the statement does not return fails the pipeline with the list of
columns it did return.
+
+TIP: Alias the expressions in the statement (`SUM(o.amount) AS total`) so the
returned column names match the fields you declare.
+
+== Variables
+
+Variables are substituted into the SQL text on the driver before the statement
is parsed — the result is plain string substitution, not a bound parameter:
+
+[source,sql]
+----
+SELECT COUNT(*) AS row_count FROM orders WHERE amount >= ${MIN_AMOUNT}
+----
+
+WARNING: Because this is string substitution, a variable whose value comes
from outside the project can change the meaning of the statement. Only use
variables you control for SQL text.
+
+== Notes and limitations
+
+* Structured Streaming SQL is not supported: the native Spark engine is
batch-only.
+* Views exist only for the statement that reads them. They are not visible to
other transforms or to later pipelines.
+* `CREATE`/`DROP` statements against a configured catalog run, but this
transform is designed to return a result set; use
xref:pipeline/transforms/spark-lake-table-maintenance.adoc[Spark lake table
maintenance] for table maintenance operations.
diff --git
a/plugins/engines/spark/src/main/java/org/apache/hop/spark/pipeline/HopPipelineMetaToSparkConverter.java
b/plugins/engines/spark/src/main/java/org/apache/hop/spark/pipeline/HopPipelineMetaToSparkConverter.java
index 8d16d7e304..76298a394c 100644
---
a/plugins/engines/spark/src/main/java/org/apache/hop/spark/pipeline/HopPipelineMetaToSparkConverter.java
+++
b/plugins/engines/spark/src/main/java/org/apache/hop/spark/pipeline/HopPipelineMetaToSparkConverter.java
@@ -52,6 +52,7 @@ import
org.apache.hop.spark.pipeline.handler.SparkLakeTableOutputHandler;
import org.apache.hop.spark.pipeline.handler.SparkMemoryGroupByHandler;
import org.apache.hop.spark.pipeline.handler.SparkMergeJoinHandler;
import org.apache.hop.spark.pipeline.handler.SparkSortRowsHandler;
+import org.apache.hop.spark.pipeline.handler.SparkSqlHandler;
import org.apache.hop.spark.pipeline.handler.SparkUniqueRowsHandler;
import org.apache.hop.spark.util.SparkConst;
import org.apache.spark.sql.Dataset;
@@ -80,7 +81,8 @@ public class HopPipelineMetaToSparkConverter {
SparkConst.SPARK_LAKE_TABLE_INPUT_PLUGIN_ID,
SparkConst.SPARK_LAKE_TABLE_OUTPUT_PLUGIN_ID,
SparkConst.SPARK_LAKE_TABLE_MERGE_PLUGIN_ID,
- SparkConst.SPARK_LAKE_TABLE_MAINTENANCE_PLUGIN_ID);
+ SparkConst.SPARK_LAKE_TABLE_MAINTENANCE_PLUGIN_ID,
+ SparkConst.SPARK_SQL_PLUGIN_ID);
/**
* Plugin ids that must not run as partition-local Hop mini-pipelines. Keep
in lockstep with
@@ -170,6 +172,7 @@ public class HopPipelineMetaToSparkConverter {
SparkConst.SPARK_LAKE_TABLE_MERGE_PLUGIN_ID, new
SparkLakeTableMergeHandler());
transformHandlers.put(
SparkConst.SPARK_LAKE_TABLE_MAINTENANCE_PLUGIN_ID, new
SparkLakeTableMaintenanceHandler());
+ transformHandlers.put(SparkConst.SPARK_SQL_PLUGIN_ID, new
SparkSqlHandler());
}
public void validatePipeline() throws HopException {
@@ -363,9 +366,10 @@ public class HopPipelineMetaToSparkConverter {
/**
* Prefer a target-stream Dataset when {@code previous} routed to {@code
current} (Filter/Switch);
- * otherwise use the previous transform's main Dataset.
+ * otherwise use the previous transform's main Dataset. Public so native
handlers that resolve
+ * their own named inputs (Spark SQL) reuse the same target-stream rules as
the converter.
*/
- static Dataset<Row> lookupPreviousDataset(
+ public static Dataset<Row> lookupPreviousDataset(
Map<String, Dataset<Row>> transformDatasetMap,
TransformMeta previous,
TransformMeta current,
diff --git
a/plugins/engines/spark/src/main/java/org/apache/hop/spark/pipeline/handler/SparkSqlHandler.java
b/plugins/engines/spark/src/main/java/org/apache/hop/spark/pipeline/handler/SparkSqlHandler.java
new file mode 100644
index 0000000000..09734456a1
--- /dev/null
+++
b/plugins/engines/spark/src/main/java/org/apache/hop/spark/pipeline/handler/SparkSqlHandler.java
@@ -0,0 +1,254 @@
+/*
+ * 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.hop.spark.pipeline.handler;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.IValueMeta;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.spark.core.HopSparkRowConverter;
+import org.apache.hop.spark.core.SparkNativeMetrics;
+import org.apache.hop.spark.engines.ISparkPipelineEngineRunConfiguration;
+import org.apache.hop.spark.pipeline.HopPipelineMetaToSparkConverter;
+import org.apache.hop.spark.transforms.io.SparkField;
+import org.apache.hop.spark.transforms.sql.SparkSqlMeta;
+import org.apache.spark.sql.Column;
+import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.Row;
+import org.apache.spark.sql.SparkSession;
+import org.apache.spark.sql.types.DataType;
+
+/**
+ * Native Spark SQL execution. Each incoming transform's Dataset is registered
as a temporary view
+ * so a single statement can join, union and aggregate them under one Catalyst
plan.
+ *
+ * <p>The statement result is projected onto the transform's declared field
list so the Dataset
+ * schema matches the Hop row layout that {@link SparkSqlMeta#getFields}
reports at design time.
+ */
+public class SparkSqlHandler extends SparkBaseTransformHandler {
+
+ @Override
+ public void handleTransform(
+ ILogChannel log,
+ IVariables variables,
+ String runConfigurationName,
+ ISparkPipelineEngineRunConfiguration runConfiguration,
+ IHopMetadataProvider metadataProvider,
+ String metastoreJson,
+ PipelineMeta pipelineMeta,
+ TransformMeta transformMeta,
+ Map<String, Dataset<Row>> transformDatasetMap,
+ SparkSession spark,
+ IRowMeta rowMeta,
+ List<TransformMeta> previousTransforms,
+ Dataset<Row> input)
+ throws HopException {
+
+ SparkSqlMeta meta = new SparkSqlMeta();
+ loadTransformMetadata(meta, transformMeta, metadataProvider, pipelineMeta);
+
+ String transformName = transformMeta.getName();
+ String sql = variables.resolve(meta.getSql());
+ if (StringUtils.isBlank(sql)) {
+ throw new HopException("Spark SQL '" + transformName + "': the SQL
statement is empty");
+ }
+
+ List<SparkField> fields = meta.getFields();
+ if (fields == null || fields.isEmpty()) {
+ throw new HopException(
+ "Spark SQL '"
+ + transformName
+ + "': an explicit output field list is required so Hop can
resolve the row layout"
+ + " at design time. Add the fields the statement returns.");
+ }
+
+ Map<String, String> registeredViews =
+ registerInputViews(
+ log, variables, meta, transformMeta, previousTransforms,
transformDatasetMap);
+
+ Dataset<Row> result;
+ try {
+ result = spark.sql(sql);
+ } catch (Exception e) {
+ throw new HopException(
+ "Spark SQL '"
+ + transformName
+ + "': the statement could not be executed. Registered views: "
+ + registeredViews.values()
+ + ". SQL: "
+ + sql,
+ e);
+ }
+
+ Dataset<Row> output = projectDeclaredFields(result, fields, transformName);
+ output = trackMetrics(output, transformMeta,
SparkNativeMetrics.Role.TRANSFORM);
+ transformDatasetMap.put(transformName, output);
+
+ log.logBasic(
+ "Handled Spark SQL : "
+ + transformName
+ + " views="
+ + registeredViews
+ + " columns="
+ + Arrays.toString(output.columns()));
+ }
+
+ /**
+ * Registers one temporary view per incoming transform and returns the
transform-name to view-name
+ * mapping. An empty map means a zero-input statement (catalog tables,
{@code VALUES}, …).
+ */
+ private Map<String, String> registerInputViews(
+ ILogChannel log,
+ IVariables variables,
+ SparkSqlMeta meta,
+ TransformMeta transformMeta,
+ List<TransformMeta> previousTransforms,
+ Map<String, Dataset<Row>> transformDatasetMap)
+ throws HopException {
+
+ Map<String, String> registered = new LinkedHashMap<>();
+ if (previousTransforms == null || previousTransforms.isEmpty()) {
+ return registered;
+ }
+
+ // view name -> source transform, to report collisions rather than
silently shadowing
+ Map<String, String> viewOwners = new LinkedHashMap<>();
+
+ for (TransformMeta previous : previousTransforms) {
+ String previousName = previous.getName();
+ Dataset<Row> dataset =
+ HopPipelineMetaToSparkConverter.lookupPreviousDataset(
+ transformDatasetMap, previous, transformMeta, log);
+ if (dataset == null) {
+ throw new HopException(
+ "Spark SQL '"
+ + transformMeta.getName()
+ + "': the Dataset for incoming transform '"
+ + previousName
+ + "' could not be found. Check that the hop into this
transform is enabled.");
+ }
+
+ String override = meta.findViewNameOverride(previousName);
+ String viewName =
+ StringUtils.isNotEmpty(override)
+ ? variables.resolve(override)
+ : SparkSqlMeta.defaultViewName(previousName);
+ if (StringUtils.isBlank(viewName)) {
+ throw new HopException(
+ "Spark SQL '"
+ + transformMeta.getName()
+ + "': could not determine a view name for incoming transform '"
+ + previousName
+ + "'");
+ }
+
+ String owner = viewOwners.put(viewName, previousName);
+ if (owner != null) {
+ throw new HopException(
+ "Spark SQL '"
+ + transformMeta.getName()
+ + "': incoming transforms '"
+ + owner
+ + "' and '"
+ + previousName
+ + "' both map to view '"
+ + viewName
+ + "'. Set distinct view names in the Input views tab.");
+ }
+
+ dataset.createOrReplaceTempView(viewName);
+ registered.put(previousName, viewName);
+ }
+ return registered;
+ }
+
+ /**
+ * Selects the declared fields, in declared order, casting each to the Spark
type for its Hop
+ * type. Guarantees the Dataset schema matches the row layout reported at
design time.
+ */
+ private Dataset<Row> projectDeclaredFields(
+ Dataset<Row> result, List<SparkField> fields, String transformName)
throws HopException {
+
+ String[] resultColumns = result.columns();
+ List<Column> projection = new ArrayList<>(fields.size());
+
+ for (SparkField field : fields) {
+ String name = field.getName();
+ if (StringUtils.isBlank(name)) {
+ throw new HopException(
+ "Spark SQL '" + transformName + "': a declared output field has no
name");
+ }
+
+ String actual = findColumn(resultColumns, name);
+ if (actual == null) {
+ throw new HopException(
+ "Spark SQL '"
+ + transformName
+ + "': declared output field '"
+ + name
+ + "' is not returned by the statement. Columns returned: "
+ + Arrays.toString(resultColumns));
+ }
+
+ IValueMeta valueMeta;
+ try {
+ valueMeta = field.createValueMeta();
+ } catch (Exception e) {
+ throw new HopException(
+ "Spark SQL '"
+ + transformName
+ + "': unable to build the value metadata for output field '"
+ + name
+ + "'",
+ e);
+ }
+ DataType dataType = HopSparkRowConverter.toDataType(valueMeta);
+ projection.add(result.col(actual).cast(dataType).alias(name));
+ }
+
+ return result.select(projection.toArray(new Column[0]));
+ }
+
+ /**
+ * Resolves a declared field against the statement's columns, preferring an
exact match and
+ * falling back to a case-insensitive one (Spark resolves case-insensitively
by default).
+ */
+ private static String findColumn(String[] resultColumns, String name) {
+ for (String column : resultColumns) {
+ if (column.equals(name)) {
+ return column;
+ }
+ }
+ for (String column : resultColumns) {
+ if (column.equalsIgnoreCase(name)) {
+ return column;
+ }
+ }
+ return null;
+ }
+}
diff --git
a/plugins/engines/spark/src/main/java/org/apache/hop/spark/transforms/sql/SparkSql.java
b/plugins/engines/spark/src/main/java/org/apache/hop/spark/transforms/sql/SparkSql.java
new file mode 100644
index 0000000000..95f92b4f01
--- /dev/null
+++
b/plugins/engines/spark/src/main/java/org/apache/hop/spark/transforms/sql/SparkSql.java
@@ -0,0 +1,48 @@
+/*
+ * 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.hop.spark.transforms.sql;
+
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.pipeline.Pipeline;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.BaseTransform;
+import org.apache.hop.pipeline.transform.TransformMeta;
+
+/**
+ * Metadata-only on the Local engine. On the native Spark engine {@link
+ * org.apache.hop.spark.pipeline.handler.SparkSqlHandler} compiles this into a
{@code
+ * SparkSession.sql()} call over the incoming Datasets.
+ */
+public class SparkSql extends BaseTransform<SparkSqlMeta, SparkSqlData> {
+
+ public SparkSql(
+ TransformMeta transformMeta,
+ SparkSqlMeta meta,
+ SparkSqlData data,
+ int copyNr,
+ PipelineMeta pipelineMeta,
+ Pipeline pipeline) {
+ super(transformMeta, meta, data, copyNr, pipelineMeta, pipeline);
+ }
+
+ @Override
+ public boolean processRow() throws HopException {
+ setOutputDone();
+ return false;
+ }
+}
diff --git
a/plugins/engines/spark/src/main/java/org/apache/hop/spark/transforms/sql/SparkSqlData.java
b/plugins/engines/spark/src/main/java/org/apache/hop/spark/transforms/sql/SparkSqlData.java
new file mode 100644
index 0000000000..3e520d45a4
--- /dev/null
+++
b/plugins/engines/spark/src/main/java/org/apache/hop/spark/transforms/sql/SparkSqlData.java
@@ -0,0 +1,27 @@
+/*
+ * 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.hop.spark.transforms.sql;
+
+import org.apache.hop.pipeline.transform.BaseTransformData;
+import org.apache.hop.pipeline.transform.ITransformData;
+
+public class SparkSqlData extends BaseTransformData implements ITransformData {
+ public SparkSqlData() {
+ super();
+ }
+}
diff --git
a/plugins/engines/spark/src/main/java/org/apache/hop/spark/transforms/sql/SparkSqlDialog.java
b/plugins/engines/spark/src/main/java/org/apache/hop/spark/transforms/sql/SparkSqlDialog.java
new file mode 100644
index 0000000000..2d69734e71
--- /dev/null
+++
b/plugins/engines/spark/src/main/java/org/apache/hop/spark/transforms/sql/SparkSqlDialog.java
@@ -0,0 +1,289 @@
+/*
+ * 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.hop.spark.transforms.sql;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.row.value.ValueMetaFactory;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.spark.transforms.io.SparkField;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.dialog.BaseDialog;
+import org.apache.hop.ui.core.widget.ColumnInfo;
+import org.apache.hop.ui.core.widget.TableView;
+import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.layout.FormAttachment;
+import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swt.widgets.TableItem;
+import org.eclipse.swt.widgets.Text;
+
+public class SparkSqlDialog extends BaseTransformDialog {
+ private static final Class<?> PKG = SparkSqlMeta.class;
+
+ private final SparkSqlMeta input;
+
+ private Text wSql;
+ private TableView wViews;
+ private TableView wFields;
+
+ public SparkSqlDialog(
+ Shell parent, IVariables variables, SparkSqlMeta transformMeta,
PipelineMeta pipelineMeta) {
+ super(parent, variables, transformMeta, pipelineMeta);
+ this.input = transformMeta;
+ }
+
+ @Override
+ public String open() {
+ Shell parent = getParent();
+ shell = new Shell(parent, SWT.DIALOG_TRIM | SWT.RESIZE | SWT.MIN |
SWT.MAX);
+ PropsUi.setLook(shell);
+ setShellImage(shell, input);
+
+ changed = input.hasChanged();
+
+ FormLayout formLayout = new FormLayout();
+ formLayout.marginWidth = PropsUi.getFormMargin();
+ formLayout.marginHeight = PropsUi.getFormMargin();
+ shell.setLayout(formLayout);
+ shell.setText(BaseMessages.getString(PKG, "SparkSqlDialog.Shell.Title"));
+
+ int middle = props.getMiddlePct();
+ int margin = PropsUi.getMargin();
+
+ wlTransformName = new Label(shell, SWT.RIGHT);
+ wlTransformName.setText(BaseMessages.getString(PKG,
"System.Label.TransformName"));
+ PropsUi.setLook(wlTransformName);
+ fdlTransformName = new FormData();
+ fdlTransformName.left = new FormAttachment(0, 0);
+ fdlTransformName.top = new FormAttachment(0, margin);
+ fdlTransformName.right = new FormAttachment(middle, -margin);
+ wlTransformName.setLayoutData(fdlTransformName);
+ wTransformName = new Text(shell, SWT.SINGLE | SWT.LEFT | SWT.BORDER);
+ wTransformName.setText(transformName);
+ PropsUi.setLook(wTransformName);
+ wTransformName.addModifyListener(e -> input.setChanged());
+ fdTransformName = new FormData();
+ fdTransformName.left = new FormAttachment(middle, 0);
+ fdTransformName.top = new FormAttachment(wlTransformName, 0, SWT.CENTER);
+ fdTransformName.right = new FormAttachment(100, 0);
+ wTransformName.setLayoutData(fdTransformName);
+ Control last = wTransformName;
+
+ Label wlSql = new Label(shell, SWT.LEFT);
+ wlSql.setText(BaseMessages.getString(PKG, "SparkSqlDialog.Sql"));
+ PropsUi.setLook(wlSql);
+ FormData fdlSql = new FormData();
+ fdlSql.left = new FormAttachment(0, 0);
+ fdlSql.top = new FormAttachment(last, margin);
+ fdlSql.right = new FormAttachment(100, 0);
+ wlSql.setLayoutData(fdlSql);
+
+ wSql = new Text(shell, SWT.MULTI | SWT.LEFT | SWT.BORDER | SWT.V_SCROLL |
SWT.H_SCROLL);
+ PropsUi.setLook(wSql);
+ wSql.addModifyListener(e -> input.setChanged());
+ wSql.setToolTipText(BaseMessages.getString(PKG,
"SparkSqlDialog.Sql.Tooltip"));
+ FormData fdSql = new FormData();
+ fdSql.left = new FormAttachment(0, 0);
+ fdSql.top = new FormAttachment(wlSql, margin);
+ fdSql.right = new FormAttachment(100, 0);
+ fdSql.height = (int) (150 * props.getZoomFactor());
+ wSql.setLayoutData(fdSql);
+ last = wSql;
+
+ Label wlViews = new Label(shell, SWT.LEFT);
+ wlViews.setText(BaseMessages.getString(PKG, "SparkSqlDialog.Views"));
+ PropsUi.setLook(wlViews);
+ FormData fdlViews = new FormData();
+ fdlViews.left = new FormAttachment(0, 0);
+ fdlViews.top = new FormAttachment(last, margin);
+ fdlViews.right = new FormAttachment(100, 0);
+ wlViews.setLayoutData(fdlViews);
+
+ String[] previousTransforms =
pipelineMeta.getPrevTransformNames(transformName);
+ ColumnInfo[] viewColumns =
+ new ColumnInfo[] {
+ new ColumnInfo(
+ BaseMessages.getString(PKG, "SparkSqlDialog.Column.Transform"),
+ ColumnInfo.COLUMN_TYPE_CCOMBO,
+ previousTransforms == null ? new String[0] : previousTransforms,
+ false),
+ new ColumnInfo(
+ BaseMessages.getString(PKG, "SparkSqlDialog.Column.View"),
+ ColumnInfo.COLUMN_TYPE_TEXT,
+ false)
+ };
+ wViews =
+ new TableView(
+ variables,
+ shell,
+ SWT.BORDER | SWT.FULL_SELECTION | SWT.MULTI,
+ viewColumns,
+ input.getViews() == null ? 1 : Math.max(1,
input.getViews().size()),
+ e -> input.setChanged(),
+ props);
+ FormData fdViews = new FormData();
+ fdViews.left = new FormAttachment(0, 0);
+ fdViews.top = new FormAttachment(wlViews, margin);
+ fdViews.right = new FormAttachment(100, 0);
+ fdViews.height = (int) (100 * props.getZoomFactor());
+ wViews.setLayoutData(fdViews);
+ last = wViews;
+
+ Label wlFields = new Label(shell, SWT.LEFT);
+ wlFields.setText(BaseMessages.getString(PKG, "SparkSqlDialog.Fields"));
+ PropsUi.setLook(wlFields);
+ FormData fdlFields = new FormData();
+ fdlFields.left = new FormAttachment(0, 0);
+ fdlFields.top = new FormAttachment(last, margin);
+ fdlFields.right = new FormAttachment(100, 0);
+ wlFields.setLayoutData(fdlFields);
+
+ ColumnInfo[] fieldColumns =
+ new ColumnInfo[] {
+ new ColumnInfo(
+ BaseMessages.getString(PKG, "SparkSqlDialog.Column.Name"),
+ ColumnInfo.COLUMN_TYPE_TEXT,
+ false),
+ new ColumnInfo(
+ BaseMessages.getString(PKG, "SparkSqlDialog.Column.Type"),
+ ColumnInfo.COLUMN_TYPE_CCOMBO,
+ ValueMetaFactory.getValueMetaNames()),
+ new ColumnInfo(
+ BaseMessages.getString(PKG, "SparkSqlDialog.Column.Length"),
+ ColumnInfo.COLUMN_TYPE_TEXT,
+ false),
+ new ColumnInfo(
+ BaseMessages.getString(PKG, "SparkSqlDialog.Column.Precision"),
+ ColumnInfo.COLUMN_TYPE_TEXT,
+ false)
+ };
+ wFields =
+ new TableView(
+ variables,
+ shell,
+ SWT.BORDER | SWT.FULL_SELECTION | SWT.MULTI,
+ fieldColumns,
+ input.getFields() == null ? 1 : Math.max(1,
input.getFields().size()),
+ e -> input.setChanged(),
+ props);
+ FormData fdFields = new FormData();
+ fdFields.left = new FormAttachment(0, 0);
+ fdFields.top = new FormAttachment(wlFields, margin);
+ fdFields.right = new FormAttachment(100, 0);
+ fdFields.bottom = new FormAttachment(100, -50);
+ wFields.setLayoutData(fdFields);
+
+ wOk = new Button(shell, SWT.PUSH);
+ wOk.setText(BaseMessages.getString(PKG, "System.Button.OK"));
+ wCancel = new Button(shell, SWT.PUSH);
+ wCancel.setText(BaseMessages.getString(PKG, "System.Button.Cancel"));
+ setButtonPositions(new Button[] {wOk, wCancel}, margin, null);
+
+ wOk.addListener(SWT.Selection, e -> ok());
+ wCancel.addListener(SWT.Selection, e -> cancel());
+
+ getData();
+ input.setChanged(changed);
+ BaseDialog.defaultShellHandling(shell, c -> ok(), c -> cancel());
+ return transformName;
+ }
+
+ private void getData() {
+ wTransformName.setText(Const.NVL(transformName, ""));
+ wSql.setText(Const.NVL(input.getSql(), ""));
+
+ if (input.getViews() != null) {
+ int i = 0;
+ for (SparkSqlView view : input.getViews()) {
+ TableItem item = wViews.table.getItem(i);
+ if (item == null) {
+ item = new TableItem(wViews.table, SWT.NONE);
+ }
+ item.setText(1, Const.NVL(view.getTransformName(), ""));
+ item.setText(2, Const.NVL(view.getViewName(), ""));
+ i++;
+ }
+ wViews.setRowNums();
+ wViews.optWidth(true);
+ }
+
+ if (input.getFields() != null) {
+ int i = 0;
+ for (SparkField f : input.getFields()) {
+ TableItem item = wFields.table.getItem(i);
+ if (item == null) {
+ item = new TableItem(wFields.table, SWT.NONE);
+ }
+ item.setText(1, Const.NVL(f.getName(), ""));
+ item.setText(2, Const.NVL(f.getHopType(), "String"));
+ item.setText(3, f.getLength() >= 0 ? Integer.toString(f.getLength()) :
"");
+ item.setText(4, f.getPrecision() >= 0 ?
Integer.toString(f.getPrecision()) : "");
+ i++;
+ }
+ wFields.setRowNums();
+ wFields.optWidth(true);
+ }
+
+ wTransformName.selectAll();
+ wTransformName.setFocus();
+ }
+
+ private void cancel() {
+ transformName = null;
+ input.setChanged(changed);
+ dispose();
+ }
+
+ private void ok() {
+ if (Utils.isEmpty(wTransformName.getText())) {
+ return;
+ }
+ transformName = wTransformName.getText();
+ input.setSql(wSql.getText());
+
+ List<SparkSqlView> views = new ArrayList<>();
+ for (int i = 0; i < wViews.nrNonEmpty(); i++) {
+ TableItem item = wViews.getNonEmpty(i);
+ views.add(new SparkSqlView(item.getText(1), item.getText(2)));
+ }
+ input.setViews(views);
+
+ List<SparkField> fields = new ArrayList<>();
+ for (int i = 0; i < wFields.nrNonEmpty(); i++) {
+ TableItem item = wFields.getNonEmpty(i);
+ SparkField f = new SparkField();
+ f.setName(item.getText(1));
+ f.setHopType(item.getText(2));
+ f.setLength(Const.toInt(item.getText(3), -1));
+ f.setPrecision(Const.toInt(item.getText(4), -1));
+ fields.add(f);
+ }
+ input.setFields(fields);
+ dispose();
+ }
+}
diff --git
a/plugins/engines/spark/src/main/java/org/apache/hop/spark/transforms/sql/SparkSqlMeta.java
b/plugins/engines/spark/src/main/java/org/apache/hop/spark/transforms/sql/SparkSqlMeta.java
new file mode 100644
index 0000000000..d204c25a70
--- /dev/null
+++
b/plugins/engines/spark/src/main/java/org/apache/hop/spark/transforms/sql/SparkSqlMeta.java
@@ -0,0 +1,148 @@
+/*
+ * 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.hop.spark.transforms.sql;
+
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.hop.core.annotations.Transform;
+import org.apache.hop.core.exception.HopPluginException;
+import org.apache.hop.core.exception.HopTransformException;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.metadata.api.HopMetadataProperty;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.pipeline.transform.BaseTransformMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.spark.transforms.io.SparkField;
+import org.apache.hop.spark.util.SparkConst;
+
+/**
+ * Runs a Spark SQL statement against the Datasets of the incoming transforms,
each registered as a
+ * temporary view. Native Spark engine only.
+ *
+ * <p>The output row layout is not inferred from the statement: v1 requires an
explicit, non-empty
+ * field list so that Hop can resolve fields at design time and so downstream
generic mapPartitions
+ * transforms see a row layout that matches the Dataset exactly.
+ */
+@Transform(
+ id = SparkConst.SPARK_SQL_PLUGIN_ID,
+ name = "i18n::SparkSql.Name",
+ description = "i18n::SparkSql.Description",
+ image = "spark-sql.svg",
+ categoryDescription =
"i18n:org.apache.hop.pipeline.transform:BaseTransform.Category.BigData",
+ keywords = "i18n::SparkSql.Keyword",
+ documentationUrl = "/pipeline/transforms/spark-sql.html",
+ supportedEngines = {SparkConst.PLUGIN_ID})
+@Getter
+@Setter
+public class SparkSqlMeta extends BaseTransformMeta<SparkSql, SparkSqlData> {
+
+ /** SQL executed through {@code SparkSession.sql()}. Variables are resolved
driver-side. */
+ @HopMetadataProperty(key = "sql", injectionKey = "SQL")
+ private String sql;
+
+ /** Optional per-input temporary view name overrides. */
+ @HopMetadataProperty(
+ groupKey = "views",
+ key = "view",
+ injectionGroupKey = "VIEWS",
+ injectionGroupDescription = "SparkSql.Injection.Group.Views")
+ private List<SparkSqlView> views = new ArrayList<>();
+
+ /** Output fields. Required and non-empty in v1 — see the class javadoc. */
+ @HopMetadataProperty(
+ groupKey = "fields",
+ key = "field",
+ injectionGroupKey = "FIELDS",
+ injectionGroupDescription = "SparkSql.Injection.Group.Fields")
+ private List<SparkField> fields = new ArrayList<>();
+
+ public SparkSqlMeta() {
+ super();
+ }
+
+ @Override
+ public String getDialogClassName() {
+ return SparkSqlDialog.class.getName();
+ }
+
+ /**
+ * Derives a SQL-safe temporary view name from a transform name: characters
outside {@code
+ * [A-Za-z0-9_]} become underscores, and a leading digit is prefixed with an
underscore. Transform
+ * names such as {@code "Read orders (raw)"} therefore become {@code
"Read_orders__raw_"}.
+ *
+ * <p>Callers that need a stable, readable name should set an explicit
override in the {@code
+ * views} list instead.
+ */
+ public static String defaultViewName(String transformName) {
+ if (transformName == null || transformName.isEmpty()) {
+ return null;
+ }
+ StringBuilder sb = new StringBuilder(transformName.length());
+ for (int i = 0; i < transformName.length(); i++) {
+ char c = transformName.charAt(i);
+ sb.append((Character.isLetterOrDigit(c) && c < 128) || c == '_' ? c :
'_');
+ }
+ if (Character.isDigit(sb.charAt(0))) {
+ sb.insert(0, '_');
+ }
+ return sb.toString();
+ }
+
+ /** Returns the configured view-name override for {@code transformName}, or
null when unset. */
+ public String findViewNameOverride(String transformName) {
+ if (views == null || transformName == null) {
+ return null;
+ }
+ for (SparkSqlView view : views) {
+ if (transformName.equals(view.getTransformName())
+ && view.getViewName() != null
+ && !view.getViewName().isEmpty()) {
+ return view.getViewName();
+ }
+ }
+ return null;
+ }
+
+ @Override
+ public void getFields(
+ IRowMeta inputRowMeta,
+ String name,
+ IRowMeta[] info,
+ TransformMeta nextTransform,
+ IVariables variables,
+ IHopMetadataProvider metadataProvider)
+ throws HopTransformException {
+ // The statement fully determines the output row: incoming fields do not
survive it.
+ inputRowMeta.clear();
+ if (fields == null || fields.isEmpty()) {
+ return;
+ }
+ try {
+ for (SparkField field : fields) {
+ if (field.getName() != null && !field.getName().isEmpty()) {
+ inputRowMeta.addValueMeta(field.createValueMeta());
+ }
+ }
+ } catch (HopPluginException e) {
+ throw new HopTransformException("Unable to create row meta for Spark
SQL", e);
+ }
+ }
+}
diff --git
a/plugins/engines/spark/src/main/java/org/apache/hop/spark/transforms/sql/SparkSqlView.java
b/plugins/engines/spark/src/main/java/org/apache/hop/spark/transforms/sql/SparkSqlView.java
new file mode 100644
index 0000000000..e16f33d793
--- /dev/null
+++
b/plugins/engines/spark/src/main/java/org/apache/hop/spark/transforms/sql/SparkSqlView.java
@@ -0,0 +1,48 @@
+/*
+ * 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.hop.spark.transforms.sql;
+
+import java.io.Serializable;
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.hop.metadata.api.HopMetadataProperty;
+
+/**
+ * Optional override of the temporary view name used for one incoming
transform. Without an override
+ * the view name is derived from the transform name by {@link
SparkSqlMeta#defaultViewName(String)}.
+ */
+@Getter
+@Setter
+public class SparkSqlView implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ /** Name of the incoming (previous) transform whose Dataset is registered. */
+ @HopMetadataProperty(key = "transform", injectionKey = "TRANSFORM")
+ private String transformName;
+
+ /** Temporary view name to register the Dataset under. Supports variables. */
+ @HopMetadataProperty(key = "view", injectionKey = "VIEW")
+ private String viewName;
+
+ public SparkSqlView() {}
+
+ public SparkSqlView(String transformName, String viewName) {
+ this.transformName = transformName;
+ this.viewName = viewName;
+ }
+}
diff --git
a/plugins/engines/spark/src/main/java/org/apache/hop/spark/util/SparkConst.java
b/plugins/engines/spark/src/main/java/org/apache/hop/spark/util/SparkConst.java
index 92d480ac18..1a55480d62 100644
---
a/plugins/engines/spark/src/main/java/org/apache/hop/spark/util/SparkConst.java
+++
b/plugins/engines/spark/src/main/java/org/apache/hop/spark/util/SparkConst.java
@@ -50,5 +50,8 @@ public final class SparkConst {
public static final String SPARK_LAKE_TABLE_MAINTENANCE_PLUGIN_ID =
"SparkLakeTableMaintenance";
+ /** Spark SQL over the Datasets of the incoming transforms — native Spark
only. */
+ public static final String SPARK_SQL_PLUGIN_ID = "SparkSql";
+
private SparkConst() {}
}
diff --git
a/plugins/engines/spark/src/main/resources/org/apache/hop/spark/transforms/sql/messages/messages_en_US.properties
b/plugins/engines/spark/src/main/resources/org/apache/hop/spark/transforms/sql/messages/messages_en_US.properties
new file mode 100644
index 0000000000..30706123f7
--- /dev/null
+++
b/plugins/engines/spark/src/main/resources/org/apache/hop/spark/transforms/sql/messages/messages_en_US.properties
@@ -0,0 +1,34 @@
+#
+# 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.
+#
+
+SparkSql.Name=Spark SQL
+SparkSql.Description=Run a Spark SQL statement over the Datasets of the
incoming transforms with the native Spark engine
+SparkSql.Keyword=spark,sql,query,join,union,aggregate,catalyst
+SparkSql.Injection.Group.Views=Input views (optional view name overrides)
+SparkSql.Injection.Group.Fields=Output fields
+
+SparkSqlDialog.Shell.Title=Spark SQL
+SparkSqlDialog.Sql=SQL statement
+SparkSqlDialog.Sql.Tooltip=Executed with SparkSession.sql(). Each incoming
transform is registered as a temporary view. Hop variables (${...}) are
resolved on the driver before the statement runs.
+SparkSqlDialog.Views=Input views (optional — defaults to the incoming
transform name)
+SparkSqlDialog.Fields=Output fields (required)
+SparkSqlDialog.Column.Transform=Incoming transform
+SparkSqlDialog.Column.View=View name
+SparkSqlDialog.Column.Name=Name
+SparkSqlDialog.Column.Type=Type
+SparkSqlDialog.Column.Length=Length
+SparkSqlDialog.Column.Precision=Precision
diff --git a/plugins/engines/spark/src/main/resources/spark-sql.svg
b/plugins/engines/spark/src/main/resources/spark-sql.svg
new file mode 100644
index 0000000000..24990f42cb
--- /dev/null
+++ b/plugins/engines/spark/src/main/resources/spark-sql.svg
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24">
+ <path d="M 2,4 H 22 V 20 H 2 Z M 4,6 V 9 H 20 V 6 Z M 4,11 v 3 h 7 v -3 z
M 13,11 v 3 h 7 v -3 z M 4,16 v 3 h 7 v -3 z M 13,16 v 3 h 7 v -3 z"
+ style="fill:#0e3a5a;fill-opacity:1;fill-rule:evenodd"/>
+</svg>
diff --git
a/plugins/engines/spark/src/test/java/org/apache/hop/spark/pipeline/handler/SparkSqlHandlerTest.java
b/plugins/engines/spark/src/test/java/org/apache/hop/spark/pipeline/handler/SparkSqlHandlerTest.java
new file mode 100644
index 0000000000..6030433c8e
--- /dev/null
+++
b/plugins/engines/spark/src/test/java/org/apache/hop/spark/pipeline/handler/SparkSqlHandlerTest.java
@@ -0,0 +1,372 @@
+/*
+ * 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.hop.spark.pipeline.handler;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaInteger;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.metadata.serializer.memory.MemoryMetadataProvider;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.spark.engines.SparkPipelineRunConfiguration;
+import org.apache.hop.spark.transforms.io.SparkField;
+import org.apache.hop.spark.transforms.sql.SparkSqlMeta;
+import org.apache.hop.spark.transforms.sql.SparkSqlView;
+import org.apache.hop.spark.util.SparkConst;
+import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.Row;
+import org.apache.spark.sql.RowFactory;
+import org.apache.spark.sql.SparkSession;
+import org.apache.spark.sql.types.DataTypes;
+import org.apache.spark.sql.types.StructField;
+import org.apache.spark.sql.types.StructType;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Integration-style unit tests for the native Spark SQL handler on a local
Spark session. These
+ * prove multi-input view registration, whole-query planning and the
declared-schema contract.
+ */
+class SparkSqlHandlerTest {
+
+ private static SparkSession spark;
+
+ @BeforeAll
+ static void startSpark() throws Exception {
+ HopEnvironment.init();
+ HopLogStore.init();
+ spark =
+ SparkSession.builder()
+ .appName("hop-spark-sql-handler-test")
+ .master("local[2]")
+ .config("spark.ui.enabled", "false")
+ .config("spark.ui.showConsoleProgress", "false")
+ .config("spark.metrics.staticSources.enabled", "false")
+ .config("spark.sql.shuffle.partitions", "4")
+ .config("spark.driver.host", "localhost")
+ .getOrCreate();
+ }
+
+ @AfterAll
+ static void stopSpark() {
+ if (spark != null) {
+ spark.stop();
+ }
+ }
+
+ @Test
+ void joinsTwoInputsRegisteredAsTempViews() throws Exception {
+ Dataset<Row> orders = ordersDataset();
+ Dataset<Row> customers = customersDataset();
+
+ SparkSqlMeta meta = new SparkSqlMeta();
+ meta.setSql(
+ "SELECT c.name AS name, SUM(o.amount) AS total "
+ + "FROM orders o JOIN customers c ON o.customer_id = c.id "
+ + "GROUP BY c.name");
+ meta.setFields(List.of(new SparkField("name", "String"), new
SparkField("total", "Integer")));
+
+ Map<String, Dataset<Row>> map = new HashMap<>();
+ map.put("orders", orders);
+ map.put("customers", customers);
+
+ Dataset<Row> output = run(meta, "sql", map, List.of("orders",
"customers"), new Variables());
+
+ assertArrayEquals(new String[] {"name", "total"}, output.columns());
+ List<Row> rows = output.orderBy("name").collectAsList();
+ assertEquals(2, rows.size());
+ assertEquals("alice", rows.get(0).getString(0));
+ assertEquals(30L, rows.get(0).getLong(1));
+ assertEquals("bob", rows.get(1).getString(0));
+ assertEquals(7L, rows.get(1).getLong(1));
+ }
+
+ @Test
+ void unionsAcrossPartitionsUnderOnePlan() throws Exception {
+ Dataset<Row> orders = ordersDataset().repartition(4);
+
+ SparkSqlMeta meta = new SparkSqlMeta();
+ meta.setSql(
+ "SELECT COUNT(*) AS row_count FROM (SELECT * FROM orders UNION ALL
SELECT * FROM orders)");
+ meta.setFields(List.of(new SparkField("row_count", "Integer")));
+
+ Map<String, Dataset<Row>> map = new HashMap<>();
+ map.put("orders", orders);
+
+ Dataset<Row> output = run(meta, "sql", map, List.of("orders"), new
Variables());
+ assertEquals(8L, output.collectAsList().get(0).getLong(0));
+ }
+
+ @Test
+ void viewNameOverrideIsUsed() throws Exception {
+ Dataset<Row> orders = ordersDataset();
+
+ SparkSqlMeta meta = new SparkSqlMeta();
+ meta.setSql("SELECT SUM(amount) AS total FROM raw_orders");
+ meta.setViews(List.of(new SparkSqlView("Read orders (raw)",
"raw_orders")));
+ meta.setFields(List.of(new SparkField("total", "Integer")));
+
+ Map<String, Dataset<Row>> map = new HashMap<>();
+ map.put("Read orders (raw)", orders);
+
+ Dataset<Row> output = run(meta, "sql", map, List.of("Read orders (raw)"),
new Variables());
+ assertEquals(37L, output.collectAsList().get(0).getLong(0));
+ }
+
+ @Test
+ void transformNameWithoutOverrideIsSanitisedIntoAViewName() throws Exception
{
+ Dataset<Row> orders = ordersDataset();
+
+ SparkSqlMeta meta = new SparkSqlMeta();
+ // "Read orders (raw)" sanitises to "Read_orders__raw_"
+ meta.setSql("SELECT SUM(amount) AS total FROM Read_orders__raw_");
+ meta.setFields(List.of(new SparkField("total", "Integer")));
+
+ Map<String, Dataset<Row>> map = new HashMap<>();
+ map.put("Read orders (raw)", orders);
+
+ Dataset<Row> output = run(meta, "sql", map, List.of("Read orders (raw)"),
new Variables());
+ assertEquals(37L, output.collectAsList().get(0).getLong(0));
+ }
+
+ @Test
+ void zeroInputStatementIsAllowed() throws Exception {
+ SparkSqlMeta meta = new SparkSqlMeta();
+ meta.setSql("SELECT * FROM VALUES (1, 'a'), (2, 'b') AS t(id, label)");
+ meta.setFields(List.of(new SparkField("id", "Integer"), new
SparkField("label", "String")));
+
+ Dataset<Row> output = run(meta, "sql", new HashMap<>(), List.of(), new
Variables());
+
+ assertArrayEquals(new String[] {"id", "label"}, output.columns());
+ assertEquals(2, output.count());
+ }
+
+ @Test
+ void variablesAreResolvedOnTheDriverBeforeExecution() throws Exception {
+ Dataset<Row> orders = ordersDataset();
+
+ IVariables variables = new Variables();
+ variables.setVariable("MIN_AMOUNT", "10");
+
+ SparkSqlMeta meta = new SparkSqlMeta();
+ meta.setSql("SELECT COUNT(*) AS row_count FROM orders WHERE amount >=
${MIN_AMOUNT}");
+ meta.setFields(List.of(new SparkField("row_count", "Integer")));
+
+ Map<String, Dataset<Row>> map = new HashMap<>();
+ map.put("orders", orders);
+
+ Dataset<Row> output = run(meta, "sql", map, List.of("orders"), variables);
+ assertEquals(2L, output.collectAsList().get(0).getLong(0));
+ }
+
+ @Test
+ void declaredFieldsDriveOutputOrderAndType() throws Exception {
+ Dataset<Row> orders = ordersDataset();
+
+ SparkSqlMeta meta = new SparkSqlMeta();
+ meta.setSql("SELECT customer_id, amount FROM orders");
+ // Declared in the opposite order, and amount declared as String
+ meta.setFields(
+ List.of(new SparkField("amount", "String"), new
SparkField("customer_id", "String")));
+
+ Map<String, Dataset<Row>> map = new HashMap<>();
+ map.put("orders", orders);
+
+ Dataset<Row> output = run(meta, "sql", map, List.of("orders"), new
Variables());
+
+ assertArrayEquals(new String[] {"amount", "customer_id"},
output.columns());
+ assertEquals(
+ DataTypes.StringType, output.schema().apply("amount").dataType(),
"declared cast applied");
+ assertEquals(DataTypes.StringType,
output.schema().apply("customer_id").dataType());
+ }
+
+ @Test
+ void missingDeclaredFieldFailsWithTheColumnsThatWereReturned() {
+ Dataset<Row> orders = ordersDataset();
+
+ SparkSqlMeta meta = new SparkSqlMeta();
+ meta.setSql("SELECT customer_id FROM orders");
+ meta.setFields(List.of(new SparkField("nope", "String")));
+
+ Map<String, Dataset<Row>> map = new HashMap<>();
+ map.put("orders", orders);
+
+ HopException e =
+ assertThrows(
+ HopException.class, () -> run(meta, "sql", map, List.of("orders"),
new Variables()));
+ assertTrue(e.getMessage().contains("nope"), e.getMessage());
+ assertTrue(e.getMessage().contains("customer_id"), e.getMessage());
+ }
+
+ @Test
+ void emptyFieldListIsRejected() {
+ Dataset<Row> orders = ordersDataset();
+
+ SparkSqlMeta meta = new SparkSqlMeta();
+ meta.setSql("SELECT customer_id FROM orders");
+ meta.setFields(new ArrayList<>());
+
+ Map<String, Dataset<Row>> map = new HashMap<>();
+ map.put("orders", orders);
+
+ HopException e =
+ assertThrows(
+ HopException.class, () -> run(meta, "sql", map, List.of("orders"),
new Variables()));
+ assertTrue(e.getMessage().contains("output field list is required"),
e.getMessage());
+ }
+
+ @Test
+ void emptySqlIsRejected() {
+ SparkSqlMeta meta = new SparkSqlMeta();
+ meta.setSql(" ");
+ meta.setFields(List.of(new SparkField("a", "String")));
+
+ HopException e =
+ assertThrows(
+ HopException.class,
+ () -> run(meta, "sql", new HashMap<>(), List.of(), new
Variables()));
+ assertTrue(e.getMessage().contains("SQL statement is empty"),
e.getMessage());
+ }
+
+ @Test
+ void collidingViewNamesAreReportedRatherThanShadowed() {
+ SparkSqlMeta meta = new SparkSqlMeta();
+ meta.setSql("SELECT 1 AS one");
+ meta.setFields(List.of(new SparkField("one", "Integer")));
+
+ Map<String, Dataset<Row>> map = new HashMap<>();
+ // Both sanitise to "orders_a"
+ map.put("orders a", ordersDataset());
+ map.put("orders_a", ordersDataset());
+
+ HopException e =
+ assertThrows(
+ HopException.class,
+ () -> run(meta, "sql", map, List.of("orders a", "orders_a"), new
Variables()));
+ assertTrue(e.getMessage().contains("both map to view"), e.getMessage());
+ }
+
+ @Test
+ void invalidSqlReportsTheRegisteredViews() {
+ Dataset<Row> orders = ordersDataset();
+
+ SparkSqlMeta meta = new SparkSqlMeta();
+ meta.setSql("SELECT * FROM does_not_exist");
+ meta.setFields(List.of(new SparkField("a", "String")));
+
+ Map<String, Dataset<Row>> map = new HashMap<>();
+ map.put("orders", orders);
+
+ HopException e =
+ assertThrows(
+ HopException.class, () -> run(meta, "sql", map, List.of("orders"),
new Variables()));
+ assertTrue(e.getMessage().contains("Registered views"), e.getMessage());
+ assertTrue(e.getMessage().contains("orders"), e.getMessage());
+ }
+
+ /** Builds the TransformMeta/PipelineMeta wiring and runs the handler,
returning its Dataset. */
+ private static Dataset<Row> run(
+ SparkSqlMeta meta,
+ String transformName,
+ Map<String, Dataset<Row>> transformDatasetMap,
+ List<String> previousTransformNames,
+ IVariables variables)
+ throws HopException {
+
+ TransformMeta sqlTransform = new TransformMeta(transformName, meta);
+ sqlTransform.setTransformPluginId(SparkConst.SPARK_SQL_PLUGIN_ID);
+
+ PipelineMeta pipelineMeta = new PipelineMeta();
+ pipelineMeta.addTransform(sqlTransform);
+
+ List<TransformMeta> previousTransforms = new ArrayList<>();
+ for (String name : previousTransformNames) {
+ TransformMeta previous = new TransformMeta(name, meta);
+ pipelineMeta.addTransform(previous);
+ previousTransforms.add(previous);
+ }
+
+ IRowMeta rowMeta = new RowMeta();
+ rowMeta.addValueMeta(new ValueMetaString("customer_id"));
+ rowMeta.addValueMeta(new ValueMetaInteger("amount"));
+
+ new SparkSqlHandler()
+ .handleTransform(
+ LogChannel.GENERAL,
+ variables,
+ "spark",
+ new SparkPipelineRunConfiguration(),
+ new MemoryMetadataProvider(),
+ "{}",
+ pipelineMeta,
+ sqlTransform,
+ transformDatasetMap,
+ spark,
+ rowMeta,
+ previousTransforms,
+ null);
+
+ return transformDatasetMap.get(transformName);
+ }
+
+ private static Dataset<Row> ordersDataset() {
+ StructType schema =
+ new StructType(
+ new StructField[] {
+ DataTypes.createStructField("customer_id", DataTypes.StringType,
false),
+ DataTypes.createStructField("amount", DataTypes.LongType, false)
+ });
+ List<Row> rows =
+ Arrays.asList(
+ RowFactory.create("c1", 10L),
+ RowFactory.create("c1", 20L),
+ RowFactory.create("c2", 7L),
+ RowFactory.create("c1", 0L));
+ return spark.createDataFrame(rows, schema);
+ }
+
+ private static Dataset<Row> customersDataset() {
+ StructType schema =
+ new StructType(
+ new StructField[] {
+ DataTypes.createStructField("id", DataTypes.StringType, false),
+ DataTypes.createStructField("name", DataTypes.StringType, false)
+ });
+ List<Row> rows =
+ Arrays.asList(RowFactory.create("c1", "alice"),
RowFactory.create("c2", "bob"));
+ return spark.createDataFrame(rows, schema);
+ }
+}
diff --git
a/plugins/engines/spark/src/test/java/org/apache/hop/spark/transforms/sql/SparkSqlMetaTest.java
b/plugins/engines/spark/src/test/java/org/apache/hop/spark/transforms/sql/SparkSqlMetaTest.java
new file mode 100644
index 0000000000..90e98a7ae9
--- /dev/null
+++
b/plugins/engines/spark/src/test/java/org/apache/hop/spark/transforms/sql/SparkSqlMetaTest.java
@@ -0,0 +1,106 @@
+/*
+ * 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.hop.spark.transforms.sql;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.util.List;
+import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.IValueMeta;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.metadata.serializer.memory.MemoryMetadataProvider;
+import org.apache.hop.spark.transforms.io.SparkField;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+class SparkSqlMetaTest {
+
+ @BeforeAll
+ static void init() throws Exception {
+ HopEnvironment.init();
+ }
+
+ @Test
+ void defaultViewNameReplacesCharactersThatAreNotSqlSafe() {
+ assertEquals("orders", SparkSqlMeta.defaultViewName("orders"));
+ assertEquals("Read_orders__raw_", SparkSqlMeta.defaultViewName("Read
orders (raw)"));
+ assertEquals("a_b_c", SparkSqlMeta.defaultViewName("a-b.c"));
+ assertEquals("keep_underscores",
SparkSqlMeta.defaultViewName("keep_underscores"));
+ }
+
+ @Test
+ void defaultViewNameEscapesALeadingDigit() {
+ assertEquals("_1_orders", SparkSqlMeta.defaultViewName("1 orders"));
+ assertEquals("_2024", SparkSqlMeta.defaultViewName("2024"));
+ }
+
+ @Test
+ void defaultViewNameHandlesNullAndEmpty() {
+ assertNull(SparkSqlMeta.defaultViewName(null));
+ assertNull(SparkSqlMeta.defaultViewName(""));
+ }
+
+ @Test
+ void findViewNameOverrideIgnoresBlankAndUnknownEntries() {
+ SparkSqlMeta meta = new SparkSqlMeta();
+ meta.setViews(
+ List.of(
+ new SparkSqlView("orders", "o"),
+ new SparkSqlView("customers", ""),
+ new SparkSqlView("blank", null)));
+
+ assertEquals("o", meta.findViewNameOverride("orders"));
+ assertNull(meta.findViewNameOverride("customers"));
+ assertNull(meta.findViewNameOverride("blank"));
+ assertNull(meta.findViewNameOverride("unknown"));
+ assertNull(meta.findViewNameOverride(null));
+ }
+
+ @Test
+ void getFieldsReplacesTheIncomingRowWithTheDeclaredFields() throws Exception
{
+ SparkSqlMeta meta = new SparkSqlMeta();
+ meta.setFields(List.of(new SparkField("name", "String"), new
SparkField("total", "Integer")));
+
+ IRowMeta rowMeta = new RowMeta();
+ rowMeta.addValueMeta(new ValueMetaString("carried_over"));
+
+ meta.getFields(rowMeta, "sql", null, null, new Variables(), new
MemoryMetadataProvider());
+
+ assertEquals(2, rowMeta.size());
+ assertEquals("name", rowMeta.getValueMeta(0).getName());
+ assertEquals(IValueMeta.TYPE_STRING, rowMeta.getValueMeta(0).getType());
+ assertEquals("total", rowMeta.getValueMeta(1).getName());
+ assertEquals(IValueMeta.TYPE_INTEGER, rowMeta.getValueMeta(1).getType());
+ }
+
+ @Test
+ void getFieldsSkipsUnnamedFields() throws Exception {
+ SparkSqlMeta meta = new SparkSqlMeta();
+ meta.setFields(List.of(new SparkField("", "String"), new
SparkField("kept", "String")));
+
+ IRowMeta rowMeta = new RowMeta();
+ meta.getFields(rowMeta, "sql", null, null, new Variables(), new
MemoryMetadataProvider());
+
+ assertEquals(1, rowMeta.size());
+ assertEquals("kept", rowMeta.getValueMeta(0).getName());
+ }
+}