From 2999e77d62988eecf281712405fdc22f957a9397 Mon Sep 17 00:00:00 2001 From: MBWhite Date: Mon, 9 Sep 2024 12:44:06 +0100 Subject: [PATCH 1/8] feat: spark-substrait example Signed-off-by: MBWhite --- .editorconfig | 2 +- .github/workflows/pr.yml | 20 + .gitignore | 1 + examples/substrait-spark/.gitignore | 2 + examples/substrait-spark/README.md | 587 ++++++++++++++++++ examples/substrait-spark/app/.gitignore | 2 + examples/substrait-spark/app/build.gradle | 62 ++ .../main/java/io/substrait/examples/App.java | 40 ++ .../examples/SparkConsumeSubstrait.java | 49 ++ .../io/substrait/examples/SparkDataset.java | 80 +++ .../io/substrait/examples/SparkHelper.java | 44 ++ .../java/io/substrait/examples/SparkSQL.java | 86 +++ .../src/main/resources/tests_subset_2023.csv | 30 + .../main/resources/vehicles_subset_2023.csv | 31 + .../substrait-spark/build-logic/build.gradle | 16 + .../build-logic/settings.gradle | 15 + ...dlogic.java-application-conventions.gradle | 13 + .../buildlogic.java-common-conventions.gradle | 39 ++ ...buildlogic.java-library-conventions.gradle | 13 + examples/substrait-spark/docker-compose.yaml | 32 + examples/substrait-spark/gradle.properties | 6 + .../substrait-spark/gradle/libs.versions.toml | 14 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43453 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + examples/substrait-spark/gradlew | 249 ++++++++ examples/substrait-spark/gradlew.bat | 92 +++ examples/substrait-spark/justfile | 56 ++ examples/substrait-spark/settings.gradle | 20 + readme.md | 6 + 29 files changed, 1613 insertions(+), 1 deletion(-) create mode 100644 examples/substrait-spark/.gitignore create mode 100644 examples/substrait-spark/README.md create mode 100644 examples/substrait-spark/app/.gitignore create mode 100644 examples/substrait-spark/app/build.gradle create mode 100644 examples/substrait-spark/app/src/main/java/io/substrait/examples/App.java create mode 100644 examples/substrait-spark/app/src/main/java/io/substrait/examples/SparkConsumeSubstrait.java create mode 100644 examples/substrait-spark/app/src/main/java/io/substrait/examples/SparkDataset.java create mode 100644 examples/substrait-spark/app/src/main/java/io/substrait/examples/SparkHelper.java create mode 100644 examples/substrait-spark/app/src/main/java/io/substrait/examples/SparkSQL.java create mode 100644 examples/substrait-spark/app/src/main/resources/tests_subset_2023.csv create mode 100644 examples/substrait-spark/app/src/main/resources/vehicles_subset_2023.csv create mode 100644 examples/substrait-spark/build-logic/build.gradle create mode 100644 examples/substrait-spark/build-logic/settings.gradle create mode 100644 examples/substrait-spark/build-logic/src/main/groovy/buildlogic.java-application-conventions.gradle create mode 100644 examples/substrait-spark/build-logic/src/main/groovy/buildlogic.java-common-conventions.gradle create mode 100644 examples/substrait-spark/build-logic/src/main/groovy/buildlogic.java-library-conventions.gradle create mode 100644 examples/substrait-spark/docker-compose.yaml create mode 100644 examples/substrait-spark/gradle.properties create mode 100644 examples/substrait-spark/gradle/libs.versions.toml create mode 100644 examples/substrait-spark/gradle/wrapper/gradle-wrapper.jar create mode 100644 examples/substrait-spark/gradle/wrapper/gradle-wrapper.properties create mode 100755 examples/substrait-spark/gradlew create mode 100644 examples/substrait-spark/gradlew.bat create mode 100644 examples/substrait-spark/justfile create mode 100644 examples/substrait-spark/settings.gradle diff --git a/.editorconfig b/.editorconfig index cc987b518..ce1567087 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,7 +10,7 @@ trim_trailing_whitespace = true [*.{yaml,yml}] indent_size = 2 -[{**/*.sql,**/OuterReferenceResolver.md,gradlew.bat}] +[{**/*.sql,**/OuterReferenceResolver.md,**gradlew.bat}] charset = unset end_of_line = unset insert_final_newline = unset diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 2feebebea..877510d15 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -86,6 +86,26 @@ jobs: uses: gradle/actions/setup-gradle@v3 - name: Build with Gradle run: gradle build --rerun-tasks + examples: + name: Build Examples + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + - uses: extractions/setup-just@v2 + - name: substrait-spark + shell: bash + run: | + pwd + ls -lart + just -f ./examples/substrait-spark/justfile buildapp + isthmus-native-image-mac-linux: name: Build Isthmus Native Image needs: java diff --git a/.gitignore b/.gitignore index d7d9428ea..c84c103c5 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ gen out/** *.iws .vscode +.pmdCache diff --git a/examples/substrait-spark/.gitignore b/examples/substrait-spark/.gitignore new file mode 100644 index 000000000..8965a89f4 --- /dev/null +++ b/examples/substrait-spark/.gitignore @@ -0,0 +1,2 @@ +_apps +_data diff --git a/examples/substrait-spark/README.md b/examples/substrait-spark/README.md new file mode 100644 index 000000000..97a53b707 --- /dev/null +++ b/examples/substrait-spark/README.md @@ -0,0 +1,587 @@ +# Introduction to the Substrait-Spark library + +The Substrait-Spark library was recently added to the [substrait-java](https://github.com/substrait-io/substrait-java) project; this library allows Substrait plans to convert to and from Spark Plans. + + +## How does this work in practice? + +Once Spark SQL and Spark DataFrame APIs queries have been created, Spark's optimized query plan can be used generate Substrait plans; and Substrait Plans can be executed on a Spark cluster. Below is a description of how to use this library; there are two sample datasets included for demonstration. + +The most commonly used logical relations are supported, including those generated from all the TPC-H queries, but there are currently some gaps in support that prevent all the TPC-DS queries from being translatable. + + +## Running the examples + +There are 3 example classes: + +- [SparkDataset](./app/src/main/java/io/substrait/examples/SparkDataset.java) that creates a plan starting with the Spark Dataset API +- [SparkSQL](./app/src/main/java/io/substrait/examples/SparkSQL.java) that creates a plan starting with the Spark SQL API +- [SparkConsumeSubstrait](./app/src/main/java/io/substrait/examples/SparkConsumeSubstrait.java) that loads a Substrait plan and executes it + + + +### Requirements + +To run these you will need: + +- Java 17 or greater +- Docker to start a test Spark Cluster + - you could use your own cluster, but would need to adjust file locations defined in [SparkHelper](./app/src/main/java/io/substrait/examples/SparkHelper.java) +- [just task runner](https://github.com/casey/just#installation) optional, but very helpful to run the bash commands +- [Two datafiles](./app/src/main/resources/) are provided (CSV format) + +For building using the `substrait-spark` library youself, using the [mvn repository](https://mvnrepository.com/artifact/io.substrait/spark) + +Using maven: +```xml + + + io.substrait + spark + 0.36.0 + +``` + +Using Gradle (groovy) +```groovy +// https://mvnrepository.com/artifact/io.substrait/spark +implementation 'io.substrait:spark:0.36.0' +``` + +### Setup configuration + +Firstly the application needs to be built; this is a simple Java application. As well issuing the `gradle` build command it also creates two directories `_apps` and `_data`. The JAR file and will be copied to the `_apps` directory and the datafiles to the `_data`. Note that the permissions on the `_data` directory are set to group write - this allows the spark process in the docker container to write the output plan + +To run using `just` +``` +just buildapp + +# or + +./gradlew build +mkdir -p ./_data && chmod g+w ./_data +mkdir -p ./_apps + +cp ./app/build/libs/app.jar ./_apps +cp ./app/src/main/resources/*.csv ./_data + +``` + +- In the `_data` directory there are now two csv files [tests_subset_2023.csv](./app/src/main/resources/tests_subset_2023.csv) and [vehicles_subset_2023.csv](./app/src/main/resources/vehicles_subset_2023.csv) + + +Second, you can start the basic Spark cluster - this uses `docker compose`. It is best to start this is a separate window + +``` +just spark +``` + +- In [SparkHelper](./app/src/main/java/io/substrait/examples/SparkHelper.java) there are constants defined to match these locations + +```java + public static final String VEHICLES_PQ_CSV = "vehicles_subset_2023.csv"; + public static final String TESTS_PQ_CSV = "tests_subset_2023.csv"; + public static final String ROOT_DIR = "file:/opt/spark-data"; +``` + +- To run the application `exec` into the SparkMaster node, and issue `spark-submit` + +``` +docker exec -it subtrait-spark-spark-1 bash +/opt/spark/bin/spark-submit --master spark://subtrait-spark-spark-1:7077 --driver-memory 1G --executor-memory 1G /opt/spark-apps/app.jar +``` + +The `justfile` has three targets to make it easy to run the examples + +- `just dataset` runs the Dataset API and produces `spark_dataset_substrait.plan` +- `just sql` runs the SQL api and produces `spark_sql_substrait.plan` +- `just consume ` runs the specified plan (from the `_data` directory) + + + + +## Creating a Substrait Plan + +In [SparkSQL](./app/src/main/java/io/substrait/examples/SparkSQL.java) is a simple use of SQL to join the two tables; after reading the two CSV files, the SQL query is defined. This is then run on Spark. + +### Loading data + +Firstly the filenames are created, and the CSV files read. Temporary views need to be created to refer to these tables in the SQL query. + +```java + String vehiclesFile = Paths.get(ROOT_DIR, VEHICLES_CSV).toString(); + String testsFile = Paths.get(ROOT_DIR, TESTS_CSV).toString(); + + spark.read().option("delimiter", ",").option("header", "true").csv(vehiclesFile) + .createOrReplaceTempView(VEHICLE_TABLE); + spark.read().option("delimiter", ",").option("header", "true").csv(testsFile) + .createOrReplaceTempView(TESTS_TABLE); +``` + +### Creating the SQL query + +The standard SQL query string as an example will find the counts of all cars (arranged by colour) of all vehicles that have passed the vehicle safety test. + +```java + String sqlQuery = """ + SELECT vehicles.colour, count(*) as colourcount + FROM vehicles + INNER JOIN tests ON vehicles.vehicle_id=tests.vehicle_id + WHERE tests.test_result = 'P' + GROUP BY vehicles.colour + ORDER BY count(*) + """; + var result = spark.sql(sqlQuery); + result.show(); +``` + +If we were to just run this as-is, the output table would be below. +``` ++------+-----------+ +|colour|colourcount| ++------+-----------+ +| GREEN| 1| +|BRONZE| 1| +| RED| 2| +| BLACK| 2| +| GREY| 2| +| BLUE| 2| +|SILVER| 3| +| WHITE| 5| ++------+-----------+ +``` + +### Logical and Optimized Query Plans + +THe next step is to look at the logical and optimised query plans that Spark has constructed. + +```java + LogicalPlan logical = result.logicalPlan(); + System.out.println(logical); + + LogicalPlan optimised = result.queryExecution().optimizedPlan(); + System.out.println(optimised); + +``` + +The logical plan will be: + +``` +Sort [colourcount#30L ASC NULLS FIRST], true ++- Aggregate [colour#3], [colour#3, count(1) AS colourcount#30L] + +- Filter (test_result#19 = P) + +- Join Inner, (vehicle_id#0L = vehicle_id#15L) + :- SubqueryAlias vehicles + : +- View (`vehicles`, [vehicle_id#0L,make#1,model#2,colour#3,fuel_type#4,cylinder_capacity#5L,first_use_date#6]) + : +- Relation [vehicle_id#0L,make#1,model#2,colour#3,fuel_type#4,cylinder_capacity#5L,first_use_date#6] csv + +- SubqueryAlias tests + +- View (`tests`, [test_id#14L,vehicle_id#15L,test_date#16,test_class#17,test_type#18,test_result#19,test_mileage#20L,postcode_area#21]) + +- Relation [test_id#14L,vehicle_id#15L,test_date#16,test_class#17,test_type#18,test_result#19,test_mileage#20L,postcode_area#21] csv +``` + +Similarly, the optimized plan can be found; here the `SubQuery` and `View` have been converted into Project and Filter + +``` +Sort [colourcount#30L ASC NULLS FIRST], true ++- Aggregate [colour#3], [colour#3, count(1) AS colourcount#30L] + +- Project [colour#3] + +- Join Inner, (vehicle_id#0L = vehicle_id#15L) + :- Project [vehicle_id#0L, colour#3] + : +- Filter isnotnull(vehicle_id#0L) + : +- Relation [vehicle_id#0L,make#1,model#2,colour#3,fuel_type#4,cylinder_capacity#5L,first_use_date#6] csv + +- Project [vehicle_id#15L] + +- Filter ((isnotnull(test_result#19) AND (test_result#19 = P)) AND isnotnull(vehicle_id#15L)) + +- Relation [test_id#14L,vehicle_id#15L,test_date#16,test_class#17,test_type#18,test_result#19,test_mileage#20L,postcode_area#21] csv +``` + +### Dataset API + +Alternatively, the dataset API can be used to create the plans, the code for this in [`SparkDataset`](./app/src/main/java/io/substrait/examples/SparkDataset.java). The overall flow of the code is very similar + +Rather than create a temporary view, the reference to the datasets are kept in `dsVehicles` and `dsTests` +```java + dsVehicles = spark.read().option("delimiter", ",").option("header", "true").csv(vehiclesFile); + dsVehicles.show(); + + dsTests = spark.read().option("delimiter", ",").option("header", "true").csv(testsFile); + dsTests.show(); +``` + +They query can be constructed based on these two datasets + +```java + Dataset joinedDs = dsVehicles.join(dsTests, dsVehicles.col("vehicle_id").equalTo(dsTests.col("vehicle_id"))) + .filter(dsTests.col("test_result").equalTo("P")) + .groupBy(dsVehicles.col("colour")) + .count(); + + joinedDs = joinedDs.orderBy(joinedDs.col("count")); + joinedDs.show(); +``` + +Using the same APIs, the Spark's optimized plan is available. If you compare this to the plan above you will see that structurally it is identical. + +``` +Sort [count#189L ASC NULLS FIRST], true ++- Aggregate [colour#20], [colour#20, count(1) AS count#189L] + +- Project [colour#20] + +- Join Inner, (vehicle_id#17 = vehicle_id#86) + :- Project [vehicle_id#17, colour#20] + : +- Filter isnotnull(vehicle_id#17) + : +- Relation [vehicle_id#17,make#18,model#19,colour#20,fuel_type#21,cylinder_capacity#22,first_use_date#23] csv + +- Project [vehicle_id#86] + +- Filter ((isnotnull(test_result#90) AND (test_result#90 = P)) AND isnotnull(vehicle_id#86)) + +- Relation [test_id#85,vehicle_id#86,test_date#87,test_class#88,test_type#89,test_result#90,test_mileage#91,postcode_area#92] csv +``` + +### Substrait Creation + +This optimized plan is the best starting point to produce a Substrait Plan; there's a `createSubstrait(..)` function that does the work and writes a binary protobuf file (`spark) + +``` + LogicalPlan optimised = result.queryExecution().optimizedPlan(); + System.out.println(optimised); + + createSubstrait(optimised); +``` + +Let's look at the APIs in the `createSubstrait(...)` method to see how it's using the `Substrait-Spark` Library. + +```java + ToSubstraitRel toSubstrait = new ToSubstraitRel(); + io.substrait.plan.Plan plan = toSubstrait.convert(enginePlan); +``` + +`ToSubstraitRel` is the main class and provides the convert method; this takes the Spark plan (optimized plan is best) and produce the Substrait Plan. The most common relations are supported currently - and the optimized plan is more likely to use these. + +The `io.substrait.plan.Plan` object is a high-level Substrait POJO representing a plan. This could be used directly or more likely be persisted. protobuf is the canonical serialization form. It's easy to convert this and store in a file + +```java + PlanProtoConverter planToProto = new PlanProtoConverter(); + byte[] buffer = planToProto.toProto(plan).toByteArray(); + try { + Files.write(Paths.get(ROOT_DIR, "spark_sql_substrait.plan"),buffer); + } catch (IOException e){ + e.printStackTrace(); + } +``` + +For the dataset approach, the `spark_dataset_substrait.plan` is created, and for the SQL approach the `spark_sql_substrait.plan` is created. These Intermediate Representations of the query can be saved, transferred and reloaded into a Data Engine. + +We can also review the Substrait plan's structure; the canonical format of the Substrait plan is the binary protobuf format, but it's possible to produce a textual version, an example is below. Both the Substrait plans from the Dataset or SQL APIs generate the same output. + +``` + +Root :: ImmutableSort [colour, count] + ++- Sort:: FieldRef#/I64/StructField{offset=1} ASC_NULLS_FIRST + +- Project:: [Str, I64, Str, I64] + +- Aggregate:: FieldRef#/Str/StructField{offset=0} + +- Project:: [Str, Str, Str, Str] + +- Join:: INNER equal:any_any + : arg0 = FieldRef#/Str/StructField{offset=0} + : arg1 = FieldRef#/Str/StructField{offset=2} + +- Project:: [Str, Str, Str, Str, Str, Str, Str, Str, Str] + +- Filter:: is_not_null:any + : arg0 = FieldRef#/Str/StructField{offset=0} + +- LocalFiles:: + : file:///opt/spark-data/vehicles_subset_2023.csv len=1547 partition=0 start=0 + +- Project:: [Str, Str, Str, Str, Str, Str, Str, Str, Str] + +- Filter:: and:bool + : arg0 = and:bool + : arg0 = is_not_null:any + : arg0 = FieldRef#/Str/StructField{offset=5} + : arg1 = equal:any_any + : arg0 = FieldRef#/Str/StructField{offset=5} + : arg1 = + : arg1 = is_not_null:any + : arg0 = FieldRef#/Str/StructField{offset=1} + +- LocalFiles:: + : file:///opt/spark-data/tests_subset_2023.csv len=1491 partition=0 start=0 +``` + +There is a more detail in this version that the Spark versions; details of the functions called for example are included. However, the structure of the overall plan is identical with 1 exception. There is an additional `project` relation included between the `sort` and `aggregate` - this is necessary to get the correct types of the output data. + +We can also see in this case as the plan came from Spark directly it's also included the location of the datafiles. Below when we reload this into Spark, the locations of the files don't need to be explicitly included. + + +As `Substrait Spark` library also allows plans to be loaded and executed, so the next step is to consume these Substrait plans. + +## Consuming a Substrait Plan + +The [`SparkConsumeSubstrait`](./app/src/main/java/io/substrait/examples/SparkConsumeSubstrait.java) code shows how to load this file, and most importantly how to convert it to a Spark engine plan to execute + +Loading the binary protobuf file is the reverse of the writing process (in the code the file name comes from a command line argument, here we're showing the hardcode file name ) + +```java + byte[] buffer = Files.readAllBytes(Paths.get("spark_sql_substrait.plan")); + io.substrait.proto.Plan proto = io.substrait.proto.Plan.parseFrom(buffer); + + ProtoPlanConverter protoToPlan = new ProtoPlanConverter(); + Plan plan = protoToPlan.from(proto); + +``` +The loaded byte array is first converted into the protobuf Plan, and then into the Substrait Plan object. Note it can be useful to name the variables, and/or use the pull class names to keep track of it's the ProtoBuf Plan or the high-level POJO Plan. + +Finally this can be converted to a Spark Plan: + +```java + ToLogicalPlan substraitConverter = new ToLogicalPlan(spark); + LogicalPlan sparkPlan = substraitConverter.convert(plan); +``` + +If you were to print out this plan, it has the identical structure to the plan seen earlier on. + +``` ++- Sort [count(1)#18L ASC NULLS FIRST], true + +- Aggregate [colour#5], [colour#5, count(1) AS count(1)#18L] + +- Project [colour#5] + +- Join Inner, (vehicle_id#2 = vehicle_id#10) + :- Project [vehicle_id#2, colour#5] + : +- Filter isnotnull(vehicle_id#2) + : +- Relation [vehicle_id#2,make#3,model#4,colour#5,fuel_type#6,cylinder_capacity#7,first_use_date#8] csv + +- Project [vehicle_id#10] + +- Filter ((isnotnull(test_result#14) AND (test_result#14 = P)) AND isnotnull(vehicle_id#10)) + +- Relation [test_id#9,vehicle_id#10,test_date#11,test_class#12,test_type#13,test_result#14,test_mileage#15,postcode_area#16] csv + +``` + +Executed of this plan is then simple `Dataset.ofRows(spark, sparkPlan).show();` giving the output of + +```java ++------+-----+ +|colour|count| ++------+-----+ +| GREEN| 1| +|BRONZE| 1| +| RED| 2| +| BLACK| 2| +| GREY| 2| +| BLUE| 2| +|SILVER| 3| +| WHITE| 5| ++------+-----+ +``` + +### Observations + +To recap on the steps above + +- Two CSV files have been loaded into Spark +- Using either the Spark SQL or the Spark Dataset API we can produce a query across those two datasets +- Both queries result in Spark creating a logical and optimized query plan + - And both being are structurally identical +- Using the Substrait-Java library, we can convert the optimized plan into the Substrait format. +- This Substrait intermediate representation of the query can be serialized via the protobuf format + - Here store as a flat file containing the bytes of that protobuf +- *Separately* this file can be loaded and the Substrait Plan converted to a Spark Plan +- This can be run in an application on Spark getting the same results + +--- +## Plan Comparison + +The structure of the query plans for both Spark and Substrait are structurally very similar. + +### Aggregate and Sort + +Spark's plan has a Project that filters down to the colour, followed by the Aggregation and Sort. +``` ++- Sort [count(1)#18L ASC NULLS FIRST], true + +- Aggregate [colour#5], [colour#5, count(1) AS count(1)#18L] + +- Project [colour#5] +``` + +When converted to Substrait the Sort and Aggregate is in the same order, but there are additional projects; it's not reduced the number of fields as early. + +``` ++- Sort:: FieldRef#/I64/StructField{offset=1} ASC_NULLS_FIRST + +- Project:: [Str, I64, Str, I64] + +- Aggregate:: FieldRef#/Str/StructField{offset=0} +``` + +### Inner Join + +Spark's inner join is taking as inputs the two filtered relations; it's ensuring the join key is not null but also the `test_result==p` check. + +``` + +- Join Inner, (vehicle_id#2 = vehicle_id#10) + :- Project [vehicle_id#2, colour#5] + : +- Filter isnotnull(vehicle_id#2) + + +- Project [vehicle_id#10] + +- Filter ((isnotnull(test_result#14) AND (test_result#14 = P)) AND isnotnull(vehicle_id#10)) + +``` + +The Substrait Representation looks longer, but is showing the same structure. (note that this format is a custom format implemented as [SubstraitStingify](...) as the standard text output can be hard to read). + +``` + +- Join:: INNER equal:any_any + : arg0 = FieldRef#/Str/StructField{offset=0} + : arg1 = FieldRef#/Str/StructField{offset=2} + +- Project:: [Str, Str, Str, Str, Str, Str, Str, Str, Str] + +- Filter:: is_not_null:any + : arg0 = FieldRef#/Str/StructField{offset=0} + + +- Project:: [Str, Str, Str, Str, Str, Str, Str, Str, Str] + +- Filter:: and:bool + : arg0 = and:bool + : arg0 = is_not_null:any + : arg0 = FieldRef#/Str/StructField{offset=5} + : arg1 = equal:any_any + : arg0 = FieldRef#/Str/StructField{offset=5} + : arg1 = + : arg1 = is_not_null:any + : arg0 = FieldRef#/Str/StructField{offset=1} +``` + +### LocalFiles + +The source of the data originally was two csv files; in the Spark plan this is referred to by csv suffix: ` Relation [...] csv`; this is represented in the Substrait plan as +``` + +- LocalFiles:: + : file:///opt/spark-data/tests_subset_2023.csv len=1491 partition=0 start=0 +``` + +There is a dedicated Substrait `ReadRel` relation for referencing files, it does include additional information about the type of the file, size, format and options for reading those specific formats. Parquet/Arrow/Orc/ProtoBuf/Dwrf currently all have specific option structures. + +## Data Locations + +The implication of a relation that includes a filename is seen when the plan is deserialized and executed; the binary Substrait plan needs to be read, converted into a Substrait Plan POJO and passed to the Spark-Substrait library to be converted. Once converted it can be directly executed. + +The plan itself contains all the information needed to be able to execute the query. + +A slight difference is observed when the Spark DataFrame is saved as a Hive table. Using `saveAsTable(...)` and `table(...)` the data can be persisted. + +```java + String vehiclesFile = Paths.get(ROOT_DIR, VEHICLES_CSV).toString(); + Dataset dsVehicles = spark.read().option("delimiter", ",").option("header", "true").csv(vehiclesFile); + dsVehicles.write().saveAsTable("vehicles"); + + spark.read().table("vehicles").show(); +``` + +When this is table is read and used in queries the Substrait "ReadRel" will be a `NamedScan` instead; this is referring to a table +`[spark_catalog, default, vehicles]` - default is the name of the default Spark database. + +``` + +- NamedScan:: Tables=[spark_catalog, default, vehicles] Fields=vehicle_id[Str],make[Str],model[Str],colour[Str],fuel_type[Str],cylinder_capacity[Str],first_use_date[Str] +``` + +This plan can be consumed in exactly the same many as the other plans; the only difference being, _if the table is not aleady_ present it will fail to execute. There isn't the source of the data, rather a reference name, and the expected fields. Ensuring the data is present in Spark, the query will execute without issue. + +## Observations on LoadFiles/NamedScan + +Including the information on the location of the data permits easy use of the plan. In the example here this worked well; however there could be difficulties depending on the recipient engine. Substrait as an intermediate form gives the ability to transfer the plans between engines; how different engines catalogue their data will be relevant. + +For example the above plan can be handled with PyArrow or DuckDB (as an example there are a variety of other engines); the code for consuming the plans is straightforward. + +```python + with open(PLAN_FILE, "wb") as file: + planbytes = file.read() + reader = substrait.run_query( + base64.b64decode(planbytes), + table_provider=self.simple_provider, + ) + result = reader.read_all() + +``` + +When run with the plan pyarrow instantly rejects it with + +``` +pyarrow.lib.ArrowNotImplementedError: non-default substrait::ReadRel::LocalFiles::FileOrFiles::length +``` + +DuckDB has a simiar API `connection.from_substrait(planbyhtes)` and produces a different error + +``` +duckdb.duckdb.IOException: IO Error: No files found that match the pattern "file:///opt/spark-data/tests_subset_2023.csv" +``` + +This shows that different engines will potentially have different supported relations; PyArrow wants to delegate the loading of the data to the user, whereas DuckDB is happy to load files. DuckDB though of course can only proceed with the information that it has, the URI of the file here is coupled to the location of the data on the originating engine. Something like a s3 uri could be potentially useful. + +Creating a plan from Spark but where the data is saved as table provides an alternative. Depending on the engine this can also need some careful handling. In the `NamedScan` above, the name was a list of 3 strings. `Tables=[spark_catalog, default, vehicles]`. Whilst DuckDB's implementation understands that these are referring to a table, its own catalogue can't be indexed with these three values. + +``` +duckdb.duckdb.CatalogException: Catalog Error: Table with name spark_catalog does not exist! +``` + +PyArrow takes a different approach in locating the data. In the PyArrow code above there is a reference to a `table_provider`; the job of 'providing a table' is delegated back to the user. + +Firstly we need to load the datasets to PyArrow datasets +```python + test = pq.read_table(TESTS_PQ_FILE) + vehicles = pq.read_table(VEHICLES_PQ_FILE) +``` + +We can define a `table_provider` function; this logs which table is being requested, but also what the expected schema is. +As names is a array, we can check the final part of the name and return the matching dataset. + +```python + def table_provider(self, names, schema): + print(f"== Requesting table {names} with schema: \n{schema}\n") + + if not names: + raise Exception("No names provided") + else: + if names[-1] == "tests": + return self.test + elif names[-1] == "vehicles": + return self.vehicles + + raise Exception(f"Unrecognized table name {names}") +``` + + +When run the output is along these lines (the query is slightly different here for simplicity); we can see the tables being request and the schema expected. Nothing is done with the schema here but could be useful for ensuring that the expectations of the plan match the schema of the data held in the engine. + +``` +== Requesting table ['spark_catalog', 'default', 'vehicles'] with schema: +vehicle_id: string +make: string +model: string +colour: string +fuel_type: string +cylinder_capacity: string +first_use_date: string + +== Requesting table ['spark_catalog', 'default', 'tests'] with schema: +test_id: string +vehicle_id: string +test_date: string +test_class: string +test_type: string +test_result: string +test_mileage: string +postcode_area: string + + colour test_result +0 WHITE P +1 WHITE F +2 BLACK P +3 BLACK P +4 RED P +5 BLACK P +6 BLUE P +7 SILVER F +8 SILVER F +9 BLACK P +``` + +# Summary + +The Substrait intermediate representation of the query can be serialized via the protobuf format and transferred between engines of the same type or between different engines. + +In the case of Spark for example, identical plans can be created with the Spark SQL or the Spark Dataset API. +*Separately* this file can be loaded and the Substrait Plan converted to a Spark Plan. Assuming that the consuming engine has the same understanding of the reference to LocalFiles the plan can be read and executed. + +Logical references to a 'table' via a `NamedScan` gives more flexibility; but the structure of the reference still needs to be properly understood and agreed upon. + +Once common understanding is agreed upon, transferring plans between engines brings great flexibility and potential. + + + + + + diff --git a/examples/substrait-spark/app/.gitignore b/examples/substrait-spark/app/.gitignore new file mode 100644 index 000000000..2ee4e319c --- /dev/null +++ b/examples/substrait-spark/app/.gitignore @@ -0,0 +1,2 @@ +spark-warehouse +derby.log diff --git a/examples/substrait-spark/app/build.gradle b/examples/substrait-spark/app/build.gradle new file mode 100644 index 000000000..cb2710b8b --- /dev/null +++ b/examples/substrait-spark/app/build.gradle @@ -0,0 +1,62 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * This project uses @Incubating APIs which are subject to change. + */ + +plugins { + id 'buildlogic.java-application-conventions' +} + +dependencies { + implementation 'org.apache.commons:commons-text' + // for running as a Spark application for real, this could be compile-only + + + implementation libs.substrait.core + implementation libs.substrait.spark + implementation libs.spark.sql + + // For a real Spark application, these would not be required since they would be in the Spark server classpath + runtimeOnly libs.spark.core +// https://mvnrepository.com/artifact/org.apache.spark/spark-hive + runtimeOnly libs.spark.hive + + + +} + +def jvmArguments = [ + "--add-exports", + "java.base/sun.nio.ch=ALL-UNNAMED", + "--add-opens=java.base/java.net=ALL-UNNAMED", + "--add-opens=java.base/java.nio=ALL-UNNAMED", + "-Dspark.master=local" +] + +application { + // Define the main class for the application. + mainClass = 'io.substrait.examples.App' + applicationDefaultJvmArgs = jvmArguments +} + +jar { + zip64 = true + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + + manifest { + attributes 'Main-Class': 'io.substrait.examples.App' + } + + from { + configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } + } + + exclude 'META-INF/*.RSA' + exclude 'META-INF/*.SF' + exclude 'META-INF/*.DSA' +} + +repositories { + +} diff --git a/examples/substrait-spark/app/src/main/java/io/substrait/examples/App.java b/examples/substrait-spark/app/src/main/java/io/substrait/examples/App.java new file mode 100644 index 000000000..fed789b3f --- /dev/null +++ b/examples/substrait-spark/app/src/main/java/io/substrait/examples/App.java @@ -0,0 +1,40 @@ +package io.substrait.examples; + +import java.nio.file.Files; +import java.nio.file.Paths; + +import io.substrait.plan.Plan; +import io.substrait.plan.ProtoPlanConverter; + +public class App { + + public static interface Action { + public void run(String arg); + } + + private App() { + } + + public static void main(String args[]) { + try { + + if (args.length == 0) { + args = new String[] { "SparkDataset" }; + } + String exampleClass = args[0]; + + var clz = Class.forName(App.class.getPackageName() + "." + exampleClass); + var action = (Action) clz.getDeclaredConstructor().newInstance(); + + if (args.length == 2) { + action.run(args[1]); + } else { + action.run(null); + } + + } catch (Exception e) { + e.printStackTrace(); + } + } + +} diff --git a/examples/substrait-spark/app/src/main/java/io/substrait/examples/SparkConsumeSubstrait.java b/examples/substrait-spark/app/src/main/java/io/substrait/examples/SparkConsumeSubstrait.java new file mode 100644 index 000000000..761209850 --- /dev/null +++ b/examples/substrait-spark/app/src/main/java/io/substrait/examples/SparkConsumeSubstrait.java @@ -0,0 +1,49 @@ +package io.substrait.examples; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; + +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan; + +import io.substrait.plan.Plan; +import io.substrait.plan.ProtoPlanConverter; +import io.substrait.spark.logical.ToLogicalPlan; + +import static io.substrait.examples.SparkHelper.ROOT_DIR; + +/** Minimal Spark application */ +public class SparkConsumeSubstrait implements App.Action { + + public SparkConsumeSubstrait() { + } + + @Override + public void run(String arg) { + + // Connect to a local in-process Spark instance + try (SparkSession spark = SparkHelper.connectLocalSpark()) { + + System.out.println("Reading from " + arg); + byte[] buffer = Files.readAllBytes(Paths.get(ROOT_DIR, arg)); + + io.substrait.proto.Plan proto = io.substrait.proto.Plan.parseFrom(buffer); + ProtoPlanConverter protoToPlan = new ProtoPlanConverter(); + Plan plan = protoToPlan.from(proto); + + ToLogicalPlan substraitConverter = new ToLogicalPlan(spark); + LogicalPlan sparkPlan = substraitConverter.convert(plan); + + System.out.println(sparkPlan); + + Dataset.ofRows(spark, sparkPlan).show(); + + spark.stop(); + } catch (IOException e) { + e.printStackTrace(); + } + } + +} diff --git a/examples/substrait-spark/app/src/main/java/io/substrait/examples/SparkDataset.java b/examples/substrait-spark/app/src/main/java/io/substrait/examples/SparkDataset.java new file mode 100644 index 000000000..4f0e668c7 --- /dev/null +++ b/examples/substrait-spark/app/src/main/java/io/substrait/examples/SparkDataset.java @@ -0,0 +1,80 @@ +package io.substrait.examples; + +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan; +import java.io.IOException; +import java.nio.file.*; +import io.substrait.plan.PlanProtoConverter; +import io.substrait.spark.logical.ToSubstraitRel; +import static io.substrait.examples.SparkHelper.ROOT_DIR; +import static io.substrait.examples.SparkHelper.TESTS_CSV; +import static io.substrait.examples.SparkHelper.VEHICLES_CSV; + +/** Minimal Spark application */ +public class SparkDataset implements App.Action { + + public SparkDataset() { + + } + + @Override + public void run(String arg) { + + // Connect to a local in-process Spark instance + try (SparkSession spark = SparkHelper.connectLocalSpark()) { + + Dataset dsVehicles; + Dataset dsTests; + + // load from CSV files + String vehiclesFile = Paths.get(ROOT_DIR, VEHICLES_CSV).toString(); + String testsFile = Paths.get(ROOT_DIR, TESTS_CSV).toString(); + + System.out.println("Reading "+vehiclesFile); + System.out.println("Reading "+testsFile); + + dsVehicles = spark.read().option("delimiter", ",").option("header", "true").csv(vehiclesFile); + dsVehicles.show(); + + dsTests = spark.read().option("delimiter", ",").option("header", "true").csv(testsFile); + dsTests.show(); + + // created the joined dataset + Dataset joinedDs = dsVehicles.join(dsTests, dsVehicles.col("vehicle_id").equalTo(dsTests.col("vehicle_id"))) + .filter(dsTests.col("test_result").equalTo("P")) + .groupBy(dsVehicles.col("colour")) + .count(); + + joinedDs = joinedDs.orderBy(joinedDs.col("count")); + joinedDs.show(); + + LogicalPlan plan = joinedDs.queryExecution().optimizedPlan(); + + System.out.println(plan); + createSubstrait(plan); + + spark.stop(); + } catch (Exception e) { + e.printStackTrace(System.out); + } + } + + public void createSubstrait(LogicalPlan enginePlan) { + ToSubstraitRel toSubstrait = new ToSubstraitRel(); + io.substrait.plan.Plan plan = toSubstrait.convert(enginePlan); + + System.out.println(plan); + + PlanProtoConverter planToProto = new PlanProtoConverter(); + byte[] buffer = planToProto.toProto(plan).toByteArray(); + try { + Files.write(Paths.get(ROOT_DIR,"spark_dataset_substrait.plan"), buffer); + System.out.println("File written to "+Paths.get(ROOT_DIR,"spark_sql_substrait.plan")); + } catch (IOException e) { + e.printStackTrace(System.out); + } + } + +} diff --git a/examples/substrait-spark/app/src/main/java/io/substrait/examples/SparkHelper.java b/examples/substrait-spark/app/src/main/java/io/substrait/examples/SparkHelper.java new file mode 100644 index 000000000..7bed7fae4 --- /dev/null +++ b/examples/substrait-spark/app/src/main/java/io/substrait/examples/SparkHelper.java @@ -0,0 +1,44 @@ +package io.substrait.examples; + +import org.apache.spark.sql.SparkSession; + +public class SparkHelper { + public static final String NAMESPACE = "demo_db"; + public static final String VEHICLE_TABLE = "vehicles"; + public static final String TESTS_TABLE = "tests"; + + public static final String VEHICLES_PQ = "vehicles_subset_2023.parquet"; + public static final String TESTS_PQ = "tests_subset_2023.parquet"; + + public static final String VEHICLES_CSV = "vehicles_subset_2023.csv"; + public static final String TESTS_CSV = "tests_subset_2023.csv"; + + public static final String ROOT_DIR = "/opt/spark-data"; + + // Connect to local spark for demo purposes + public static SparkSession connectSpark(String spark_master) { + + SparkSession spark = SparkSession.builder() + // .config("spark.sql.warehouse.dir", "spark-warehouse") + .config("spark.master", spark_master) + .enableHiveSupport() + .getOrCreate(); + + spark.sparkContext().setLogLevel("ERROR"); + + return spark; + } + + public static SparkSession connectLocalSpark() { + + SparkSession spark = SparkSession.builder() + .enableHiveSupport() + .getOrCreate(); + + spark.sparkContext().setLogLevel("ERROR"); + + return spark; + } + + +} diff --git a/examples/substrait-spark/app/src/main/java/io/substrait/examples/SparkSQL.java b/examples/substrait-spark/app/src/main/java/io/substrait/examples/SparkSQL.java new file mode 100644 index 000000000..3bdd26e96 --- /dev/null +++ b/examples/substrait-spark/app/src/main/java/io/substrait/examples/SparkSQL.java @@ -0,0 +1,86 @@ +package io.substrait.examples; + +import static io.substrait.examples.SparkHelper.ROOT_DIR; +import static io.substrait.examples.SparkHelper.TESTS_CSV; +import static io.substrait.examples.SparkHelper.TESTS_TABLE; +import static io.substrait.examples.SparkHelper.VEHICLES_CSV; +import static io.substrait.examples.SparkHelper.VEHICLE_TABLE; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; + +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan; + +import io.substrait.plan.PlanProtoConverter; +import io.substrait.spark.logical.ToSubstraitRel; + +/** Minimal Spark application */ +public class SparkSQL implements App.Action { + + public SparkSQL() { + + } + + @Override + public void run(String arg) { + + // Connect to a local in-process Spark instance + try (SparkSession spark = SparkHelper.connectLocalSpark()) { + spark.catalog().listDatabases().show(); + + // load from CSV files + String vehiclesFile = Paths.get(ROOT_DIR, VEHICLES_CSV).toString(); + String testsFile = Paths.get(ROOT_DIR, TESTS_CSV).toString(); + + System.out.println("Reading " + vehiclesFile); + System.out.println("Reading " + testsFile); + + spark.read().option("delimiter", ",").option("header", "true").csv(vehiclesFile) + .createOrReplaceTempView(VEHICLE_TABLE); + spark.read().option("delimiter", ",").option("header", "true").csv(testsFile) + .createOrReplaceTempView(TESTS_TABLE); + + String sqlQuery = """ + SELECT vehicles.colour, count(*) as colourcount + FROM vehicles + INNER JOIN tests ON vehicles.vehicle_id=tests.vehicle_id + WHERE tests.test_result = 'P' + GROUP BY vehicles.colour + ORDER BY count(*) + """; + + var result = spark.sql(sqlQuery); + result.show(); + + LogicalPlan logical = result.logicalPlan(); + System.out.println(logical); + + LogicalPlan optimised = result.queryExecution().optimizedPlan(); + System.out.println(optimised); + + createSubstrait(optimised); + spark.stop(); + } catch (Exception e) { + e.printStackTrace(System.out); + } + } + + public void createSubstrait(LogicalPlan enginePlan) { + ToSubstraitRel toSubstrait = new ToSubstraitRel(); + io.substrait.plan.Plan plan = toSubstrait.convert(enginePlan); + System.out.println(plan); + + PlanProtoConverter planToProto = new PlanProtoConverter(); + byte[] buffer = planToProto.toProto(plan).toByteArray(); + try { + Files.write(Paths.get(ROOT_DIR,"spark_sql_substrait.plan"), buffer); + System.out.println("File written to "+Paths.get(ROOT_DIR,"spark_sql_substrait.plan")); + + } catch (IOException e) { + e.printStackTrace(); + } + } + +} diff --git a/examples/substrait-spark/app/src/main/resources/tests_subset_2023.csv b/examples/substrait-spark/app/src/main/resources/tests_subset_2023.csv new file mode 100644 index 000000000..762d53491 --- /dev/null +++ b/examples/substrait-spark/app/src/main/resources/tests_subset_2023.csv @@ -0,0 +1,30 @@ +test_id,vehicle_id,test_date,test_class,test_type,test_result,test_mileage,postcode_area +539514409,17113014,2023-01-09,4,NT,F,69934,PA +1122718877,986649781,2023-01-16,4,NT,F,57376,SG +1104881351,424684356,2023-03-06,4,NT,F,81853,SG +1487493049,1307056703,2023-03-07,4,NT,P,20763,SA +1107861883,130747047,2023-03-27,4,RT,P,125910,SA +472789285,777757523,2023-03-29,4,NT,P,68399,CO +1105082521,840180863,2023-04-15,4,NT,P,54240,NN +1172953135,917255260,2023-04-27,4,NT,P,60918,SM +127807783,888103385,2023-05-08,4,NT,P,112090,EH +1645970709,816803134,2023-06-03,4,NT,P,134858,RG +1355347761,919820431,2023-06-21,4,NT,P,37336,ST +1750209849,544950855,2023-06-23,4,NT,F,120034,NR +1376930435,439876988,2023-07-19,4,NT,P,109927,PO +582729949,1075446447,2023-07-19,4,NT,P,72986,SA +127953451,105663799,2023-07-31,4,NT,F,35824,ME +759291679,931759350,2023-08-07,4,NT,P,65353,DY +1629819891,335780567,2023-08-08,4,NT,PRS,103365,CF +1120026477,1153361746,2023-08-11,4,NT,P,286881,RM +1331300969,644861283,2023-08-15,4,NT,P,52173,LE +990694587,449899992,2023-08-16,4,NT,F,124891,SA +193460599,759696266,2023-08-29,4,NT,P,83554,LU +1337337679,1110416764,2023-10-09,4,NT,PRS,71093,SS +1885237527,137785384,2023-11-04,4,NT,P,88730,BH +1082642803,1291985882,2023-11-15,4,NT,PRS,160717,BA +896066743,615735063,2023-11-15,4,RT,P,107710,NR +1022666841,474362449,2023-11-20,4,NT,P,56296,HP +1010400923,1203222226,2023-12-04,4,NT,F,89255,TW +866705687,605696575,2023-12-06,4,NT,P,14674,YO +621751843,72093448,2023-12-14,4,NT,F,230280,TR diff --git a/examples/substrait-spark/app/src/main/resources/vehicles_subset_2023.csv b/examples/substrait-spark/app/src/main/resources/vehicles_subset_2023.csv new file mode 100644 index 000000000..087b54c84 --- /dev/null +++ b/examples/substrait-spark/app/src/main/resources/vehicles_subset_2023.csv @@ -0,0 +1,31 @@ +vehicle_id,make,model,colour,fuel_type,cylinder_capacity,first_use_date +17113014,VAUXHALL,VIVARO,BLACK,DI,1995,2011-09-29 +986649781,VAUXHALL,INSIGNIA,WHITE,DI,1956,2017-07-19 +424684356,RENAULT,GRAND SCENIC,GREY,PE,1997,2010-07-19 +1307056703,RENAULT,CLIO,BLACK,DI,1461,2014-05-30 +130747047,FORD,FOCUS,SILVER,DI,1560,2013-07-10 +777757523,HYUNDAI,I10,WHITE,PE,998,2016-05-21 +840180863,BMW,1 SERIES,WHITE,PE,2979,2016-03-11 +917255260,VAUXHALL,ASTRA,WHITE,PE,1364,2012-04-21 +888103385,FORD,GALAXY,SILVER,DI,1997,2014-09-12 +816803134,FORD,FIESTA,BLUE,PE,1299,2002-10-24 +697184031,BMW,X1,WHITE,DI,1995,2016-03-31 +919820431,TOYOTA,AURIS,BRONZE,PE,1329,2015-06-29 +544950855,VAUXHALL,ASTRA,RED,DI,1956,2012-09-17 +439876988,MINI,MINI,GREEN,PE,1598,2010-03-31 +1075446447,CITROEN,C4,RED,DI,1560,2015-10-05 +105663799,RENAULT,KADJAR,BLACK,PE,1332,2020-07-23 +931759350,FIAT,DUCATO,WHITE,DI,2199,2008-04-18 +335780567,HYUNDAI,I20,BLUE,PE,1396,2013-08-13 +1153361746,TOYOTA,PRIUS,SILVER,HY,1800,2010-06-23 +644861283,FORD,FIESTA,BLACK,PE,998,2015-09-03 +449899992,BMW,3 SERIES,GREEN,DI,2926,2006-09-30 +759696266,CITROEN,C4,BLUE,DI,1997,2011-12-19 +1110416764,CITROEN,XSARA,SILVER,DI,1997,1999-06-30 +137785384,MINI,MINI,GREY,DI,1598,2011-11-29 +1291985882,LAND ROVER,DEFENDER,BLUE,DI,2495,2002-06-12 +615735063,VOLKSWAGEN,CADDY,WHITE,DI,1598,2013-03-01 +474362449,VAUXHALL,GRANDLAND,GREY,PE,1199,2018-11-12 +1203222226,VAUXHALL,ASTRA,BLUE,PE,1598,2010-06-03 +605696575,SUZUKI,SWIFT SZ-T DUALJET MHEV CVT,RED,HY,1197,2020-12-18 +72093448,AUDI,A4,SILVER,DI,1896,2001-03-19 diff --git a/examples/substrait-spark/build-logic/build.gradle b/examples/substrait-spark/build-logic/build.gradle new file mode 100644 index 000000000..d29beaf6e --- /dev/null +++ b/examples/substrait-spark/build-logic/build.gradle @@ -0,0 +1,16 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * This project uses @Incubating APIs which are subject to change. + */ + +plugins { + // Support convention plugins written in Groovy. Convention plugins are build scripts in 'src/main' that automatically become available as plugins in the main build. + id 'groovy-gradle-plugin' +} + +repositories { + + // Use the plugin portal to apply community plugins in convention plugins. + gradlePluginPortal() +} diff --git a/examples/substrait-spark/build-logic/settings.gradle b/examples/substrait-spark/build-logic/settings.gradle new file mode 100644 index 000000000..58fbfd5cb --- /dev/null +++ b/examples/substrait-spark/build-logic/settings.gradle @@ -0,0 +1,15 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * This settings file is used to specify which projects to include in your build-logic build. + * This project uses @Incubating APIs which are subject to change. + */ + +dependencyResolutionManagement { + // Reuse version catalog from the main build. + versionCatalogs { + create('libs', { from(files("../gradle/libs.versions.toml")) }) + } +} + +rootProject.name = 'build-logic' diff --git a/examples/substrait-spark/build-logic/src/main/groovy/buildlogic.java-application-conventions.gradle b/examples/substrait-spark/build-logic/src/main/groovy/buildlogic.java-application-conventions.gradle new file mode 100644 index 000000000..1006b9b31 --- /dev/null +++ b/examples/substrait-spark/build-logic/src/main/groovy/buildlogic.java-application-conventions.gradle @@ -0,0 +1,13 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * This project uses @Incubating APIs which are subject to change. + */ + +plugins { + // Apply the common convention plugin for shared build configuration between library and application projects. + id 'buildlogic.java-common-conventions' + + // Apply the application plugin to add support for building a CLI application in Java. + id 'application' +} diff --git a/examples/substrait-spark/build-logic/src/main/groovy/buildlogic.java-common-conventions.gradle b/examples/substrait-spark/build-logic/src/main/groovy/buildlogic.java-common-conventions.gradle new file mode 100644 index 000000000..1f605ee5f --- /dev/null +++ b/examples/substrait-spark/build-logic/src/main/groovy/buildlogic.java-common-conventions.gradle @@ -0,0 +1,39 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * This project uses @Incubating APIs which are subject to change. + */ + +plugins { + // Apply the java Plugin to add support for Java. + id 'java' +} + +repositories { + // Use Maven Central for resolving dependencies. + mavenCentral() +} + +dependencies { + constraints { + // Define dependency versions as constraints + implementation 'org.apache.commons:commons-text:1.11.0' + } +} + +testing { + suites { + // Configure the built-in test suite + test { + // Use JUnit Jupiter test framework + useJUnitJupiter('5.10.1') + } + } +} + +// Apply a specific Java toolchain to ease working on different environments. +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} diff --git a/examples/substrait-spark/build-logic/src/main/groovy/buildlogic.java-library-conventions.gradle b/examples/substrait-spark/build-logic/src/main/groovy/buildlogic.java-library-conventions.gradle new file mode 100644 index 000000000..526803e32 --- /dev/null +++ b/examples/substrait-spark/build-logic/src/main/groovy/buildlogic.java-library-conventions.gradle @@ -0,0 +1,13 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * This project uses @Incubating APIs which are subject to change. + */ + +plugins { + // Apply the common convention plugin for shared build configuration between library and application projects. + id 'buildlogic.java-common-conventions' + + // Apply the java-library plugin for API and implementation separation. + id 'java-library' +} diff --git a/examples/substrait-spark/docker-compose.yaml b/examples/substrait-spark/docker-compose.yaml new file mode 100644 index 000000000..15252983e --- /dev/null +++ b/examples/substrait-spark/docker-compose.yaml @@ -0,0 +1,32 @@ +services: + spark: + image: docker.io/bitnami/spark:3.5 + user: ":${MY_GID}" + environment: + - SPARK_MODE=master + - SPARK_RPC_AUTHENTICATION_ENABLED=no + - SPARK_RPC_ENCRYPTION_ENABLED=no + - SPARK_LOCAL_STORAGE_ENCRYPTION_ENABLED=no + - SPARK_SSL_ENABLED=no + - SPARK_USER=spark + ports: + - '8080:8080' + volumes: + - ./_apps:/opt/spark-apps + - ./_data:/opt/spark-data + spark-worker: + image: docker.io/bitnami/spark:3.5 + user: ":${MY_GID}" + environment: + - SPARK_MODE=worker + - SPARK_MASTER_URL=spark://spark:7077 + - SPARK_WORKER_MEMORY=1G + - SPARK_WORKER_CORES=1 + - SPARK_RPC_AUTHENTICATION_ENABLED=no + - SPARK_RPC_ENCRYPTION_ENABLED=no + - SPARK_LOCAL_STORAGE_ENCRYPTION_ENABLED=no + - SPARK_SSL_ENABLED=no + - SPARK_USER=spark + volumes: + - ./_apps:/opt/spark-apps + - ./_data:/opt/spark-data diff --git a/examples/substrait-spark/gradle.properties b/examples/substrait-spark/gradle.properties new file mode 100644 index 000000000..18f452c73 --- /dev/null +++ b/examples/substrait-spark/gradle.properties @@ -0,0 +1,6 @@ +# This file was generated by the Gradle 'init' task. +# https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_configuration_properties + +org.gradle.parallel=true +org.gradle.caching=true + diff --git a/examples/substrait-spark/gradle/libs.versions.toml b/examples/substrait-spark/gradle/libs.versions.toml new file mode 100644 index 000000000..8a36ae4d9 --- /dev/null +++ b/examples/substrait-spark/gradle/libs.versions.toml @@ -0,0 +1,14 @@ +# This file was generated by the Gradle 'init' task. +# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format +[versions] +spark = "3.5.1" +spotless = "6.25.0" +substrait = "0.36.0" +substrait-spark = "0.36.0" + +[libraries] +spark-core = { module = "org.apache.spark:spark-core_2.12", version.ref = "spark" } +spark-sql = { module = "org.apache.spark:spark-sql_2.12", version.ref = "spark" } +spark-hive = { module = "org.apache.spark:spark-hive_2.12", version.ref = "spark" } +substrait-spark = { module = "io.substrait:spark", version.ref = "substrait-spark" } +substrait-core = { module = "io.substrait:core", version.ref = "substrait" } diff --git a/examples/substrait-spark/gradle/wrapper/gradle-wrapper.jar b/examples/substrait-spark/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..e6441136f3d4ba8a0da8d277868979cfbc8ad796 GIT binary patch literal 43453 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 yu?e%!_ptzGB|8GrCfu%p?(_ zQccdaaVK$5bz;*rnyK{_SQYM>;aES6Qs^lj9lEs6_J+%nIiuQC*fN;z8md>r_~Mfl zU%p5Dt_YT>gQqfr@`cR!$NWr~+`CZb%dn;WtzrAOI>P_JtsB76PYe*<%H(y>qx-`Kq!X_; z<{RpAqYhE=L1r*M)gNF3B8r(<%8mo*SR2hu zccLRZwGARt)Hlo1euqTyM>^!HK*!Q2P;4UYrysje@;(<|$&%vQekbn|0Ruu_Io(w4#%p6ld2Yp7tlA`Y$cciThP zKzNGIMPXX%&Ud0uQh!uQZz|FB`4KGD?3!ND?wQt6!n*f4EmCoJUh&b?;B{|lxs#F- z31~HQ`SF4x$&v00@(P+j1pAaj5!s`)b2RDBp*PB=2IB>oBF!*6vwr7Dp%zpAx*dPr zb@Zjq^XjN?O4QcZ*O+8>)|HlrR>oD*?WQl5ri3R#2?*W6iJ>>kH%KnnME&TT@ZzrHS$Q%LC?n|e>V+D+8D zYc4)QddFz7I8#}y#Wj6>4P%34dZH~OUDb?uP%-E zwjXM(?Sg~1!|wI(RVuxbu)-rH+O=igSho_pDCw(c6b=P zKk4ATlB?bj9+HHlh<_!&z0rx13K3ZrAR8W)!@Y}o`?a*JJsD+twZIv`W)@Y?Amu_u zz``@-e2X}27$i(2=9rvIu5uTUOVhzwu%mNazS|lZb&PT;XE2|B&W1>=B58#*!~D&) zfVmJGg8UdP*fx(>Cj^?yS^zH#o-$Q-*$SnK(ZVFkw+er=>N^7!)FtP3y~Xxnu^nzY zikgB>Nj0%;WOltWIob|}%lo?_C7<``a5hEkx&1ku$|)i>Rh6@3h*`slY=9U}(Ql_< zaNG*J8vb&@zpdhAvv`?{=zDedJ23TD&Zg__snRAH4eh~^oawdYi6A3w8<Ozh@Kw)#bdktM^GVb zrG08?0bG?|NG+w^&JvD*7LAbjED{_Zkc`3H!My>0u5Q}m!+6VokMLXxl`Mkd=g&Xx z-a>m*#G3SLlhbKB!)tnzfWOBV;u;ftU}S!NdD5+YtOjLg?X}dl>7m^gOpihrf1;PY zvll&>dIuUGs{Qnd- zwIR3oIrct8Va^Tm0t#(bJD7c$Z7DO9*7NnRZorrSm`b`cxz>OIC;jSE3DO8`hX955ui`s%||YQtt2 z5DNA&pG-V+4oI2s*x^>-$6J?p=I>C|9wZF8z;VjR??Icg?1w2v5Me+FgAeGGa8(3S z4vg*$>zC-WIVZtJ7}o9{D-7d>zCe|z#<9>CFve-OPAYsneTb^JH!Enaza#j}^mXy1 z+ULn^10+rWLF6j2>Ya@@Kq?26>AqK{A_| zQKb*~F1>sE*=d?A?W7N2j?L09_7n+HGi{VY;MoTGr_)G9)ot$p!-UY5zZ2Xtbm=t z@dpPSGwgH=QtIcEulQNI>S-#ifbnO5EWkI;$A|pxJd885oM+ zGZ0_0gDvG8q2xebj+fbCHYfAXuZStH2j~|d^sBAzo46(K8n59+T6rzBwK)^rfPT+B zyIFw)9YC-V^rhtK`!3jrhmW-sTmM+tPH+;nwjL#-SjQPUZ53L@A>y*rt(#M(qsiB2 zx6B)dI}6Wlsw%bJ8h|(lhkJVogQZA&n{?Vgs6gNSXzuZpEyu*xySy8ro07QZ7Vk1!3tJphN_5V7qOiyK8p z#@jcDD8nmtYi1^l8ml;AF<#IPK?!pqf9D4moYk>d99Im}Jtwj6c#+A;f)CQ*f-hZ< z=p_T86jog%!p)D&5g9taSwYi&eP z#JuEK%+NULWus;0w32-SYFku#i}d~+{Pkho&^{;RxzP&0!RCm3-9K6`>KZpnzS6?L z^H^V*s!8<>x8bomvD%rh>Zp3>Db%kyin;qtl+jAv8Oo~1g~mqGAC&Qi_wy|xEt2iz zWAJEfTV%cl2Cs<1L&DLRVVH05EDq`pH7Oh7sR`NNkL%wi}8n>IXcO40hp+J+sC!W?!krJf!GJNE8uj zg-y~Ns-<~D?yqbzVRB}G>0A^f0!^N7l=$m0OdZuqAOQqLc zX?AEGr1Ht+inZ-Qiwnl@Z0qukd__a!C*CKuGdy5#nD7VUBM^6OCpxCa2A(X;e0&V4 zM&WR8+wErQ7UIc6LY~Q9x%Sn*Tn>>P`^t&idaOEnOd(Ufw#>NoR^1QdhJ8s`h^|R_ zXX`c5*O~Xdvh%q;7L!_!ohf$NfEBmCde|#uVZvEo>OfEq%+Ns7&_f$OR9xsihRpBb z+cjk8LyDm@U{YN>+r46?nn{7Gh(;WhFw6GAxtcKD+YWV?uge>;+q#Xx4!GpRkVZYu zzsF}1)7$?%s9g9CH=Zs+B%M_)+~*j3L0&Q9u7!|+T`^O{xE6qvAP?XWv9_MrZKdo& z%IyU)$Q95AB4!#hT!_dA>4e@zjOBD*Y=XjtMm)V|+IXzjuM;(l+8aA5#Kaz_$rR6! zj>#&^DidYD$nUY(D$mH`9eb|dtV0b{S>H6FBfq>t5`;OxA4Nn{J(+XihF(stSche7$es&~N$epi&PDM_N`As;*9D^L==2Q7Z2zD+CiU(|+-kL*VG+&9!Yb3LgPy?A zm7Z&^qRG_JIxK7-FBzZI3Q<;{`DIxtc48k> zc|0dmX;Z=W$+)qE)~`yn6MdoJ4co;%!`ddy+FV538Y)j(vg}5*k(WK)KWZ3WaOG!8 z!syGn=s{H$odtpqFrT#JGM*utN7B((abXnpDM6w56nhw}OY}0TiTG1#f*VFZr+^-g zbP10`$LPq_;PvrA1XXlyx2uM^mrjTzX}w{yuLo-cOClE8MMk47T25G8M!9Z5ypOSV zAJUBGEg5L2fY)ZGJb^E34R2zJ?}Vf>{~gB!8=5Z) z9y$>5c)=;o0HeHHSuE4U)#vG&KF|I%-cF6f$~pdYJWk_dD}iOA>iA$O$+4%@>JU08 zS`ep)$XLPJ+n0_i@PkF#ri6T8?ZeAot$6JIYHm&P6EB=BiaNY|aA$W0I+nz*zkz_z zkEru!tj!QUffq%)8y0y`T&`fuus-1p>=^hnBiBqD^hXrPs`PY9tU3m0np~rISY09> z`P3s=-kt_cYcxWd{de@}TwSqg*xVhp;E9zCsnXo6z z?f&Sv^U7n4`xr=mXle94HzOdN!2kB~4=%)u&N!+2;z6UYKUDqi-s6AZ!haB;@&B`? z_TRX0%@suz^TRdCb?!vNJYPY8L_}&07uySH9%W^Tc&1pia6y1q#?*Drf}GjGbPjBS zbOPcUY#*$3sL2x4v_i*Y=N7E$mR}J%|GUI(>WEr+28+V z%v5{#e!UF*6~G&%;l*q*$V?&r$Pp^sE^i-0$+RH3ERUUdQ0>rAq2(2QAbG}$y{de( z>{qD~GGuOk559Y@%$?N^1ApVL_a704>8OD%8Y%8B;FCt%AoPu8*D1 zLB5X>b}Syz81pn;xnB}%0FnwazlWfUV)Z-~rZg6~b z6!9J$EcE&sEbzcy?CI~=boWA&eeIa%z(7SE^qgVLz??1Vbc1*aRvc%Mri)AJaAG!p z$X!_9Ds;Zz)f+;%s&dRcJt2==P{^j3bf0M=nJd&xwUGlUFn?H=2W(*2I2Gdu zv!gYCwM10aeus)`RIZSrCK=&oKaO_Ry~D1B5!y0R=%!i2*KfXGYX&gNv_u+n9wiR5 z*e$Zjju&ODRW3phN925%S(jL+bCHv6rZtc?!*`1TyYXT6%Ju=|X;6D@lq$8T zW{Y|e39ioPez(pBH%k)HzFITXHvnD6hw^lIoUMA;qAJ^CU?top1fo@s7xT13Fvn1H z6JWa-6+FJF#x>~+A;D~;VDs26>^oH0EI`IYT2iagy23?nyJ==i{g4%HrAf1-*v zK1)~@&(KkwR7TL}L(A@C_S0G;-GMDy=MJn2$FP5s<%wC)4jC5PXoxrQBFZ_k0P{{s@sz+gX`-!=T8rcB(=7vW}^K6oLWMmp(rwDh}b zwaGGd>yEy6fHv%jM$yJXo5oMAQ>c9j`**}F?MCry;T@47@r?&sKHgVe$MCqk#Z_3S z1GZI~nOEN*P~+UaFGnj{{Jo@16`(qVNtbU>O0Hf57-P>x8Jikp=`s8xWs^dAJ9lCQ z)GFm+=OV%AMVqVATtN@|vp61VVAHRn87}%PC^RAzJ%JngmZTasWBAWsoAqBU+8L8u z4A&Pe?fmTm0?mK-BL9t+{y7o(7jm+RpOhL9KnY#E&qu^}B6=K_dB}*VlSEiC9fn)+V=J;OnN)Ta5v66ic1rG+dGAJ1 z1%Zb_+!$=tQ~lxQrzv3x#CPb?CekEkA}0MYSgx$Jdd}q8+R=ma$|&1a#)TQ=l$1tQ z=tL9&_^vJ)Pk}EDO-va`UCT1m#Uty1{v^A3P~83_#v^ozH}6*9mIjIr;t3Uv%@VeW zGL6(CwCUp)Jq%G0bIG%?{_*Y#5IHf*5M@wPo6A{$Um++Co$wLC=J1aoG93&T7Ho}P z=mGEPP7GbvoG!uD$k(H3A$Z))+i{Hy?QHdk>3xSBXR0j!11O^mEe9RHmw!pvzv?Ua~2_l2Yh~_!s1qS`|0~0)YsbHSz8!mG)WiJE| z2f($6TQtt6L_f~ApQYQKSb=`053LgrQq7G@98#igV>y#i==-nEjQ!XNu9 z~;mE+gtj4IDDNQJ~JVk5Ux6&LCSFL!y=>79kE9=V}J7tD==Ga+IW zX)r7>VZ9dY=V&}DR))xUoV!u(Z|%3ciQi_2jl}3=$Agc(`RPb z8kEBpvY>1FGQ9W$n>Cq=DIpski};nE)`p3IUw1Oz0|wxll^)4dq3;CCY@RyJgFgc# zKouFh!`?Xuo{IMz^xi-h=StCis_M7yq$u) z?XHvw*HP0VgR+KR6wI)jEMX|ssqYvSf*_3W8zVTQzD?3>H!#>InzpSO)@SC8q*ii- z%%h}_#0{4JG;Jm`4zg};BPTGkYamx$Xo#O~lBirRY)q=5M45n{GCfV7h9qwyu1NxOMoP4)jjZMxmT|IQQh0U7C$EbnMN<3)Kk?fFHYq$d|ICu>KbY_hO zTZM+uKHe(cIZfEqyzyYSUBZa8;Fcut-GN!HSA9ius`ltNebF46ZX_BbZNU}}ZOm{M2&nANL9@0qvih15(|`S~z}m&h!u4x~(%MAO$jHRWNfuxWF#B)E&g3ghSQ9|> z(MFaLQj)NE0lowyjvg8z0#m6FIuKE9lDO~Glg}nSb7`~^&#(Lw{}GVOS>U)m8bF}x zVjbXljBm34Cs-yM6TVusr+3kYFjr28STT3g056y3cH5Tmge~ASxBj z%|yb>$eF;WgrcOZf569sDZOVwoo%8>XO>XQOX1OyN9I-SQgrm;U;+#3OI(zrWyow3 zk==|{lt2xrQ%FIXOTejR>;wv(Pb8u8}BUpx?yd(Abh6? zsoO3VYWkeLnF43&@*#MQ9-i-d0t*xN-UEyNKeyNMHw|A(k(_6QKO=nKMCxD(W(Yop zsRQ)QeL4X3Lxp^L%wzi2-WVSsf61dqliPUM7srDB?Wm6Lzn0&{*}|IsKQW;02(Y&| zaTKv|`U(pSzuvR6Rduu$wzK_W-Y-7>7s?G$)U}&uK;<>vU}^^ns@Z!p+9?St1s)dG zK%y6xkPyyS1$~&6v{kl?Md6gwM|>mt6Upm>oa8RLD^8T{0?HC!Z>;(Bob7el(DV6x zi`I)$&E&ngwFS@bi4^xFLAn`=fzTC;aimE^!cMI2n@Vo%Ae-ne`RF((&5y6xsjjAZ zVguVoQ?Z9uk$2ON;ersE%PU*xGO@T*;j1BO5#TuZKEf(mB7|g7pcEA=nYJ{s3vlbg zd4-DUlD{*6o%Gc^N!Nptgay>j6E5;3psI+C3Q!1ZIbeCubW%w4pq9)MSDyB{HLm|k zxv-{$$A*pS@csolri$Ge<4VZ}e~78JOL-EVyrbxKra^d{?|NnPp86!q>t<&IP07?Z z^>~IK^k#OEKgRH+LjllZXk7iA>2cfH6+(e&9ku5poo~6y{GC5>(bRK7hwjiurqAiZ zg*DmtgY}v83IjE&AbiWgMyFbaRUPZ{lYiz$U^&Zt2YjG<%m((&_JUbZcfJ22(>bi5 z!J?<7AySj0JZ&<-qXX;mcV!f~>G=sB0KnjWca4}vrtunD^1TrpfeS^4dvFr!65knK zZh`d;*VOkPs4*-9kL>$GP0`(M!j~B;#x?Ba~&s6CopvO86oM?-? zOw#dIRc;6A6T?B`Qp%^<U5 z19x(ywSH$_N+Io!6;e?`tWaM$`=Db!gzx|lQ${DG!zb1Zl&|{kX0y6xvO1o z220r<-oaS^^R2pEyY;=Qllqpmue|5yI~D|iI!IGt@iod{Opz@*ml^w2bNs)p`M(Io z|E;;m*Xpjd9l)4G#KaWfV(t8YUn@A;nK^#xgv=LtnArX|vWQVuw3}B${h+frU2>9^ z!l6)!Uo4`5k`<<;E(ido7M6lKTgWezNLq>U*=uz&s=cc$1%>VrAeOoUtA|T6gO4>UNqsdK=NF*8|~*sl&wI=x9-EGiq*aqV!(VVXA57 zw9*o6Ir8Lj1npUXvlevtn(_+^X5rzdR>#(}4YcB9O50q97%rW2me5_L=%ffYPUSRc z!vv?Kv>dH994Qi>U(a<0KF6NH5b16enCp+mw^Hb3Xs1^tThFpz!3QuN#}KBbww`(h z7GO)1olDqy6?T$()R7y%NYx*B0k_2IBiZ14&8|JPFxeMF{vSTxF-Vi3+ZOI=Thq2} zyQgjYY1_7^ZQHh{?P))4+qUiQJLi1&{yE>h?~jU%tjdV0h|FENbM3X(KnJdPKc?~k zh=^Ixv*+smUll!DTWH!jrV*wSh*(mx0o6}1@JExzF(#9FXgmTXVoU+>kDe68N)dkQ zH#_98Zv$}lQwjKL@yBd;U(UD0UCl322=pav<=6g>03{O_3oKTq;9bLFX1ia*lw;#K zOiYDcBJf)82->83N_Y(J7Kr_3lE)hAu;)Q(nUVydv+l+nQ$?|%MWTy`t>{havFSQloHwiIkGK9YZ79^9?AZo0ZyQlVR#}lF%dn5n%xYksXf8gnBm=wO7g_^! zauQ-bH1Dc@3ItZ-9D_*pH}p!IG7j8A_o94#~>$LR|TFq zZ-b00*nuw|-5C2lJDCw&8p5N~Z1J&TrcyErds&!l3$eSz%`(*izc;-?HAFD9AHb-| z>)id`QCrzRws^9(#&=pIx9OEf2rmlob8sK&xPCWS+nD~qzU|qG6KwA{zbikcfQrdH z+ zQg>O<`K4L8rN7`GJB0*3<3`z({lWe#K!4AZLsI{%z#ja^OpfjU{!{)x0ZH~RB0W5X zTwN^w=|nA!4PEU2=LR05x~}|B&ZP?#pNgDMwD*ajI6oJqv!L81gu=KpqH22avXf0w zX3HjbCI!n9>l046)5rr5&v5ja!xkKK42zmqHzPx$9Nn_MZk`gLeSLgC=LFf;H1O#B zn=8|^1iRrujHfbgA+8i<9jaXc;CQBAmQvMGQPhFec2H1knCK2x!T`e6soyrqCamX% zTQ4dX_E*8so)E*TB$*io{$c6X)~{aWfaqdTh=xEeGvOAN9H&-t5tEE-qso<+C!2>+ zskX51H-H}#X{A75wqFe-J{?o8Bx|>fTBtl&tcbdR|132Ztqu5X0i-pisB-z8n71%q%>EF}yy5?z=Ve`}hVh{Drv1YWL zW=%ug_&chF11gDv3D6B)Tz5g54H0mDHNjuKZ+)CKFk4Z|$RD zfRuKLW`1B>B?*RUfVd0+u8h3r-{@fZ{k)c!93t1b0+Q9vOaRnEn1*IL>5Z4E4dZ!7 ztp4GP-^1d>8~LMeb}bW!(aAnB1tM_*la=Xx)q(I0Y@__Zd$!KYb8T2VBRw%e$iSdZ zkwdMwd}eV9q*;YvrBFTv1>1+}{H!JK2M*C|TNe$ZSA>UHKk);wz$(F$rXVc|sI^lD zV^?_J!3cLM;GJuBMbftbaRUs$;F}HDEDtIeHQ)^EJJ1F9FKJTGH<(Jj`phE6OuvE) zqK^K`;3S{Y#1M@8yRQwH`?kHMq4tHX#rJ>5lY3DM#o@or4&^_xtBC(|JpGTfrbGkA z2Tu+AyT^pHannww!4^!$5?@5v`LYy~T`qs7SYt$JgrY(w%C+IWA;ZkwEF)u5sDvOK zGk;G>Mh&elvXDcV69J_h02l&O;!{$({fng9Rlc3ID#tmB^FIG^w{HLUpF+iB`|
NnX)EH+Nua)3Y(c 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/examples/substrait-spark/gradlew.bat b/examples/substrait-spark/gradlew.bat new file mode 100644 index 000000000..7101f8e46 --- /dev/null +++ b/examples/substrait-spark/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/examples/substrait-spark/justfile b/examples/substrait-spark/justfile new file mode 100644 index 000000000..9a138d278 --- /dev/null +++ b/examples/substrait-spark/justfile @@ -0,0 +1,56 @@ +# Main justfile to run all the development scripts +# To install 'just' see https://github.com/casey/just#installation + +# Ensure all properties are exported as shell env-vars +set export +set dotenv-load + +# set the current directory, and the location of the test dats +CWDIR := justfile_directory() + +SPARK_VERSION := "3.5.1" + +SPARK_MASTER_CONTAINER := "subtrait-spark-spark-1" + +_default: + @just -f {{justfile()}} --list + +buildapp: + #!/bin/bash + set -e -o pipefail + + ${CWDIR}/gradlew build + + # need to let the SPARK user be able to write to the _data mount + mkdir -p ${CWDIR}/_data && chmod g+w ${CWDIR}/_data + mkdir -p ${CWDIR}/_apps + + cp ${CWDIR}/app/build/libs/app.jar ${CWDIR}/_apps + cp ${CWDIR}/app/src/main/resources/*.csv ${CWDIR}/_data + +dataset: + #!/bin/bash + set -e -o pipefail + + docker exec -it ${SPARK_MASTER_CONTAINER} bash -c "/opt/bitnami/spark/bin/spark-submit --master spark://${SPARK_MASTER_CONTAINER}:7077 --driver-memory 1G --executor-memory 1G /opt/spark-apps/app.jar SparkDataset" + +sql: + #!/bin/bash + set -e -o pipefail + + docker exec -it ${SPARK_MASTER_CONTAINER} bash -c "/opt/bitnami/spark/bin/spark-submit --master spark://${SPARK_MASTER_CONTAINER}:7077 --driver-memory 1G --executor-memory 1G /opt/spark-apps/app.jar SparkSQL" + +consume arg: + #!/bin/bash + set -e -o pipefail + + docker exec -it ${SPARK_MASTER_CONTAINER} bash -c "/opt/bitnami/spark/bin/spark-submit --master spark://${SPARK_MASTER_CONTAINER}:7077 --driver-memory 1G --executor-memory 1G /opt/spark-apps/app.jar SparkConsumeSubstrait {{arg}}" + + +spark: + #!/bin/bash + set -e -o pipefail + + export MY_UID=$(id -u) + export MY_GID=$(id -g) + docker compose up diff --git a/examples/substrait-spark/settings.gradle b/examples/substrait-spark/settings.gradle new file mode 100644 index 000000000..ed37a683e --- /dev/null +++ b/examples/substrait-spark/settings.gradle @@ -0,0 +1,20 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * The settings file is used to specify which projects to include in your build. + * For more detailed information on multi-project builds, please refer to https://docs.gradle.org/8.7/userguide/multi_project_builds.html in the Gradle documentation. + * This project uses @Incubating APIs which are subject to change. + */ + +pluginManagement { + // Include 'plugins build' to define convention plugins. + includeBuild('build-logic') +} + +plugins { + // Apply the foojay-resolver plugin to allow automatic download of JDKs + id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0' +} + +rootProject.name = 'flexdata-spark' +include('app') diff --git a/readme.md b/readme.md index 4ae53a2fc..d527584a9 100644 --- a/readme.md +++ b/readme.md @@ -33,5 +33,11 @@ SLF4J(W): Defaulting to no-operation (NOP) logger implementation SLF4J(W): See https://www.slf4j.org/codes.html#noProviders for further details. ``` +## Examples + +The [examples](./examples) folder contains examples on using Substrait with Java; please check each example for specific details of the requirements and how to run. The examples are aimed to be tested within the github workflow; depending on the setup required it might be only possible to validate compilation. + +- [Substrait-Spark](./examples/subtrait-spark/README.md) Using Substrait to produce and consume plans within Apache Spark + ## Getting Involved To learn more, head over [Substrait](https://substrait.io/), our parent project and join our [community](https://substrait.io/community/) From 0ef64e8dcd0230f26b7ecf8fb12e8c595fa2a53f Mon Sep 17 00:00:00 2001 From: MBWhite Date: Mon, 23 Sep 2024 16:08:29 +0100 Subject: [PATCH 2/8] feat: review Comments Address the review comments Also adjusted the build so it was connected to the main top level build (resulted in removing quite a bit of the gradle build that really wasn't needed here) Converted to Kotlin format (hopefully corectly) Signed-off-by: MBWhite --- .gitignore | 2 + examples/substrait-spark/.gitignore | 2 + examples/substrait-spark/README.md | 5 +- examples/substrait-spark/app/.gitignore | 2 - examples/substrait-spark/app/build.gradle | 62 ----- .../substrait-spark/build-logic/build.gradle | 16 -- .../build-logic/settings.gradle | 15 -- ...dlogic.java-application-conventions.gradle | 13 - .../buildlogic.java-common-conventions.gradle | 39 --- ...buildlogic.java-library-conventions.gradle | 13 - examples/substrait-spark/build.gradle.kts | 44 ++++ examples/substrait-spark/gradle.properties | 6 - .../substrait-spark/gradle/libs.versions.toml | 14 - .../gradle/wrapper/gradle-wrapper.jar | Bin 43453 -> 0 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 - examples/substrait-spark/gradlew | 249 ------------------ examples/substrait-spark/gradlew.bat | 92 ------- examples/substrait-spark/justfile | 8 +- examples/substrait-spark/settings.gradle | 20 -- .../main/java/io/substrait/examples/App.java | 22 +- .../examples/SparkConsumeSubstrait.java | 3 +- .../io/substrait/examples/SparkDataset.java | 17 +- .../io/substrait/examples/SparkHelper.java | 34 ++- .../java/io/substrait/examples/SparkSQL.java | 21 +- .../src/main/resources/tests_subset_2023.csv | 0 .../main/resources/vehicles_subset_2023.csv | 0 settings.gradle.kts | 2 +- 27 files changed, 117 insertions(+), 591 deletions(-) delete mode 100644 examples/substrait-spark/app/.gitignore delete mode 100644 examples/substrait-spark/app/build.gradle delete mode 100644 examples/substrait-spark/build-logic/build.gradle delete mode 100644 examples/substrait-spark/build-logic/settings.gradle delete mode 100644 examples/substrait-spark/build-logic/src/main/groovy/buildlogic.java-application-conventions.gradle delete mode 100644 examples/substrait-spark/build-logic/src/main/groovy/buildlogic.java-common-conventions.gradle delete mode 100644 examples/substrait-spark/build-logic/src/main/groovy/buildlogic.java-library-conventions.gradle create mode 100644 examples/substrait-spark/build.gradle.kts delete mode 100644 examples/substrait-spark/gradle.properties delete mode 100644 examples/substrait-spark/gradle/libs.versions.toml delete mode 100644 examples/substrait-spark/gradle/wrapper/gradle-wrapper.jar delete mode 100644 examples/substrait-spark/gradle/wrapper/gradle-wrapper.properties delete mode 100755 examples/substrait-spark/gradlew delete mode 100644 examples/substrait-spark/gradlew.bat delete mode 100644 examples/substrait-spark/settings.gradle rename examples/substrait-spark/{app => }/src/main/java/io/substrait/examples/App.java (69%) rename examples/substrait-spark/{app => }/src/main/java/io/substrait/examples/SparkConsumeSubstrait.java (97%) rename examples/substrait-spark/{app => }/src/main/java/io/substrait/examples/SparkDataset.java (85%) rename examples/substrait-spark/{app => }/src/main/java/io/substrait/examples/SparkHelper.java (59%) rename examples/substrait-spark/{app => }/src/main/java/io/substrait/examples/SparkSQL.java (85%) rename examples/substrait-spark/{app => }/src/main/resources/tests_subset_2023.csv (100%) rename examples/substrait-spark/{app => }/src/main/resources/vehicles_subset_2023.csv (100%) diff --git a/.gitignore b/.gitignore index c84c103c5..50c4f73e5 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ out/** *.iws .vscode .pmdCache + +*/bin diff --git a/examples/substrait-spark/.gitignore b/examples/substrait-spark/.gitignore index 8965a89f4..a6765eee4 100644 --- a/examples/substrait-spark/.gitignore +++ b/examples/substrait-spark/.gitignore @@ -1,2 +1,4 @@ +spark-warehouse +derby.log _apps _data diff --git a/examples/substrait-spark/README.md b/examples/substrait-spark/README.md index 97a53b707..a8079e2cd 100644 --- a/examples/substrait-spark/README.md +++ b/examples/substrait-spark/README.md @@ -1,7 +1,6 @@ # Introduction to the Substrait-Spark library -The Substrait-Spark library was recently added to the [substrait-java](https://github.com/substrait-io/substrait-java) project; this library allows Substrait plans to convert to and from Spark Plans. - +The Substrait-Spark library allows Substrait plans to convert to and from Spark Plans. This example will show how this can be used. ## How does this work in practice? @@ -27,7 +26,7 @@ To run these you will need: - Java 17 or greater - Docker to start a test Spark Cluster - you could use your own cluster, but would need to adjust file locations defined in [SparkHelper](./app/src/main/java/io/substrait/examples/SparkHelper.java) -- [just task runner](https://github.com/casey/just#installation) optional, but very helpful to run the bash commands +- The [just task runner](https://github.com/casey/just#installation) is optional, but very helpful to run the bash commands - [Two datafiles](./app/src/main/resources/) are provided (CSV format) For building using the `substrait-spark` library youself, using the [mvn repository](https://mvnrepository.com/artifact/io.substrait/spark) diff --git a/examples/substrait-spark/app/.gitignore b/examples/substrait-spark/app/.gitignore deleted file mode 100644 index 2ee4e319c..000000000 --- a/examples/substrait-spark/app/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -spark-warehouse -derby.log diff --git a/examples/substrait-spark/app/build.gradle b/examples/substrait-spark/app/build.gradle deleted file mode 100644 index cb2710b8b..000000000 --- a/examples/substrait-spark/app/build.gradle +++ /dev/null @@ -1,62 +0,0 @@ -/* - * This file was generated by the Gradle 'init' task. - * - * This project uses @Incubating APIs which are subject to change. - */ - -plugins { - id 'buildlogic.java-application-conventions' -} - -dependencies { - implementation 'org.apache.commons:commons-text' - // for running as a Spark application for real, this could be compile-only - - - implementation libs.substrait.core - implementation libs.substrait.spark - implementation libs.spark.sql - - // For a real Spark application, these would not be required since they would be in the Spark server classpath - runtimeOnly libs.spark.core -// https://mvnrepository.com/artifact/org.apache.spark/spark-hive - runtimeOnly libs.spark.hive - - - -} - -def jvmArguments = [ - "--add-exports", - "java.base/sun.nio.ch=ALL-UNNAMED", - "--add-opens=java.base/java.net=ALL-UNNAMED", - "--add-opens=java.base/java.nio=ALL-UNNAMED", - "-Dspark.master=local" -] - -application { - // Define the main class for the application. - mainClass = 'io.substrait.examples.App' - applicationDefaultJvmArgs = jvmArguments -} - -jar { - zip64 = true - duplicatesStrategy = DuplicatesStrategy.EXCLUDE - - manifest { - attributes 'Main-Class': 'io.substrait.examples.App' - } - - from { - configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } - } - - exclude 'META-INF/*.RSA' - exclude 'META-INF/*.SF' - exclude 'META-INF/*.DSA' -} - -repositories { - -} diff --git a/examples/substrait-spark/build-logic/build.gradle b/examples/substrait-spark/build-logic/build.gradle deleted file mode 100644 index d29beaf6e..000000000 --- a/examples/substrait-spark/build-logic/build.gradle +++ /dev/null @@ -1,16 +0,0 @@ -/* - * This file was generated by the Gradle 'init' task. - * - * This project uses @Incubating APIs which are subject to change. - */ - -plugins { - // Support convention plugins written in Groovy. Convention plugins are build scripts in 'src/main' that automatically become available as plugins in the main build. - id 'groovy-gradle-plugin' -} - -repositories { - - // Use the plugin portal to apply community plugins in convention plugins. - gradlePluginPortal() -} diff --git a/examples/substrait-spark/build-logic/settings.gradle b/examples/substrait-spark/build-logic/settings.gradle deleted file mode 100644 index 58fbfd5cb..000000000 --- a/examples/substrait-spark/build-logic/settings.gradle +++ /dev/null @@ -1,15 +0,0 @@ -/* - * This file was generated by the Gradle 'init' task. - * - * This settings file is used to specify which projects to include in your build-logic build. - * This project uses @Incubating APIs which are subject to change. - */ - -dependencyResolutionManagement { - // Reuse version catalog from the main build. - versionCatalogs { - create('libs', { from(files("../gradle/libs.versions.toml")) }) - } -} - -rootProject.name = 'build-logic' diff --git a/examples/substrait-spark/build-logic/src/main/groovy/buildlogic.java-application-conventions.gradle b/examples/substrait-spark/build-logic/src/main/groovy/buildlogic.java-application-conventions.gradle deleted file mode 100644 index 1006b9b31..000000000 --- a/examples/substrait-spark/build-logic/src/main/groovy/buildlogic.java-application-conventions.gradle +++ /dev/null @@ -1,13 +0,0 @@ -/* - * This file was generated by the Gradle 'init' task. - * - * This project uses @Incubating APIs which are subject to change. - */ - -plugins { - // Apply the common convention plugin for shared build configuration between library and application projects. - id 'buildlogic.java-common-conventions' - - // Apply the application plugin to add support for building a CLI application in Java. - id 'application' -} diff --git a/examples/substrait-spark/build-logic/src/main/groovy/buildlogic.java-common-conventions.gradle b/examples/substrait-spark/build-logic/src/main/groovy/buildlogic.java-common-conventions.gradle deleted file mode 100644 index 1f605ee5f..000000000 --- a/examples/substrait-spark/build-logic/src/main/groovy/buildlogic.java-common-conventions.gradle +++ /dev/null @@ -1,39 +0,0 @@ -/* - * This file was generated by the Gradle 'init' task. - * - * This project uses @Incubating APIs which are subject to change. - */ - -plugins { - // Apply the java Plugin to add support for Java. - id 'java' -} - -repositories { - // Use Maven Central for resolving dependencies. - mavenCentral() -} - -dependencies { - constraints { - // Define dependency versions as constraints - implementation 'org.apache.commons:commons-text:1.11.0' - } -} - -testing { - suites { - // Configure the built-in test suite - test { - // Use JUnit Jupiter test framework - useJUnitJupiter('5.10.1') - } - } -} - -// Apply a specific Java toolchain to ease working on different environments. -java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } -} diff --git a/examples/substrait-spark/build-logic/src/main/groovy/buildlogic.java-library-conventions.gradle b/examples/substrait-spark/build-logic/src/main/groovy/buildlogic.java-library-conventions.gradle deleted file mode 100644 index 526803e32..000000000 --- a/examples/substrait-spark/build-logic/src/main/groovy/buildlogic.java-library-conventions.gradle +++ /dev/null @@ -1,13 +0,0 @@ -/* - * This file was generated by the Gradle 'init' task. - * - * This project uses @Incubating APIs which are subject to change. - */ - -plugins { - // Apply the common convention plugin for shared build configuration between library and application projects. - id 'buildlogic.java-common-conventions' - - // Apply the java-library plugin for API and implementation separation. - id 'java-library' -} diff --git a/examples/substrait-spark/build.gradle.kts b/examples/substrait-spark/build.gradle.kts new file mode 100644 index 000000000..64533f02d --- /dev/null +++ b/examples/substrait-spark/build.gradle.kts @@ -0,0 +1,44 @@ + +plugins { + // Apply the application plugin to add support for building a CLI application in Java. + id("java") +} + +repositories { + // Use Maven Central for resolving dependencies. + mavenCentral() +} + +dependencies { + implementation("org.apache.spark:spark-core_2.12:3.5.1") + implementation("io.substrait:spark:0.36.0") + implementation("io.substrait:core:0.36.0") + implementation("org.apache.spark:spark-sql_2.12:3.5.1") + + // For a real Spark application, these would not be required since they would be in the Spark server classpath + runtimeOnly("org.apache.spark:spark-core_2.12:3.5.1") + runtimeOnly("org.apache.spark:spark-hive_2.12:3.5.1") + +} + +tasks.jar { + isZip64 = true + exclude ("META-INF/*.RSA") + exclude ("META-INF/*.SF") + exclude ("META-INF/*.DSA") + + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + manifest.attributes["Main-Class"] = "io.substrait.examples.App" + from(configurations.runtimeClasspath.get().map({ if (it.isDirectory) it else zipTree(it) })) + +} + +tasks.named("test") { + // Use JUnit Platform for unit tests. + useJUnitPlatform() +} + +java { + toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } +} + diff --git a/examples/substrait-spark/gradle.properties b/examples/substrait-spark/gradle.properties deleted file mode 100644 index 18f452c73..000000000 --- a/examples/substrait-spark/gradle.properties +++ /dev/null @@ -1,6 +0,0 @@ -# This file was generated by the Gradle 'init' task. -# https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_configuration_properties - -org.gradle.parallel=true -org.gradle.caching=true - diff --git a/examples/substrait-spark/gradle/libs.versions.toml b/examples/substrait-spark/gradle/libs.versions.toml deleted file mode 100644 index 8a36ae4d9..000000000 --- a/examples/substrait-spark/gradle/libs.versions.toml +++ /dev/null @@ -1,14 +0,0 @@ -# This file was generated by the Gradle 'init' task. -# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format -[versions] -spark = "3.5.1" -spotless = "6.25.0" -substrait = "0.36.0" -substrait-spark = "0.36.0" - -[libraries] -spark-core = { module = "org.apache.spark:spark-core_2.12", version.ref = "spark" } -spark-sql = { module = "org.apache.spark:spark-sql_2.12", version.ref = "spark" } -spark-hive = { module = "org.apache.spark:spark-hive_2.12", version.ref = "spark" } -substrait-spark = { module = "io.substrait:spark", version.ref = "substrait-spark" } -substrait-core = { module = "io.substrait:core", version.ref = "substrait" } diff --git a/examples/substrait-spark/gradle/wrapper/gradle-wrapper.jar b/examples/substrait-spark/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index e6441136f3d4ba8a0da8d277868979cfbc8ad796..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 43453 zcma&N1CXTcmMvW9vTb(Rwr$&4wr$(C?dmSu>@vG-+vuvg^_??!{yS%8zW-#zn-LkA z5&1^$^{lnmUON?}LBF8_K|(?T0Ra(xUH{($5eN!MR#ZihR#HxkUPe+_R8Cn`RRs(P z_^*#_XlXmGv7!4;*Y%p4nw?{bNp@UZHv1?Um8r6)Fei3p@ClJn0ECfg1hkeuUU@Or zDaPa;U3fE=3L}DooL;8f;P0ipPt0Z~9P0)lbStMS)ag54=uL9ia-Lm3nh|@(Y?B`; zx_#arJIpXH!U{fbCbI^17}6Ri*H<>OLR%c|^mh8+)*h~K8Z!9)DPf zR2h?lbDZQ`p9P;&DQ4F0sur@TMa!Y}S8irn(%d-gi0*WxxCSk*A?3lGh=gcYN?FGl z7D=Js!i~0=u3rox^eO3i@$0=n{K1lPNU zwmfjRVmLOCRfe=seV&P*1Iq=^i`502keY8Uy-WNPwVNNtJFx?IwAyRPZo2Wo1+S(xF37LJZ~%i)kpFQ3Fw=mXfd@>%+)RpYQLnr}B~~zoof(JVm^^&f zxKV^+3D3$A1G;qh4gPVjhrC8e(VYUHv#dy^)(RoUFM?o%W-EHxufuWf(l*@-l+7vt z=l`qmR56K~F|v<^Pd*p~1_y^P0P^aPC##d8+HqX4IR1gu+7w#~TBFphJxF)T$2WEa zxa?H&6=Qe7d(#tha?_1uQys2KtHQ{)Qco)qwGjrdNL7thd^G5i8Os)CHqc>iOidS} z%nFEDdm=GXBw=yXe1W-ShHHFb?Cc70+$W~z_+}nAoHFYI1MV1wZegw*0y^tC*s%3h zhD3tN8b=Gv&rj}!SUM6|ajSPp*58KR7MPpI{oAJCtY~JECm)*m_x>AZEu>DFgUcby z1Qaw8lU4jZpQ_$;*7RME+gq1KySGG#Wql>aL~k9tLrSO()LWn*q&YxHEuzmwd1?aAtI zBJ>P=&$=l1efe1CDU;`Fd+_;&wI07?V0aAIgc(!{a z0Jg6Y=inXc3^n!U0Atk`iCFIQooHqcWhO(qrieUOW8X(x?(RD}iYDLMjSwffH2~tB z)oDgNBLB^AJBM1M^c5HdRx6fBfka`(LD-qrlh5jqH~);#nw|iyp)()xVYak3;Ybik z0j`(+69aK*B>)e_p%=wu8XC&9e{AO4c~O1U`5X9}?0mrd*m$_EUek{R?DNSh(=br# z#Q61gBzEpmy`$pA*6!87 zSDD+=@fTY7<4A?GLqpA?Pb2z$pbCc4B4zL{BeZ?F-8`s$?>*lXXtn*NC61>|*w7J* z$?!iB{6R-0=KFmyp1nnEmLsA-H0a6l+1uaH^g%c(p{iT&YFrbQ$&PRb8Up#X3@Zsk zD^^&LK~111%cqlP%!_gFNa^dTYT?rhkGl}5=fL{a`UViaXWI$k-UcHJwmaH1s=S$4 z%4)PdWJX;hh5UoK?6aWoyLxX&NhNRqKam7tcOkLh{%j3K^4Mgx1@i|Pi&}<^5>hs5 zm8?uOS>%)NzT(%PjVPGa?X%`N2TQCKbeH2l;cTnHiHppPSJ<7y-yEIiC!P*ikl&!B z%+?>VttCOQM@ShFguHVjxX^?mHX^hSaO_;pnyh^v9EumqSZTi+#f&_Vaija0Q-e*| z7ulQj6Fs*bbmsWp{`auM04gGwsYYdNNZcg|ph0OgD>7O}Asn7^Z=eI>`$2*v78;sj-}oMoEj&@)9+ycEOo92xSyY344^ z11Hb8^kdOvbf^GNAK++bYioknrpdN>+u8R?JxG=!2Kd9r=YWCOJYXYuM0cOq^FhEd zBg2puKy__7VT3-r*dG4c62Wgxi52EMCQ`bKgf*#*ou(D4-ZN$+mg&7$u!! z-^+Z%;-3IDwqZ|K=ah85OLwkO zKxNBh+4QHh)u9D?MFtpbl)us}9+V!D%w9jfAMYEb>%$A;u)rrI zuBudh;5PN}_6J_}l55P3l_)&RMlH{m!)ai-i$g)&*M`eN$XQMw{v^r@-125^RRCF0 z^2>|DxhQw(mtNEI2Kj(;KblC7x=JlK$@78`O~>V!`|1Lm-^JR$-5pUANAnb(5}B}JGjBsliK4& zk6y(;$e&h)lh2)L=bvZKbvh@>vLlreBdH8No2>$#%_Wp1U0N7Ank!6$dFSi#xzh|( zRi{Uw%-4W!{IXZ)fWx@XX6;&(m_F%c6~X8hx=BN1&q}*( zoaNjWabE{oUPb!Bt$eyd#$5j9rItB-h*5JiNi(v^e|XKAj*8(k<5-2$&ZBR5fF|JA z9&m4fbzNQnAU}r8ab>fFV%J0z5awe#UZ|bz?Ur)U9bCIKWEzi2%A+5CLqh?}K4JHi z4vtM;+uPsVz{Lfr;78W78gC;z*yTch~4YkLr&m-7%-xc ztw6Mh2d>_iO*$Rd8(-Cr1_V8EO1f*^@wRoSozS) zy1UoC@pruAaC8Z_7~_w4Q6n*&B0AjOmMWa;sIav&gu z|J5&|{=a@vR!~k-OjKEgPFCzcJ>#A1uL&7xTDn;{XBdeM}V=l3B8fE1--DHjSaxoSjNKEM9|U9#m2<3>n{Iuo`r3UZp;>GkT2YBNAh|b z^jTq-hJp(ebZh#Lk8hVBP%qXwv-@vbvoREX$TqRGTgEi$%_F9tZES@z8Bx}$#5eeG zk^UsLBH{bc2VBW)*EdS({yw=?qmevwi?BL6*=12k9zM5gJv1>y#ML4!)iiPzVaH9% zgSImetD@dam~e>{LvVh!phhzpW+iFvWpGT#CVE5TQ40n%F|p(sP5mXxna+Ev7PDwA zamaV4m*^~*xV+&p;W749xhb_X=$|LD;FHuB&JL5?*Y2-oIT(wYY2;73<^#46S~Gx| z^cez%V7x$81}UWqS13Gz80379Rj;6~WdiXWOSsdmzY39L;Hg3MH43o*y8ibNBBH`(av4|u;YPq%{R;IuYow<+GEsf@R?=@tT@!}?#>zIIn0CoyV!hq3mw zHj>OOjfJM3F{RG#6ujzo?y32m^tgSXf@v=J$ELdJ+=5j|=F-~hP$G&}tDZsZE?5rX ztGj`!S>)CFmdkccxM9eGIcGnS2AfK#gXwj%esuIBNJQP1WV~b~+D7PJTmWGTSDrR` zEAu4B8l>NPuhsk5a`rReSya2nfV1EK01+G!x8aBdTs3Io$u5!6n6KX%uv@DxAp3F@{4UYg4SWJtQ-W~0MDb|j-$lwVn znAm*Pl!?Ps&3wO=R115RWKb*JKoexo*)uhhHBncEDMSVa_PyA>k{Zm2(wMQ(5NM3# z)jkza|GoWEQo4^s*wE(gHz?Xsg4`}HUAcs42cM1-qq_=+=!Gk^y710j=66(cSWqUe zklbm8+zB_syQv5A2rj!Vbw8;|$@C!vfNmNV!yJIWDQ>{+2x zKjuFX`~~HKG~^6h5FntRpnnHt=D&rq0>IJ9#F0eM)Y-)GpRjiN7gkA8wvnG#K=q{q z9dBn8_~wm4J<3J_vl|9H{7q6u2A!cW{bp#r*-f{gOV^e=8S{nc1DxMHFwuM$;aVI^ zz6A*}m8N-&x8;aunp1w7_vtB*pa+OYBw=TMc6QK=mbA-|Cf* zvyh8D4LRJImooUaSb7t*fVfih<97Gf@VE0|z>NcBwBQze);Rh!k3K_sfunToZY;f2 z^HmC4KjHRVg+eKYj;PRN^|E0>Gj_zagfRbrki68I^#~6-HaHg3BUW%+clM1xQEdPYt_g<2K+z!$>*$9nQ>; zf9Bei{?zY^-e{q_*|W#2rJG`2fy@{%6u0i_VEWTq$*(ZN37|8lFFFt)nCG({r!q#9 z5VK_kkSJ3?zOH)OezMT{!YkCuSSn!K#-Rhl$uUM(bq*jY? zi1xbMVthJ`E>d>(f3)~fozjg^@eheMF6<)I`oeJYx4*+M&%c9VArn(OM-wp%M<-`x z7sLP1&3^%Nld9Dhm@$3f2}87!quhI@nwd@3~fZl_3LYW-B?Ia>ui`ELg z&Qfe!7m6ze=mZ`Ia9$z|ARSw|IdMpooY4YiPN8K z4B(ts3p%2i(Td=tgEHX z0UQ_>URBtG+-?0E;E7Ld^dyZ;jjw0}XZ(}-QzC6+NN=40oDb2^v!L1g9xRvE#@IBR zO!b-2N7wVfLV;mhEaXQ9XAU+>=XVA6f&T4Z-@AX!leJ8obP^P^wP0aICND?~w&NykJ#54x3_@r7IDMdRNy4Hh;h*!u(Ol(#0bJdwEo$5437-UBjQ+j=Ic>Q2z` zJNDf0yO6@mr6y1#n3)s(W|$iE_i8r@Gd@!DWDqZ7J&~gAm1#~maIGJ1sls^gxL9LLG_NhU!pTGty!TbhzQnu)I*S^54U6Yu%ZeCg`R>Q zhBv$n5j0v%O_j{QYWG!R9W?5_b&67KB$t}&e2LdMvd(PxN6Ir!H4>PNlerpBL>Zvyy!yw z-SOo8caEpDt(}|gKPBd$qND5#a5nju^O>V&;f890?yEOfkSG^HQVmEbM3Ugzu+UtH zC(INPDdraBN?P%kE;*Ae%Wto&sgw(crfZ#Qy(<4nk;S|hD3j{IQRI6Yq|f^basLY; z-HB&Je%Gg}Jt@={_C{L$!RM;$$|iD6vu#3w?v?*;&()uB|I-XqEKqZPS!reW9JkLewLb!70T7n`i!gNtb1%vN- zySZj{8-1>6E%H&=V}LM#xmt`J3XQoaD|@XygXjdZ1+P77-=;=eYpoEQ01B@L*a(uW zrZeZz?HJsw_4g0vhUgkg@VF8<-X$B8pOqCuWAl28uB|@r`19DTUQQsb^pfqB6QtiT z*`_UZ`fT}vtUY#%sq2{rchyfu*pCg;uec2$-$N_xgjZcoumE5vSI{+s@iLWoz^Mf; zuI8kDP{!XY6OP~q5}%1&L}CtfH^N<3o4L@J@zg1-mt{9L`s^z$Vgb|mr{@WiwAqKg zp#t-lhrU>F8o0s1q_9y`gQNf~Vb!F%70f}$>i7o4ho$`uciNf=xgJ>&!gSt0g;M>*x4-`U)ysFW&Vs^Vk6m%?iuWU+o&m(2Jm26Y(3%TL; zA7T)BP{WS!&xmxNw%J=$MPfn(9*^*TV;$JwRy8Zl*yUZi8jWYF>==j~&S|Xinsb%c z2?B+kpet*muEW7@AzjBA^wAJBY8i|#C{WtO_or&Nj2{=6JTTX05}|H>N2B|Wf!*3_ z7hW*j6p3TvpghEc6-wufFiY!%-GvOx*bZrhZu+7?iSrZL5q9}igiF^*R3%DE4aCHZ zqu>xS8LkW+Auv%z-<1Xs92u23R$nk@Pk}MU5!gT|c7vGlEA%G^2th&Q*zfg%-D^=f z&J_}jskj|Q;73NP4<4k*Y%pXPU2Thoqr+5uH1yEYM|VtBPW6lXaetokD0u z9qVek6Q&wk)tFbQ8(^HGf3Wp16gKmr>G;#G(HRBx?F`9AIRboK+;OfHaLJ(P>IP0w zyTbTkx_THEOs%Q&aPrxbZrJlio+hCC_HK<4%f3ZoSAyG7Dn`=X=&h@m*|UYO-4Hq0 z-Bq&+Ie!S##4A6OGoC~>ZW`Y5J)*ouaFl_e9GA*VSL!O_@xGiBw!AF}1{tB)z(w%c zS1Hmrb9OC8>0a_$BzeiN?rkPLc9%&;1CZW*4}CDDNr2gcl_3z+WC15&H1Zc2{o~i) z)LLW=WQ{?ricmC`G1GfJ0Yp4Dy~Ba;j6ZV4r{8xRs`13{dD!xXmr^Aga|C=iSmor% z8hi|pTXH)5Yf&v~exp3o+sY4B^^b*eYkkCYl*T{*=-0HniSA_1F53eCb{x~1k3*`W zr~};p1A`k{1DV9=UPnLDgz{aJH=-LQo<5%+Em!DNN252xwIf*wF_zS^!(XSm(9eoj z=*dXG&n0>)_)N5oc6v!>-bd(2ragD8O=M|wGW z!xJQS<)u70m&6OmrF0WSsr@I%T*c#Qo#Ha4d3COcX+9}hM5!7JIGF>7<~C(Ear^Sn zm^ZFkV6~Ula6+8S?oOROOA6$C&q&dp`>oR-2Ym3(HT@O7Sd5c~+kjrmM)YmgPH*tL zX+znN>`tv;5eOfX?h{AuX^LK~V#gPCu=)Tigtq9&?7Xh$qN|%A$?V*v=&-2F$zTUv z`C#WyIrChS5|Kgm_GeudCFf;)!WH7FI60j^0o#65o6`w*S7R@)88n$1nrgU(oU0M9 zx+EuMkC>(4j1;m6NoGqEkpJYJ?vc|B zOlwT3t&UgL!pX_P*6g36`ZXQ; z9~Cv}ANFnJGp(;ZhS(@FT;3e)0)Kp;h^x;$*xZn*k0U6-&FwI=uOGaODdrsp-!K$Ac32^c{+FhI-HkYd5v=`PGsg%6I`4d9Jy)uW0y%) zm&j^9WBAp*P8#kGJUhB!L?a%h$hJgQrx!6KCB_TRo%9{t0J7KW8!o1B!NC)VGLM5! zpZy5Jc{`r{1e(jd%jsG7k%I+m#CGS*BPA65ZVW~fLYw0dA-H_}O zrkGFL&P1PG9p2(%QiEWm6x;U-U&I#;Em$nx-_I^wtgw3xUPVVu zqSuKnx&dIT-XT+T10p;yjo1Y)z(x1fb8Dzfn8e yu?e%!_ptzGB|8GrCfu%p?(_ zQccdaaVK$5bz;*rnyK{_SQYM>;aES6Qs^lj9lEs6_J+%nIiuQC*fN;z8md>r_~Mfl zU%p5Dt_YT>gQqfr@`cR!$NWr~+`CZb%dn;WtzrAOI>P_JtsB76PYe*<%H(y>qx-`Kq!X_; z<{RpAqYhE=L1r*M)gNF3B8r(<%8mo*SR2hu zccLRZwGARt)Hlo1euqTyM>^!HK*!Q2P;4UYrysje@;(<|$&%vQekbn|0Ruu_Io(w4#%p6ld2Yp7tlA`Y$cciThP zKzNGIMPXX%&Ud0uQh!uQZz|FB`4KGD?3!ND?wQt6!n*f4EmCoJUh&b?;B{|lxs#F- z31~HQ`SF4x$&v00@(P+j1pAaj5!s`)b2RDBp*PB=2IB>oBF!*6vwr7Dp%zpAx*dPr zb@Zjq^XjN?O4QcZ*O+8>)|HlrR>oD*?WQl5ri3R#2?*W6iJ>>kH%KnnME&TT@ZzrHS$Q%LC?n|e>V+D+8D zYc4)QddFz7I8#}y#Wj6>4P%34dZH~OUDb?uP%-E zwjXM(?Sg~1!|wI(RVuxbu)-rH+O=igSho_pDCw(c6b=P zKk4ATlB?bj9+HHlh<_!&z0rx13K3ZrAR8W)!@Y}o`?a*JJsD+twZIv`W)@Y?Amu_u zz``@-e2X}27$i(2=9rvIu5uTUOVhzwu%mNazS|lZb&PT;XE2|B&W1>=B58#*!~D&) zfVmJGg8UdP*fx(>Cj^?yS^zH#o-$Q-*$SnK(ZVFkw+er=>N^7!)FtP3y~Xxnu^nzY zikgB>Nj0%;WOltWIob|}%lo?_C7<``a5hEkx&1ku$|)i>Rh6@3h*`slY=9U}(Ql_< zaNG*J8vb&@zpdhAvv`?{=zDedJ23TD&Zg__snRAH4eh~^oawdYi6A3w8<Ozh@Kw)#bdktM^GVb zrG08?0bG?|NG+w^&JvD*7LAbjED{_Zkc`3H!My>0u5Q}m!+6VokMLXxl`Mkd=g&Xx z-a>m*#G3SLlhbKB!)tnzfWOBV;u;ftU}S!NdD5+YtOjLg?X}dl>7m^gOpihrf1;PY zvll&>dIuUGs{Qnd- zwIR3oIrct8Va^Tm0t#(bJD7c$Z7DO9*7NnRZorrSm`b`cxz>OIC;jSE3DO8`hX955ui`s%||YQtt2 z5DNA&pG-V+4oI2s*x^>-$6J?p=I>C|9wZF8z;VjR??Icg?1w2v5Me+FgAeGGa8(3S z4vg*$>zC-WIVZtJ7}o9{D-7d>zCe|z#<9>CFve-OPAYsneTb^JH!Enaza#j}^mXy1 z+ULn^10+rWLF6j2>Ya@@Kq?26>AqK{A_| zQKb*~F1>sE*=d?A?W7N2j?L09_7n+HGi{VY;MoTGr_)G9)ot$p!-UY5zZ2Xtbm=t z@dpPSGwgH=QtIcEulQNI>S-#ifbnO5EWkI;$A|pxJd885oM+ zGZ0_0gDvG8q2xebj+fbCHYfAXuZStH2j~|d^sBAzo46(K8n59+T6rzBwK)^rfPT+B zyIFw)9YC-V^rhtK`!3jrhmW-sTmM+tPH+;nwjL#-SjQPUZ53L@A>y*rt(#M(qsiB2 zx6B)dI}6Wlsw%bJ8h|(lhkJVogQZA&n{?Vgs6gNSXzuZpEyu*xySy8ro07QZ7Vk1!3tJphN_5V7qOiyK8p z#@jcDD8nmtYi1^l8ml;AF<#IPK?!pqf9D4moYk>d99Im}Jtwj6c#+A;f)CQ*f-hZ< z=p_T86jog%!p)D&5g9taSwYi&eP z#JuEK%+NULWus;0w32-SYFku#i}d~+{Pkho&^{;RxzP&0!RCm3-9K6`>KZpnzS6?L z^H^V*s!8<>x8bomvD%rh>Zp3>Db%kyin;qtl+jAv8Oo~1g~mqGAC&Qi_wy|xEt2iz zWAJEfTV%cl2Cs<1L&DLRVVH05EDq`pH7Oh7sR`NNkL%wi}8n>IXcO40hp+J+sC!W?!krJf!GJNE8uj zg-y~Ns-<~D?yqbzVRB}G>0A^f0!^N7l=$m0OdZuqAOQqLc zX?AEGr1Ht+inZ-Qiwnl@Z0qukd__a!C*CKuGdy5#nD7VUBM^6OCpxCa2A(X;e0&V4 zM&WR8+wErQ7UIc6LY~Q9x%Sn*Tn>>P`^t&idaOEnOd(Ufw#>NoR^1QdhJ8s`h^|R_ zXX`c5*O~Xdvh%q;7L!_!ohf$NfEBmCde|#uVZvEo>OfEq%+Ns7&_f$OR9xsihRpBb z+cjk8LyDm@U{YN>+r46?nn{7Gh(;WhFw6GAxtcKD+YWV?uge>;+q#Xx4!GpRkVZYu zzsF}1)7$?%s9g9CH=Zs+B%M_)+~*j3L0&Q9u7!|+T`^O{xE6qvAP?XWv9_MrZKdo& z%IyU)$Q95AB4!#hT!_dA>4e@zjOBD*Y=XjtMm)V|+IXzjuM;(l+8aA5#Kaz_$rR6! zj>#&^DidYD$nUY(D$mH`9eb|dtV0b{S>H6FBfq>t5`;OxA4Nn{J(+XihF(stSche7$es&~N$epi&PDM_N`As;*9D^L==2Q7Z2zD+CiU(|+-kL*VG+&9!Yb3LgPy?A zm7Z&^qRG_JIxK7-FBzZI3Q<;{`DIxtc48k> zc|0dmX;Z=W$+)qE)~`yn6MdoJ4co;%!`ddy+FV538Y)j(vg}5*k(WK)KWZ3WaOG!8 z!syGn=s{H$odtpqFrT#JGM*utN7B((abXnpDM6w56nhw}OY}0TiTG1#f*VFZr+^-g zbP10`$LPq_;PvrA1XXlyx2uM^mrjTzX}w{yuLo-cOClE8MMk47T25G8M!9Z5ypOSV zAJUBGEg5L2fY)ZGJb^E34R2zJ?}Vf>{~gB!8=5Z) z9y$>5c)=;o0HeHHSuE4U)#vG&KF|I%-cF6f$~pdYJWk_dD}iOA>iA$O$+4%@>JU08 zS`ep)$XLPJ+n0_i@PkF#ri6T8?ZeAot$6JIYHm&P6EB=BiaNY|aA$W0I+nz*zkz_z zkEru!tj!QUffq%)8y0y`T&`fuus-1p>=^hnBiBqD^hXrPs`PY9tU3m0np~rISY09> z`P3s=-kt_cYcxWd{de@}TwSqg*xVhp;E9zCsnXo6z z?f&Sv^U7n4`xr=mXle94HzOdN!2kB~4=%)u&N!+2;z6UYKUDqi-s6AZ!haB;@&B`? z_TRX0%@suz^TRdCb?!vNJYPY8L_}&07uySH9%W^Tc&1pia6y1q#?*Drf}GjGbPjBS zbOPcUY#*$3sL2x4v_i*Y=N7E$mR}J%|GUI(>WEr+28+V z%v5{#e!UF*6~G&%;l*q*$V?&r$Pp^sE^i-0$+RH3ERUUdQ0>rAq2(2QAbG}$y{de( z>{qD~GGuOk559Y@%$?N^1ApVL_a704>8OD%8Y%8B;FCt%AoPu8*D1 zLB5X>b}Syz81pn;xnB}%0FnwazlWfUV)Z-~rZg6~b z6!9J$EcE&sEbzcy?CI~=boWA&eeIa%z(7SE^qgVLz??1Vbc1*aRvc%Mri)AJaAG!p z$X!_9Ds;Zz)f+;%s&dRcJt2==P{^j3bf0M=nJd&xwUGlUFn?H=2W(*2I2Gdu zv!gYCwM10aeus)`RIZSrCK=&oKaO_Ry~D1B5!y0R=%!i2*KfXGYX&gNv_u+n9wiR5 z*e$Zjju&ODRW3phN925%S(jL+bCHv6rZtc?!*`1TyYXT6%Ju=|X;6D@lq$8T zW{Y|e39ioPez(pBH%k)HzFITXHvnD6hw^lIoUMA;qAJ^CU?top1fo@s7xT13Fvn1H z6JWa-6+FJF#x>~+A;D~;VDs26>^oH0EI`IYT2iagy23?nyJ==i{g4%HrAf1-*v zK1)~@&(KkwR7TL}L(A@C_S0G;-GMDy=MJn2$FP5s<%wC)4jC5PXoxrQBFZ_k0P{{s@sz+gX`-!=T8rcB(=7vW}^K6oLWMmp(rwDh}b zwaGGd>yEy6fHv%jM$yJXo5oMAQ>c9j`**}F?MCry;T@47@r?&sKHgVe$MCqk#Z_3S z1GZI~nOEN*P~+UaFGnj{{Jo@16`(qVNtbU>O0Hf57-P>x8Jikp=`s8xWs^dAJ9lCQ z)GFm+=OV%AMVqVATtN@|vp61VVAHRn87}%PC^RAzJ%JngmZTasWBAWsoAqBU+8L8u z4A&Pe?fmTm0?mK-BL9t+{y7o(7jm+RpOhL9KnY#E&qu^}B6=K_dB}*VlSEiC9fn)+V=J;OnN)Ta5v66ic1rG+dGAJ1 z1%Zb_+!$=tQ~lxQrzv3x#CPb?CekEkA}0MYSgx$Jdd}q8+R=ma$|&1a#)TQ=l$1tQ z=tL9&_^vJ)Pk}EDO-va`UCT1m#Uty1{v^A3P~83_#v^ozH}6*9mIjIr;t3Uv%@VeW zGL6(CwCUp)Jq%G0bIG%?{_*Y#5IHf*5M@wPo6A{$Um++Co$wLC=J1aoG93&T7Ho}P z=mGEPP7GbvoG!uD$k(H3A$Z))+i{Hy?QHdk>3xSBXR0j!11O^mEe9RHmw!pvzv?Ua~2_l2Yh~_!s1qS`|0~0)YsbHSz8!mG)WiJE| z2f($6TQtt6L_f~ApQYQKSb=`053LgrQq7G@98#igV>y#i==-nEjQ!XNu9 z~;mE+gtj4IDDNQJ~JVk5Ux6&LCSFL!y=>79kE9=V}J7tD==Ga+IW zX)r7>VZ9dY=V&}DR))xUoV!u(Z|%3ciQi_2jl}3=$Agc(`RPb z8kEBpvY>1FGQ9W$n>Cq=DIpski};nE)`p3IUw1Oz0|wxll^)4dq3;CCY@RyJgFgc# zKouFh!`?Xuo{IMz^xi-h=StCis_M7yq$u) z?XHvw*HP0VgR+KR6wI)jEMX|ssqYvSf*_3W8zVTQzD?3>H!#>InzpSO)@SC8q*ii- z%%h}_#0{4JG;Jm`4zg};BPTGkYamx$Xo#O~lBirRY)q=5M45n{GCfV7h9qwyu1NxOMoP4)jjZMxmT|IQQh0U7C$EbnMN<3)Kk?fFHYq$d|ICu>KbY_hO zTZM+uKHe(cIZfEqyzyYSUBZa8;Fcut-GN!HSA9ius`ltNebF46ZX_BbZNU}}ZOm{M2&nANL9@0qvih15(|`S~z}m&h!u4x~(%MAO$jHRWNfuxWF#B)E&g3ghSQ9|> z(MFaLQj)NE0lowyjvg8z0#m6FIuKE9lDO~Glg}nSb7`~^&#(Lw{}GVOS>U)m8bF}x zVjbXljBm34Cs-yM6TVusr+3kYFjr28STT3g056y3cH5Tmge~ASxBj z%|yb>$eF;WgrcOZf569sDZOVwoo%8>XO>XQOX1OyN9I-SQgrm;U;+#3OI(zrWyow3 zk==|{lt2xrQ%FIXOTejR>;wv(Pb8u8}BUpx?yd(Abh6? zsoO3VYWkeLnF43&@*#MQ9-i-d0t*xN-UEyNKeyNMHw|A(k(_6QKO=nKMCxD(W(Yop zsRQ)QeL4X3Lxp^L%wzi2-WVSsf61dqliPUM7srDB?Wm6Lzn0&{*}|IsKQW;02(Y&| zaTKv|`U(pSzuvR6Rduu$wzK_W-Y-7>7s?G$)U}&uK;<>vU}^^ns@Z!p+9?St1s)dG zK%y6xkPyyS1$~&6v{kl?Md6gwM|>mt6Upm>oa8RLD^8T{0?HC!Z>;(Bob7el(DV6x zi`I)$&E&ngwFS@bi4^xFLAn`=fzTC;aimE^!cMI2n@Vo%Ae-ne`RF((&5y6xsjjAZ zVguVoQ?Z9uk$2ON;ersE%PU*xGO@T*;j1BO5#TuZKEf(mB7|g7pcEA=nYJ{s3vlbg zd4-DUlD{*6o%Gc^N!Nptgay>j6E5;3psI+C3Q!1ZIbeCubW%w4pq9)MSDyB{HLm|k zxv-{$$A*pS@csolri$Ge<4VZ}e~78JOL-EVyrbxKra^d{?|NnPp86!q>t<&IP07?Z z^>~IK^k#OEKgRH+LjllZXk7iA>2cfH6+(e&9ku5poo~6y{GC5>(bRK7hwjiurqAiZ zg*DmtgY}v83IjE&AbiWgMyFbaRUPZ{lYiz$U^&Zt2YjG<%m((&_JUbZcfJ22(>bi5 z!J?<7AySj0JZ&<-qXX;mcV!f~>G=sB0KnjWca4}vrtunD^1TrpfeS^4dvFr!65knK zZh`d;*VOkPs4*-9kL>$GP0`(M!j~B;#x?Ba~&s6CopvO86oM?-? zOw#dIRc;6A6T?B`Qp%^<U5 z19x(ywSH$_N+Io!6;e?`tWaM$`=Db!gzx|lQ${DG!zb1Zl&|{kX0y6xvO1o z220r<-oaS^^R2pEyY;=Qllqpmue|5yI~D|iI!IGt@iod{Opz@*ml^w2bNs)p`M(Io z|E;;m*Xpjd9l)4G#KaWfV(t8YUn@A;nK^#xgv=LtnArX|vWQVuw3}B${h+frU2>9^ z!l6)!Uo4`5k`<<;E(ido7M6lKTgWezNLq>U*=uz&s=cc$1%>VrAeOoUtA|T6gO4>UNqsdK=NF*8|~*sl&wI=x9-EGiq*aqV!(VVXA57 zw9*o6Ir8Lj1npUXvlevtn(_+^X5rzdR>#(}4YcB9O50q97%rW2me5_L=%ffYPUSRc z!vv?Kv>dH994Qi>U(a<0KF6NH5b16enCp+mw^Hb3Xs1^tThFpz!3QuN#}KBbww`(h z7GO)1olDqy6?T$()R7y%NYx*B0k_2IBiZ14&8|JPFxeMF{vSTxF-Vi3+ZOI=Thq2} zyQgjYY1_7^ZQHh{?P))4+qUiQJLi1&{yE>h?~jU%tjdV0h|FENbM3X(KnJdPKc?~k zh=^Ixv*+smUll!DTWH!jrV*wSh*(mx0o6}1@JExzF(#9FXgmTXVoU+>kDe68N)dkQ zH#_98Zv$}lQwjKL@yBd;U(UD0UCl322=pav<=6g>03{O_3oKTq;9bLFX1ia*lw;#K zOiYDcBJf)82->83N_Y(J7Kr_3lE)hAu;)Q(nUVydv+l+nQ$?|%MWTy`t>{havFSQloHwiIkGK9YZ79^9?AZo0ZyQlVR#}lF%dn5n%xYksXf8gnBm=wO7g_^! zauQ-bH1Dc@3ItZ-9D_*pH}p!IG7j8A_o94#~>$LR|TFq zZ-b00*nuw|-5C2lJDCw&8p5N~Z1J&TrcyErds&!l3$eSz%`(*izc;-?HAFD9AHb-| z>)id`QCrzRws^9(#&=pIx9OEf2rmlob8sK&xPCWS+nD~qzU|qG6KwA{zbikcfQrdH z+ zQg>O<`K4L8rN7`GJB0*3<3`z({lWe#K!4AZLsI{%z#ja^OpfjU{!{)x0ZH~RB0W5X zTwN^w=|nA!4PEU2=LR05x~}|B&ZP?#pNgDMwD*ajI6oJqv!L81gu=KpqH22avXf0w zX3HjbCI!n9>l046)5rr5&v5ja!xkKK42zmqHzPx$9Nn_MZk`gLeSLgC=LFf;H1O#B zn=8|^1iRrujHfbgA+8i<9jaXc;CQBAmQvMGQPhFec2H1knCK2x!T`e6soyrqCamX% zTQ4dX_E*8so)E*TB$*io{$c6X)~{aWfaqdTh=xEeGvOAN9H&-t5tEE-qso<+C!2>+ zskX51H-H}#X{A75wqFe-J{?o8Bx|>fTBtl&tcbdR|132Ztqu5X0i-pisB-z8n71%q%>EF}yy5?z=Ve`}hVh{Drv1YWL zW=%ug_&chF11gDv3D6B)Tz5g54H0mDHNjuKZ+)CKFk4Z|$RD zfRuKLW`1B>B?*RUfVd0+u8h3r-{@fZ{k)c!93t1b0+Q9vOaRnEn1*IL>5Z4E4dZ!7 ztp4GP-^1d>8~LMeb}bW!(aAnB1tM_*la=Xx)q(I0Y@__Zd$!KYb8T2VBRw%e$iSdZ zkwdMwd}eV9q*;YvrBFTv1>1+}{H!JK2M*C|TNe$ZSA>UHKk);wz$(F$rXVc|sI^lD zV^?_J!3cLM;GJuBMbftbaRUs$;F}HDEDtIeHQ)^EJJ1F9FKJTGH<(Jj`phE6OuvE) zqK^K`;3S{Y#1M@8yRQwH`?kHMq4tHX#rJ>5lY3DM#o@or4&^_xtBC(|JpGTfrbGkA z2Tu+AyT^pHannww!4^!$5?@5v`LYy~T`qs7SYt$JgrY(w%C+IWA;ZkwEF)u5sDvOK zGk;G>Mh&elvXDcV69J_h02l&O;!{$({fng9Rlc3ID#tmB^FIG^w{HLUpF+iB`|
NnX)EH+Nua)3Y(c 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 '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -# This is normally unused -# shellcheck disable=SC2034 -APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - if ! command -v java >/dev/null 2>&1 - then - die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ - "$@" - -# Stop when "xargs" is not available. -if ! command -v xargs >/dev/null 2>&1 -then - die "xargs is not available" -fi - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/examples/substrait-spark/gradlew.bat b/examples/substrait-spark/gradlew.bat deleted file mode 100644 index 7101f8e46..000000000 --- a/examples/substrait-spark/gradlew.bat +++ /dev/null @@ -1,92 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/examples/substrait-spark/justfile b/examples/substrait-spark/justfile index 9a138d278..6fcad7666 100644 --- a/examples/substrait-spark/justfile +++ b/examples/substrait-spark/justfile @@ -10,7 +10,7 @@ CWDIR := justfile_directory() SPARK_VERSION := "3.5.1" -SPARK_MASTER_CONTAINER := "subtrait-spark-spark-1" +SPARK_MASTER_CONTAINER := "substrait-spark-spark-1" _default: @just -f {{justfile()}} --list @@ -19,14 +19,14 @@ buildapp: #!/bin/bash set -e -o pipefail - ${CWDIR}/gradlew build + ${CWDIR}/../../gradlew build # need to let the SPARK user be able to write to the _data mount mkdir -p ${CWDIR}/_data && chmod g+w ${CWDIR}/_data mkdir -p ${CWDIR}/_apps - cp ${CWDIR}/app/build/libs/app.jar ${CWDIR}/_apps - cp ${CWDIR}/app/src/main/resources/*.csv ${CWDIR}/_data + cp ${CWDIR}/build/libs/substrait-spark*.jar ${CWDIR}/_apps/app.jar + cp ${CWDIR}/src/main/resources/*.csv ${CWDIR}/_data dataset: #!/bin/bash diff --git a/examples/substrait-spark/settings.gradle b/examples/substrait-spark/settings.gradle deleted file mode 100644 index ed37a683e..000000000 --- a/examples/substrait-spark/settings.gradle +++ /dev/null @@ -1,20 +0,0 @@ -/* - * This file was generated by the Gradle 'init' task. - * - * The settings file is used to specify which projects to include in your build. - * For more detailed information on multi-project builds, please refer to https://docs.gradle.org/8.7/userguide/multi_project_builds.html in the Gradle documentation. - * This project uses @Incubating APIs which are subject to change. - */ - -pluginManagement { - // Include 'plugins build' to define convention plugins. - includeBuild('build-logic') -} - -plugins { - // Apply the foojay-resolver plugin to allow automatic download of JDKs - id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0' -} - -rootProject.name = 'flexdata-spark' -include('app') diff --git a/examples/substrait-spark/app/src/main/java/io/substrait/examples/App.java b/examples/substrait-spark/src/main/java/io/substrait/examples/App.java similarity index 69% rename from examples/substrait-spark/app/src/main/java/io/substrait/examples/App.java rename to examples/substrait-spark/src/main/java/io/substrait/examples/App.java index fed789b3f..b401c0417 100644 --- a/examples/substrait-spark/app/src/main/java/io/substrait/examples/App.java +++ b/examples/substrait-spark/src/main/java/io/substrait/examples/App.java @@ -1,20 +1,26 @@ package io.substrait.examples; -import java.nio.file.Files; -import java.nio.file.Paths; +/** Main class */ +public final class App { -import io.substrait.plan.Plan; -import io.substrait.plan.ProtoPlanConverter; + /** Implemented by all examples */ + public interface Action { -public class App { - - public static interface Action { - public void run(String arg); + /** Run + * + * @param arg argument + */ + void run(String arg); } private App() { } + /** + * Traditional main method + * + * @param args string[] + */ public static void main(String args[]) { try { diff --git a/examples/substrait-spark/app/src/main/java/io/substrait/examples/SparkConsumeSubstrait.java b/examples/substrait-spark/src/main/java/io/substrait/examples/SparkConsumeSubstrait.java similarity index 97% rename from examples/substrait-spark/app/src/main/java/io/substrait/examples/SparkConsumeSubstrait.java rename to examples/substrait-spark/src/main/java/io/substrait/examples/SparkConsumeSubstrait.java index 761209850..13805515b 100644 --- a/examples/substrait-spark/app/src/main/java/io/substrait/examples/SparkConsumeSubstrait.java +++ b/examples/substrait-spark/src/main/java/io/substrait/examples/SparkConsumeSubstrait.java @@ -17,8 +17,7 @@ /** Minimal Spark application */ public class SparkConsumeSubstrait implements App.Action { - public SparkConsumeSubstrait() { - } + @Override public void run(String arg) { diff --git a/examples/substrait-spark/app/src/main/java/io/substrait/examples/SparkDataset.java b/examples/substrait-spark/src/main/java/io/substrait/examples/SparkDataset.java similarity index 85% rename from examples/substrait-spark/app/src/main/java/io/substrait/examples/SparkDataset.java rename to examples/substrait-spark/src/main/java/io/substrait/examples/SparkDataset.java index 4f0e668c7..e03916ceb 100644 --- a/examples/substrait-spark/app/src/main/java/io/substrait/examples/SparkDataset.java +++ b/examples/substrait-spark/src/main/java/io/substrait/examples/SparkDataset.java @@ -15,10 +15,6 @@ /** Minimal Spark application */ public class SparkDataset implements App.Action { - public SparkDataset() { - - } - @Override public void run(String arg) { @@ -32,8 +28,8 @@ public void run(String arg) { String vehiclesFile = Paths.get(ROOT_DIR, VEHICLES_CSV).toString(); String testsFile = Paths.get(ROOT_DIR, TESTS_CSV).toString(); - System.out.println("Reading "+vehiclesFile); - System.out.println("Reading "+testsFile); + System.out.println("Reading " + vehiclesFile); + System.out.println("Reading " + testsFile); dsVehicles = spark.read().option("delimiter", ",").option("header", "true").csv(vehiclesFile); dsVehicles.show(); @@ -61,6 +57,11 @@ public void run(String arg) { } } + /** + * Create substrait plan and save to file based on logical plan + * + * @param enginePlan logical plan + */ public void createSubstrait(LogicalPlan enginePlan) { ToSubstraitRel toSubstrait = new ToSubstraitRel(); io.substrait.plan.Plan plan = toSubstrait.convert(enginePlan); @@ -70,8 +71,8 @@ public void createSubstrait(LogicalPlan enginePlan) { PlanProtoConverter planToProto = new PlanProtoConverter(); byte[] buffer = planToProto.toProto(plan).toByteArray(); try { - Files.write(Paths.get(ROOT_DIR,"spark_dataset_substrait.plan"), buffer); - System.out.println("File written to "+Paths.get(ROOT_DIR,"spark_sql_substrait.plan")); + Files.write(Paths.get(ROOT_DIR, "spark_dataset_substrait.plan"), buffer); + System.out.println("File written to " + Paths.get(ROOT_DIR, "spark_sql_substrait.plan")); } catch (IOException e) { e.printStackTrace(System.out); } diff --git a/examples/substrait-spark/app/src/main/java/io/substrait/examples/SparkHelper.java b/examples/substrait-spark/src/main/java/io/substrait/examples/SparkHelper.java similarity index 59% rename from examples/substrait-spark/app/src/main/java/io/substrait/examples/SparkHelper.java rename to examples/substrait-spark/src/main/java/io/substrait/examples/SparkHelper.java index 7bed7fae4..efe516578 100644 --- a/examples/substrait-spark/app/src/main/java/io/substrait/examples/SparkHelper.java +++ b/examples/substrait-spark/src/main/java/io/substrait/examples/SparkHelper.java @@ -2,25 +2,47 @@ import org.apache.spark.sql.SparkSession; -public class SparkHelper { +/** Collection of helper fns */ +public final class SparkHelper { + + private SparkHelper() { + } + + /** + * Namespace to use for the data + */ public static final String NAMESPACE = "demo_db"; + + /** Vehicles table */ public static final String VEHICLE_TABLE = "vehicles"; + + /** Tests table (the vehicle safety tests) */ public static final String TESTS_TABLE = "tests"; + /** Source data - parquet */ public static final String VEHICLES_PQ = "vehicles_subset_2023.parquet"; + + /** Source data - parquet */ public static final String TESTS_PQ = "tests_subset_2023.parquet"; + /** Source data - csv */ public static final String VEHICLES_CSV = "vehicles_subset_2023.csv"; + + /** Source data - csv */ public static final String TESTS_CSV = "tests_subset_2023.csv"; + /** In-container data location */ public static final String ROOT_DIR = "/opt/spark-data"; - // Connect to local spark for demo purposes - public static SparkSession connectSpark(String spark_master) { + /** Connect to local spark for demo purposes + * @param sparkMaster address of the Spark Master to connect to + * @return SparkSession + */ + public static SparkSession connectSpark(String sparkMaster) { SparkSession spark = SparkSession.builder() // .config("spark.sql.warehouse.dir", "spark-warehouse") - .config("spark.master", spark_master) + .config("spark.master", sparkMaster) .enableHiveSupport() .getOrCreate(); @@ -29,6 +51,9 @@ public static SparkSession connectSpark(String spark_master) { return spark; } + /** Connects to the local spark cluister + * @return SparkSession + */ public static SparkSession connectLocalSpark() { SparkSession spark = SparkSession.builder() @@ -40,5 +65,4 @@ public static SparkSession connectLocalSpark() { return spark; } - } diff --git a/examples/substrait-spark/app/src/main/java/io/substrait/examples/SparkSQL.java b/examples/substrait-spark/src/main/java/io/substrait/examples/SparkSQL.java similarity index 85% rename from examples/substrait-spark/app/src/main/java/io/substrait/examples/SparkSQL.java rename to examples/substrait-spark/src/main/java/io/substrait/examples/SparkSQL.java index 3bdd26e96..3da061801 100644 --- a/examples/substrait-spark/app/src/main/java/io/substrait/examples/SparkSQL.java +++ b/examples/substrait-spark/src/main/java/io/substrait/examples/SparkSQL.java @@ -19,10 +19,6 @@ /** Minimal Spark application */ public class SparkSQL implements App.Action { - public SparkSQL() { - - } - @Override public void run(String arg) { @@ -42,14 +38,12 @@ public void run(String arg) { spark.read().option("delimiter", ",").option("header", "true").csv(testsFile) .createOrReplaceTempView(TESTS_TABLE); - String sqlQuery = """ - SELECT vehicles.colour, count(*) as colourcount - FROM vehicles - INNER JOIN tests ON vehicles.vehicle_id=tests.vehicle_id - WHERE tests.test_result = 'P' - GROUP BY vehicles.colour - ORDER BY count(*) - """; + String sqlQuery = "SELECT vehicles.colour, count(*) as colourcount"+ + " FROM vehicles"+ + " INNER JOIN tests ON vehicles.vehicle_id=tests.vehicle_id"+ + " WHERE tests.test_result = 'P'"+ + " GROUP BY vehicles.colour"+ + " ORDER BY count(*)"; var result = spark.sql(sqlQuery); result.show(); @@ -67,6 +61,9 @@ ORDER BY count(*) } } + /** creates a substrait plan based on the logical plan + * @param enginePlan Spark Local PLan + */ public void createSubstrait(LogicalPlan enginePlan) { ToSubstraitRel toSubstrait = new ToSubstraitRel(); io.substrait.plan.Plan plan = toSubstrait.convert(enginePlan); diff --git a/examples/substrait-spark/app/src/main/resources/tests_subset_2023.csv b/examples/substrait-spark/src/main/resources/tests_subset_2023.csv similarity index 100% rename from examples/substrait-spark/app/src/main/resources/tests_subset_2023.csv rename to examples/substrait-spark/src/main/resources/tests_subset_2023.csv diff --git a/examples/substrait-spark/app/src/main/resources/vehicles_subset_2023.csv b/examples/substrait-spark/src/main/resources/vehicles_subset_2023.csv similarity index 100% rename from examples/substrait-spark/app/src/main/resources/vehicles_subset_2023.csv rename to examples/substrait-spark/src/main/resources/vehicles_subset_2023.csv diff --git a/settings.gradle.kts b/settings.gradle.kts index 224c6b509..013449786 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,6 +1,6 @@ rootProject.name = "substrait" -include("bom", "core", "isthmus", "isthmus-cli", "spark") +include("bom", "core", "isthmus", "isthmus-cli", "spark", "examples:substrait-spark") pluginManagement { plugins { From e6018ab7b065a46100b8894ce53300ed65ba0a91 Mon Sep 17 00:00:00 2001 From: Victor Barua Date: Mon, 30 Sep 2024 16:56:15 -0700 Subject: [PATCH 3/8] style(spark): configure and apply spotless --- examples/substrait-spark/build.gradle.kts | 54 +++++++-------- .../main/java/io/substrait/examples/App.java | 69 +++++++++---------- .../examples/SparkConsumeSubstrait.java | 15 ++-- .../io/substrait/examples/SparkDataset.java | 27 ++++---- .../io/substrait/examples/SparkHelper.java | 33 +++++---- .../java/io/substrait/examples/SparkSQL.java | 48 +++++++------ 6 files changed, 123 insertions(+), 123 deletions(-) diff --git a/examples/substrait-spark/build.gradle.kts b/examples/substrait-spark/build.gradle.kts index 64533f02d..667514482 100644 --- a/examples/substrait-spark/build.gradle.kts +++ b/examples/substrait-spark/build.gradle.kts @@ -1,44 +1,40 @@ - plugins { - // Apply the application plugin to add support for building a CLI application in Java. - id("java") + // Apply the application plugin to add support for building a CLI application in Java. + id("java") + id("com.diffplug.spotless") version "6.11.0" } repositories { - // Use Maven Central for resolving dependencies. - mavenCentral() + // Use Maven Central for resolving dependencies. + mavenCentral() } dependencies { - implementation("org.apache.spark:spark-core_2.12:3.5.1") - implementation("io.substrait:spark:0.36.0") - implementation("io.substrait:core:0.36.0") - implementation("org.apache.spark:spark-sql_2.12:3.5.1") - - // For a real Spark application, these would not be required since they would be in the Spark server classpath - runtimeOnly("org.apache.spark:spark-core_2.12:3.5.1") - runtimeOnly("org.apache.spark:spark-hive_2.12:3.5.1") - + implementation("org.apache.spark:spark-core_2.12:3.5.1") + implementation("io.substrait:spark:0.36.0") + implementation("io.substrait:core:0.36.0") + implementation("org.apache.spark:spark-sql_2.12:3.5.1") + + // For a real Spark application, these would not be required since they would be in the Spark + // server classpath + runtimeOnly("org.apache.spark:spark-core_2.12:3.5.1") + runtimeOnly("org.apache.spark:spark-hive_2.12:3.5.1") } tasks.jar { - isZip64 = true - exclude ("META-INF/*.RSA") - exclude ("META-INF/*.SF") - exclude ("META-INF/*.DSA") - - duplicatesStrategy = DuplicatesStrategy.EXCLUDE - manifest.attributes["Main-Class"] = "io.substrait.examples.App" - from(configurations.runtimeClasspath.get().map({ if (it.isDirectory) it else zipTree(it) })) - + isZip64 = true + exclude("META-INF/*.RSA") + exclude("META-INF/*.SF") + exclude("META-INF/*.DSA") + + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + manifest.attributes["Main-Class"] = "io.substrait.examples.App" + from(configurations.runtimeClasspath.get().map({ if (it.isDirectory) it else zipTree(it) })) } tasks.named("test") { - // Use JUnit Platform for unit tests. - useJUnitPlatform() -} - -java { - toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } + // Use JUnit Platform for unit tests. + useJUnitPlatform() } +java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } } diff --git a/examples/substrait-spark/src/main/java/io/substrait/examples/App.java b/examples/substrait-spark/src/main/java/io/substrait/examples/App.java index b401c0417..87d7190f4 100644 --- a/examples/substrait-spark/src/main/java/io/substrait/examples/App.java +++ b/examples/substrait-spark/src/main/java/io/substrait/examples/App.java @@ -3,44 +3,43 @@ /** Main class */ public final class App { - /** Implemented by all examples */ - public interface Action { - - /** Run - * - * @param arg argument - */ - void run(String arg); - } - - private App() { - } + /** Implemented by all examples */ + public interface Action { /** - * Traditional main method + * Run * - * @param args string[] + * @param arg argument */ - public static void main(String args[]) { - try { - - if (args.length == 0) { - args = new String[] { "SparkDataset" }; - } - String exampleClass = args[0]; - - var clz = Class.forName(App.class.getPackageName() + "." + exampleClass); - var action = (Action) clz.getDeclaredConstructor().newInstance(); - - if (args.length == 2) { - action.run(args[1]); - } else { - action.run(null); - } - - } catch (Exception e) { - e.printStackTrace(); - } + void run(String arg); + } + + private App() {} + + /** + * Traditional main method + * + * @param args string[] + */ + public static void main(String args[]) { + try { + + if (args.length == 0) { + args = new String[] {"SparkDataset"}; + } + String exampleClass = args[0]; + + var clz = Class.forName(App.class.getPackageName() + "." + exampleClass); + var action = (Action) clz.getDeclaredConstructor().newInstance(); + + if (args.length == 2) { + action.run(args[1]); + } else { + action.run(null); + } + + } catch (Exception e) { + e.printStackTrace(); } - + } } diff --git a/examples/substrait-spark/src/main/java/io/substrait/examples/SparkConsumeSubstrait.java b/examples/substrait-spark/src/main/java/io/substrait/examples/SparkConsumeSubstrait.java index 13805515b..5f5436b05 100644 --- a/examples/substrait-spark/src/main/java/io/substrait/examples/SparkConsumeSubstrait.java +++ b/examples/substrait-spark/src/main/java/io/substrait/examples/SparkConsumeSubstrait.java @@ -1,24 +1,20 @@ package io.substrait.examples; +import static io.substrait.examples.SparkHelper.ROOT_DIR; + +import io.substrait.plan.Plan; +import io.substrait.plan.ProtoPlanConverter; +import io.substrait.spark.logical.ToLogicalPlan; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Paths; - import org.apache.spark.sql.Dataset; import org.apache.spark.sql.SparkSession; import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan; -import io.substrait.plan.Plan; -import io.substrait.plan.ProtoPlanConverter; -import io.substrait.spark.logical.ToLogicalPlan; - -import static io.substrait.examples.SparkHelper.ROOT_DIR; - /** Minimal Spark application */ public class SparkConsumeSubstrait implements App.Action { - - @Override public void run(String arg) { @@ -44,5 +40,4 @@ public void run(String arg) { e.printStackTrace(); } } - } diff --git a/examples/substrait-spark/src/main/java/io/substrait/examples/SparkDataset.java b/examples/substrait-spark/src/main/java/io/substrait/examples/SparkDataset.java index e03916ceb..d5d4a488a 100644 --- a/examples/substrait-spark/src/main/java/io/substrait/examples/SparkDataset.java +++ b/examples/substrait-spark/src/main/java/io/substrait/examples/SparkDataset.java @@ -1,16 +1,18 @@ package io.substrait.examples; +import static io.substrait.examples.SparkHelper.ROOT_DIR; +import static io.substrait.examples.SparkHelper.TESTS_CSV; +import static io.substrait.examples.SparkHelper.VEHICLES_CSV; + +import io.substrait.plan.PlanProtoConverter; +import io.substrait.spark.logical.ToSubstraitRel; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; import org.apache.spark.sql.Dataset; import org.apache.spark.sql.Row; import org.apache.spark.sql.SparkSession; import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan; -import java.io.IOException; -import java.nio.file.*; -import io.substrait.plan.PlanProtoConverter; -import io.substrait.spark.logical.ToSubstraitRel; -import static io.substrait.examples.SparkHelper.ROOT_DIR; -import static io.substrait.examples.SparkHelper.TESTS_CSV; -import static io.substrait.examples.SparkHelper.VEHICLES_CSV; /** Minimal Spark application */ public class SparkDataset implements App.Action { @@ -38,10 +40,12 @@ public void run(String arg) { dsTests.show(); // created the joined dataset - Dataset joinedDs = dsVehicles.join(dsTests, dsVehicles.col("vehicle_id").equalTo(dsTests.col("vehicle_id"))) - .filter(dsTests.col("test_result").equalTo("P")) - .groupBy(dsVehicles.col("colour")) - .count(); + Dataset joinedDs = + dsVehicles + .join(dsTests, dsVehicles.col("vehicle_id").equalTo(dsTests.col("vehicle_id"))) + .filter(dsTests.col("test_result").equalTo("P")) + .groupBy(dsVehicles.col("colour")) + .count(); joinedDs = joinedDs.orderBy(joinedDs.col("count")); joinedDs.show(); @@ -77,5 +81,4 @@ public void createSubstrait(LogicalPlan enginePlan) { e.printStackTrace(System.out); } } - } diff --git a/examples/substrait-spark/src/main/java/io/substrait/examples/SparkHelper.java b/examples/substrait-spark/src/main/java/io/substrait/examples/SparkHelper.java index efe516578..f75140556 100644 --- a/examples/substrait-spark/src/main/java/io/substrait/examples/SparkHelper.java +++ b/examples/substrait-spark/src/main/java/io/substrait/examples/SparkHelper.java @@ -5,12 +5,9 @@ /** Collection of helper fns */ public final class SparkHelper { - private SparkHelper() { - } + private SparkHelper() {} - /** - * Namespace to use for the data - */ + /** Namespace to use for the data */ public static final String NAMESPACE = "demo_db"; /** Vehicles table */ @@ -31,38 +28,40 @@ private SparkHelper() { /** Source data - csv */ public static final String TESTS_CSV = "tests_subset_2023.csv"; - /** In-container data location */ + /** In-container data location */ public static final String ROOT_DIR = "/opt/spark-data"; - /** Connect to local spark for demo purposes + /** + * Connect to local spark for demo purposes + * * @param sparkMaster address of the Spark Master to connect to * @return SparkSession */ public static SparkSession connectSpark(String sparkMaster) { - SparkSession spark = SparkSession.builder() - // .config("spark.sql.warehouse.dir", "spark-warehouse") - .config("spark.master", sparkMaster) - .enableHiveSupport() - .getOrCreate(); + SparkSession spark = + SparkSession.builder() + // .config("spark.sql.warehouse.dir", "spark-warehouse") + .config("spark.master", sparkMaster) + .enableHiveSupport() + .getOrCreate(); spark.sparkContext().setLogLevel("ERROR"); return spark; } - /** Connects to the local spark cluister + /** + * Connects to the local spark cluister + * * @return SparkSession */ public static SparkSession connectLocalSpark() { - SparkSession spark = SparkSession.builder() - .enableHiveSupport() - .getOrCreate(); + SparkSession spark = SparkSession.builder().enableHiveSupport().getOrCreate(); spark.sparkContext().setLogLevel("ERROR"); return spark; } - } diff --git a/examples/substrait-spark/src/main/java/io/substrait/examples/SparkSQL.java b/examples/substrait-spark/src/main/java/io/substrait/examples/SparkSQL.java index 3da061801..a927d0799 100644 --- a/examples/substrait-spark/src/main/java/io/substrait/examples/SparkSQL.java +++ b/examples/substrait-spark/src/main/java/io/substrait/examples/SparkSQL.java @@ -6,16 +6,14 @@ import static io.substrait.examples.SparkHelper.VEHICLES_CSV; import static io.substrait.examples.SparkHelper.VEHICLE_TABLE; +import io.substrait.plan.PlanProtoConverter; +import io.substrait.spark.logical.ToSubstraitRel; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Paths; - import org.apache.spark.sql.SparkSession; import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan; -import io.substrait.plan.PlanProtoConverter; -import io.substrait.spark.logical.ToSubstraitRel; - /** Minimal Spark application */ public class SparkSQL implements App.Action { @@ -33,19 +31,28 @@ public void run(String arg) { System.out.println("Reading " + vehiclesFile); System.out.println("Reading " + testsFile); - spark.read().option("delimiter", ",").option("header", "true").csv(vehiclesFile) + spark + .read() + .option("delimiter", ",") + .option("header", "true") + .csv(vehiclesFile) .createOrReplaceTempView(VEHICLE_TABLE); - spark.read().option("delimiter", ",").option("header", "true").csv(testsFile) - .createOrReplaceTempView(TESTS_TABLE); - - String sqlQuery = "SELECT vehicles.colour, count(*) as colourcount"+ - " FROM vehicles"+ - " INNER JOIN tests ON vehicles.vehicle_id=tests.vehicle_id"+ - " WHERE tests.test_result = 'P'"+ - " GROUP BY vehicles.colour"+ - " ORDER BY count(*)"; - - var result = spark.sql(sqlQuery); + spark + .read() + .option("delimiter", ",") + .option("header", "true") + .csv(testsFile) + .createOrReplaceTempView(TESTS_TABLE); + + String sqlQuery = + "SELECT vehicles.colour, count(*) as colourcount" + + " FROM vehicles" + + " INNER JOIN tests ON vehicles.vehicle_id=tests.vehicle_id" + + " WHERE tests.test_result = 'P'" + + " GROUP BY vehicles.colour" + + " ORDER BY count(*)"; + + var result = spark.sql(sqlQuery); result.show(); LogicalPlan logical = result.logicalPlan(); @@ -61,7 +68,9 @@ public void run(String arg) { } } - /** creates a substrait plan based on the logical plan + /** + * creates a substrait plan based on the logical plan + * * @param enginePlan Spark Local PLan */ public void createSubstrait(LogicalPlan enginePlan) { @@ -72,12 +81,11 @@ public void createSubstrait(LogicalPlan enginePlan) { PlanProtoConverter planToProto = new PlanProtoConverter(); byte[] buffer = planToProto.toProto(plan).toByteArray(); try { - Files.write(Paths.get(ROOT_DIR,"spark_sql_substrait.plan"), buffer); - System.out.println("File written to "+Paths.get(ROOT_DIR,"spark_sql_substrait.plan")); + Files.write(Paths.get(ROOT_DIR, "spark_sql_substrait.plan"), buffer); + System.out.println("File written to " + Paths.get(ROOT_DIR, "spark_sql_substrait.plan")); } catch (IOException e) { e.printStackTrace(); } } - } From c115600243f4afa45ce8df14662303ab255bb023 Mon Sep 17 00:00:00 2001 From: Victor Barua Date: Mon, 30 Sep 2024 17:14:07 -0700 Subject: [PATCH 4/8] docs: minor type fixes --- examples/substrait-spark/README.md | 29 +++++++++-------------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/examples/substrait-spark/README.md b/examples/substrait-spark/README.md index a8079e2cd..495a117d6 100644 --- a/examples/substrait-spark/README.md +++ b/examples/substrait-spark/README.md @@ -4,7 +4,7 @@ The Substrait-Spark library allows Substrait plans to convert to and from Spark ## How does this work in practice? -Once Spark SQL and Spark DataFrame APIs queries have been created, Spark's optimized query plan can be used generate Substrait plans; and Substrait Plans can be executed on a Spark cluster. Below is a description of how to use this library; there are two sample datasets included for demonstration. +Once Spark SQL and Spark DataFrame APIs queries have been created, Spark's optimized query plan can be used to generate Substrait plans; and Substrait Plans can be executed on a Spark cluster. Below is a description of how to use this library; there are two sample datasets included for demonstration. The most commonly used logical relations are supported, including those generated from all the TPC-H queries, but there are currently some gaps in support that prevent all the TPC-DS queries from being translatable. @@ -25,13 +25,13 @@ To run these you will need: - Java 17 or greater - Docker to start a test Spark Cluster - - you could use your own cluster, but would need to adjust file locations defined in [SparkHelper](./app/src/main/java/io/substrait/examples/SparkHelper.java) + - You could use your own cluster, but would need to adjust file locations defined in [SparkHelper](./app/src/main/java/io/substrait/examples/SparkHelper.java) - The [just task runner](https://github.com/casey/just#installation) is optional, but very helpful to run the bash commands - [Two datafiles](./app/src/main/resources/) are provided (CSV format) For building using the `substrait-spark` library youself, using the [mvn repository](https://mvnrepository.com/artifact/io.substrait/spark) -Using maven: +Using Maven: ```xml @@ -41,7 +41,7 @@ Using maven: ``` -Using Gradle (groovy) +Using Gradle (Groovy) ```groovy // https://mvnrepository.com/artifact/io.substrait/spark implementation 'io.substrait:spark:0.36.0' @@ -49,7 +49,7 @@ implementation 'io.substrait:spark:0.36.0' ### Setup configuration -Firstly the application needs to be built; this is a simple Java application. As well issuing the `gradle` build command it also creates two directories `_apps` and `_data`. The JAR file and will be copied to the `_apps` directory and the datafiles to the `_data`. Note that the permissions on the `_data` directory are set to group write - this allows the spark process in the docker container to write the output plan +Firstly the application needs to be built; this is a simple Java application. As well issuing the `gradle` build command it also creates two directories `_apps` and `_data`. The JAR file and will be copied to the `_apps` directory and the datafiles to the `_data`. Note that the permissions on the `_data` directory are set to group write - this allows the Spark process in the docker container to write the output plan To run using `just` ``` @@ -152,7 +152,7 @@ If we were to just run this as-is, the output table would be below. ### Logical and Optimized Query Plans -THe next step is to look at the logical and optimised query plans that Spark has constructed. +The next step is to look at the logical and optimised query plans that Spark has constructed. ```java LogicalPlan logical = result.logicalPlan(); @@ -160,7 +160,6 @@ THe next step is to look at the logical and optimised query plans that Spark has LogicalPlan optimised = result.queryExecution().optimizedPlan(); System.out.println(optimised); - ``` The logical plan will be: @@ -310,7 +309,7 @@ As `Substrait Spark` library also allows plans to be loaded and executed, so the The [`SparkConsumeSubstrait`](./app/src/main/java/io/substrait/examples/SparkConsumeSubstrait.java) code shows how to load this file, and most importantly how to convert it to a Spark engine plan to execute -Loading the binary protobuf file is the reverse of the writing process (in the code the file name comes from a command line argument, here we're showing the hardcode file name ) +Loading the binary protobuf file is the reverse of the writing process (in the code the file name comes from a command line argument, here we're showing the hardcoded file name ) ```java byte[] buffer = Files.readAllBytes(Paths.get("spark_sql_substrait.plan")); @@ -318,7 +317,6 @@ Loading the binary protobuf file is the reverse of the writing process (in the c ProtoPlanConverter protoToPlan = new ProtoPlanConverter(); Plan plan = protoToPlan.from(proto); - ``` The loaded byte array is first converted into the protobuf Plan, and then into the Substrait Plan object. Note it can be useful to name the variables, and/or use the pull class names to keep track of it's the ProtoBuf Plan or the high-level POJO Plan. @@ -342,10 +340,9 @@ If you were to print out this plan, it has the identical structure to the plan s +- Project [vehicle_id#10] +- Filter ((isnotnull(test_result#14) AND (test_result#14 = P)) AND isnotnull(vehicle_id#10)) +- Relation [test_id#9,vehicle_id#10,test_date#11,test_class#12,test_type#13,test_result#14,test_mileage#15,postcode_area#16] csv - ``` -Executed of this plan is then simple `Dataset.ofRows(spark, sparkPlan).show();` giving the output of +Execution of this plan is then a simple `Dataset.ofRows(spark, sparkPlan).show();` giving the output of ```java +------+-----+ @@ -409,7 +406,6 @@ Spark's inner join is taking as inputs the two filtered relations; it's ensuring +- Project [vehicle_id#10] +- Filter ((isnotnull(test_result#14) AND (test_result#14 = P)) AND isnotnull(vehicle_id#10)) - ``` The Substrait Representation looks longer, but is showing the same structure. (note that this format is a custom format implemented as [SubstraitStingify](...) as the standard text output can be hard to read). @@ -483,7 +479,6 @@ For example the above plan can be handled with PyArrow or DuckDB (as an example table_provider=self.simple_provider, ) result = reader.read_all() - ``` When run with the plan pyarrow instantly rejects it with @@ -533,7 +528,7 @@ As names is a array, we can check the final part of the name and return the matc ``` -When run the output is along these lines (the query is slightly different here for simplicity); we can see the tables being request and the schema expected. Nothing is done with the schema here but could be useful for ensuring that the expectations of the plan match the schema of the data held in the engine. +When run the output is along these lines (the query is slightly different here for simplicity); we can see the tables being requested and the schema expected. Nothing is done with the schema here but could be useful for ensuring that the expectations of the plan match the schema of the data held in the engine. ``` == Requesting table ['spark_catalog', 'default', 'vehicles'] with schema: @@ -578,9 +573,3 @@ In the case of Spark for example, identical plans can be created with the Spark Logical references to a 'table' via a `NamedScan` gives more flexibility; but the structure of the reference still needs to be properly understood and agreed upon. Once common understanding is agreed upon, transferring plans between engines brings great flexibility and potential. - - - - - - From 4fce4e77fcdb589f3a82a4ada383b0ede3c34176 Mon Sep 17 00:00:00 2001 From: Victor Barua Date: Mon, 30 Sep 2024 17:17:43 -0700 Subject: [PATCH 5/8] build: bump spotless plugin version --- examples/substrait-spark/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/substrait-spark/build.gradle.kts b/examples/substrait-spark/build.gradle.kts index 667514482..212f2b11c 100644 --- a/examples/substrait-spark/build.gradle.kts +++ b/examples/substrait-spark/build.gradle.kts @@ -1,7 +1,7 @@ plugins { // Apply the application plugin to add support for building a CLI application in Java. id("java") - id("com.diffplug.spotless") version "6.11.0" + id("com.diffplug.spotless") version "6.19.0" } repositories { From e71ebabea79137289e55b72057d4b2a26813b17c Mon Sep 17 00:00:00 2001 From: MBWhite Date: Tue, 1 Oct 2024 10:45:23 +0100 Subject: [PATCH 6/8] feat: addressed comments and added missing code Signed-off-by: MBWhite --- examples/substrait-spark/.gitignore | 1 + examples/substrait-spark/README.md | 23 +- examples/substrait-spark/justfile | 6 +- .../examples/SparkConsumeSubstrait.java | 3 + .../io/substrait/examples/SparkDataset.java | 3 +- .../io/substrait/examples/SparkHelper.java | 29 -- .../java/io/substrait/examples/SparkSQL.java | 4 +- .../examples/util/ExpressionStringify.java | 276 ++++++++++++++ .../examples/util/FunctionArgStringify.java | 31 ++ .../examples/util/ParentStringify.java | 57 +++ .../examples/util/SubstraitStringify.java | 336 ++++++++++++++++++ .../examples/util/TypeStringify.java | 175 +++++++++ 12 files changed, 907 insertions(+), 37 deletions(-) create mode 100644 examples/substrait-spark/src/main/java/io/substrait/examples/util/ExpressionStringify.java create mode 100644 examples/substrait-spark/src/main/java/io/substrait/examples/util/FunctionArgStringify.java create mode 100644 examples/substrait-spark/src/main/java/io/substrait/examples/util/ParentStringify.java create mode 100644 examples/substrait-spark/src/main/java/io/substrait/examples/util/SubstraitStringify.java create mode 100644 examples/substrait-spark/src/main/java/io/substrait/examples/util/TypeStringify.java diff --git a/examples/substrait-spark/.gitignore b/examples/substrait-spark/.gitignore index a6765eee4..4eb18b0c4 100644 --- a/examples/substrait-spark/.gitignore +++ b/examples/substrait-spark/.gitignore @@ -2,3 +2,4 @@ spark-warehouse derby.log _apps _data +bin diff --git a/examples/substrait-spark/README.md b/examples/substrait-spark/README.md index 495a117d6..8cbc42677 100644 --- a/examples/substrait-spark/README.md +++ b/examples/substrait-spark/README.md @@ -95,8 +95,16 @@ The `justfile` has three targets to make it easy to run the examples - `just dataset` runs the Dataset API and produces `spark_dataset_substrait.plan` - `just sql` runs the SQL api and produces `spark_sql_substrait.plan` - `just consume ` runs the specified plan (from the `_data` directory) - - +- run `just` without arguments to get a summary of the targets available. +``` +just +Available recipes: + buildapp # Builds the application into a JAR file + consume arg # Consumes the Substrait plan file passed as the argument + dataset # Runs a Spark dataset api query and produces a Substrait plan + spark # Starts a simple Spark cluster locally in docker + sql # Runs a Spark SQL api query and produces a Substrait plan +``` ## Creating a Substrait Plan @@ -266,7 +274,7 @@ The `io.substrait.plan.Plan` object is a high-level Substrait POJO representing For the dataset approach, the `spark_dataset_substrait.plan` is created, and for the SQL approach the `spark_sql_substrait.plan` is created. These Intermediate Representations of the query can be saved, transferred and reloaded into a Data Engine. -We can also review the Substrait plan's structure; the canonical format of the Substrait plan is the binary protobuf format, but it's possible to produce a textual version, an example is below. Both the Substrait plans from the Dataset or SQL APIs generate the same output. +We can also review the Substrait plan's structure; the canonical format of the Substrait plan is the binary protobuf format, but it's possible to produce a textual version, an example is below (please see the [SubstraitStringify utility class](./src/main/java/io/substrait/examples/util/SubstraitStringify.java); it's also a good example of how to use some if the vistor patterns). Both the Substrait plans from the Dataset or SQL APIs generate the same output. ``` @@ -318,7 +326,9 @@ Loading the binary protobuf file is the reverse of the writing process (in the c ProtoPlanConverter protoToPlan = new ProtoPlanConverter(); Plan plan = protoToPlan.from(proto); ``` -The loaded byte array is first converted into the protobuf Plan, and then into the Substrait Plan object. Note it can be useful to name the variables, and/or use the pull class names to keep track of it's the ProtoBuf Plan or the high-level POJO Plan. + +The loaded byte array is first converted into the protobuf Plan, and then into the Substrait Plan object. Note it can be useful to name the variables, and/or use the full class names to keep track of it's the ProtoBuf Plan or the high-level POJO Plan. For example `io.substrait.proto.Plan` or `io.substrait.Plan` + Finally this can be converted to a Spark Plan: @@ -395,6 +405,9 @@ When converted to Substrait the Sort and Aggregate is in the same order, but the +- Aggregate:: FieldRef#/Str/StructField{offset=0} ``` +These look different due to two factors. Firstly the Spark optimizer has swapped the project and aggregate functions. +Secondly projects within the Substrait plan joined the fields together but don't reduce the number of fields. Any such filtering is done on the outer relations. + ### Inner Join Spark's inner join is taking as inputs the two filtered relations; it's ensuring the join key is not null but also the `test_result==p` check. @@ -408,7 +421,7 @@ Spark's inner join is taking as inputs the two filtered relations; it's ensuring +- Filter ((isnotnull(test_result#14) AND (test_result#14 = P)) AND isnotnull(vehicle_id#10)) ``` -The Substrait Representation looks longer, but is showing the same structure. (note that this format is a custom format implemented as [SubstraitStingify](...) as the standard text output can be hard to read). +The Substrait Representation looks longer, but is showing the same structure. (note that this format is a custom format implemented as [SubstraitStingify](./src/main/java/io/substrait/examples/util/SubstraitStringify.java) as the standard text output can be hard to read). ``` +- Join:: INNER equal:any_any diff --git a/examples/substrait-spark/justfile b/examples/substrait-spark/justfile index 6fcad7666..37ed425cf 100644 --- a/examples/substrait-spark/justfile +++ b/examples/substrait-spark/justfile @@ -15,6 +15,7 @@ SPARK_MASTER_CONTAINER := "substrait-spark-spark-1" _default: @just -f {{justfile()}} --list +# Builds the application into a JAR file buildapp: #!/bin/bash set -e -o pipefail @@ -28,25 +29,28 @@ buildapp: cp ${CWDIR}/build/libs/substrait-spark*.jar ${CWDIR}/_apps/app.jar cp ${CWDIR}/src/main/resources/*.csv ${CWDIR}/_data +# Runs a Spark dataset api query and produces a Substrait plan dataset: #!/bin/bash set -e -o pipefail docker exec -it ${SPARK_MASTER_CONTAINER} bash -c "/opt/bitnami/spark/bin/spark-submit --master spark://${SPARK_MASTER_CONTAINER}:7077 --driver-memory 1G --executor-memory 1G /opt/spark-apps/app.jar SparkDataset" +# Runs a Spark SQL api query and produces a Substrait plan sql: #!/bin/bash set -e -o pipefail docker exec -it ${SPARK_MASTER_CONTAINER} bash -c "/opt/bitnami/spark/bin/spark-submit --master spark://${SPARK_MASTER_CONTAINER}:7077 --driver-memory 1G --executor-memory 1G /opt/spark-apps/app.jar SparkSQL" +# Consumes the Substrait plan file passed as the argument consume arg: #!/bin/bash set -e -o pipefail docker exec -it ${SPARK_MASTER_CONTAINER} bash -c "/opt/bitnami/spark/bin/spark-submit --master spark://${SPARK_MASTER_CONTAINER}:7077 --driver-memory 1G --executor-memory 1G /opt/spark-apps/app.jar SparkConsumeSubstrait {{arg}}" - +# Starts a simple Spark cluster locally in docker spark: #!/bin/bash set -e -o pipefail diff --git a/examples/substrait-spark/src/main/java/io/substrait/examples/SparkConsumeSubstrait.java b/examples/substrait-spark/src/main/java/io/substrait/examples/SparkConsumeSubstrait.java index 5f5436b05..26c15274f 100644 --- a/examples/substrait-spark/src/main/java/io/substrait/examples/SparkConsumeSubstrait.java +++ b/examples/substrait-spark/src/main/java/io/substrait/examples/SparkConsumeSubstrait.java @@ -2,6 +2,7 @@ import static io.substrait.examples.SparkHelper.ROOT_DIR; +import io.substrait.examples.util.SubstraitStringify; import io.substrait.plan.Plan; import io.substrait.plan.ProtoPlanConverter; import io.substrait.spark.logical.ToLogicalPlan; @@ -28,6 +29,8 @@ public void run(String arg) { ProtoPlanConverter protoToPlan = new ProtoPlanConverter(); Plan plan = protoToPlan.from(proto); + SubstraitStringify.explain(plan).forEach(System.out::println); + ToLogicalPlan substraitConverter = new ToLogicalPlan(spark); LogicalPlan sparkPlan = substraitConverter.convert(plan); diff --git a/examples/substrait-spark/src/main/java/io/substrait/examples/SparkDataset.java b/examples/substrait-spark/src/main/java/io/substrait/examples/SparkDataset.java index d5d4a488a..81de54b0b 100644 --- a/examples/substrait-spark/src/main/java/io/substrait/examples/SparkDataset.java +++ b/examples/substrait-spark/src/main/java/io/substrait/examples/SparkDataset.java @@ -4,6 +4,7 @@ import static io.substrait.examples.SparkHelper.TESTS_CSV; import static io.substrait.examples.SparkHelper.VEHICLES_CSV; +import io.substrait.examples.util.SubstraitStringify; import io.substrait.plan.PlanProtoConverter; import io.substrait.spark.logical.ToSubstraitRel; import java.io.IOException; @@ -70,7 +71,7 @@ public void createSubstrait(LogicalPlan enginePlan) { ToSubstraitRel toSubstrait = new ToSubstraitRel(); io.substrait.plan.Plan plan = toSubstrait.convert(enginePlan); - System.out.println(plan); + SubstraitStringify.explain(plan).forEach(System.out::println); PlanProtoConverter planToProto = new PlanProtoConverter(); byte[] buffer = planToProto.toProto(plan).toByteArray(); diff --git a/examples/substrait-spark/src/main/java/io/substrait/examples/SparkHelper.java b/examples/substrait-spark/src/main/java/io/substrait/examples/SparkHelper.java index f75140556..4f77a9c1c 100644 --- a/examples/substrait-spark/src/main/java/io/substrait/examples/SparkHelper.java +++ b/examples/substrait-spark/src/main/java/io/substrait/examples/SparkHelper.java @@ -7,21 +7,12 @@ public final class SparkHelper { private SparkHelper() {} - /** Namespace to use for the data */ - public static final String NAMESPACE = "demo_db"; - /** Vehicles table */ public static final String VEHICLE_TABLE = "vehicles"; /** Tests table (the vehicle safety tests) */ public static final String TESTS_TABLE = "tests"; - /** Source data - parquet */ - public static final String VEHICLES_PQ = "vehicles_subset_2023.parquet"; - - /** Source data - parquet */ - public static final String TESTS_PQ = "tests_subset_2023.parquet"; - /** Source data - csv */ public static final String VEHICLES_CSV = "vehicles_subset_2023.csv"; @@ -31,26 +22,6 @@ private SparkHelper() {} /** In-container data location */ public static final String ROOT_DIR = "/opt/spark-data"; - /** - * Connect to local spark for demo purposes - * - * @param sparkMaster address of the Spark Master to connect to - * @return SparkSession - */ - public static SparkSession connectSpark(String sparkMaster) { - - SparkSession spark = - SparkSession.builder() - // .config("spark.sql.warehouse.dir", "spark-warehouse") - .config("spark.master", sparkMaster) - .enableHiveSupport() - .getOrCreate(); - - spark.sparkContext().setLogLevel("ERROR"); - - return spark; - } - /** * Connects to the local spark cluister * diff --git a/examples/substrait-spark/src/main/java/io/substrait/examples/SparkSQL.java b/examples/substrait-spark/src/main/java/io/substrait/examples/SparkSQL.java index a927d0799..fc4c8fec4 100644 --- a/examples/substrait-spark/src/main/java/io/substrait/examples/SparkSQL.java +++ b/examples/substrait-spark/src/main/java/io/substrait/examples/SparkSQL.java @@ -6,6 +6,7 @@ import static io.substrait.examples.SparkHelper.VEHICLES_CSV; import static io.substrait.examples.SparkHelper.VEHICLE_TABLE; +import io.substrait.examples.util.SubstraitStringify; import io.substrait.plan.PlanProtoConverter; import io.substrait.spark.logical.ToSubstraitRel; import java.io.IOException; @@ -76,7 +77,8 @@ public void run(String arg) { public void createSubstrait(LogicalPlan enginePlan) { ToSubstraitRel toSubstrait = new ToSubstraitRel(); io.substrait.plan.Plan plan = toSubstrait.convert(enginePlan); - System.out.println(plan); + + SubstraitStringify.explain(plan).forEach(System.out::println); PlanProtoConverter planToProto = new PlanProtoConverter(); byte[] buffer = planToProto.toProto(plan).toByteArray(); diff --git a/examples/substrait-spark/src/main/java/io/substrait/examples/util/ExpressionStringify.java b/examples/substrait-spark/src/main/java/io/substrait/examples/util/ExpressionStringify.java new file mode 100644 index 000000000..6d0ddf69e --- /dev/null +++ b/examples/substrait-spark/src/main/java/io/substrait/examples/util/ExpressionStringify.java @@ -0,0 +1,276 @@ +package io.substrait.examples.util; + +import io.substrait.expression.Expression.BinaryLiteral; +import io.substrait.expression.Expression.BoolLiteral; +import io.substrait.expression.Expression.Cast; +import io.substrait.expression.Expression.DateLiteral; +import io.substrait.expression.Expression.DecimalLiteral; +import io.substrait.expression.Expression.EmptyListLiteral; +import io.substrait.expression.Expression.FP32Literal; +import io.substrait.expression.Expression.FP64Literal; +import io.substrait.expression.Expression.FixedBinaryLiteral; +import io.substrait.expression.Expression.FixedCharLiteral; +import io.substrait.expression.Expression.I16Literal; +import io.substrait.expression.Expression.I32Literal; +import io.substrait.expression.Expression.I64Literal; +import io.substrait.expression.Expression.I8Literal; +import io.substrait.expression.Expression.IfThen; +import io.substrait.expression.Expression.InPredicate; +import io.substrait.expression.Expression.IntervalDayLiteral; +import io.substrait.expression.Expression.IntervalYearLiteral; +import io.substrait.expression.Expression.ListLiteral; +import io.substrait.expression.Expression.MapLiteral; +import io.substrait.expression.Expression.MultiOrList; +import io.substrait.expression.Expression.NullLiteral; +import io.substrait.expression.Expression.ScalarFunctionInvocation; +import io.substrait.expression.Expression.ScalarSubquery; +import io.substrait.expression.Expression.SetPredicate; +import io.substrait.expression.Expression.SingleOrList; +import io.substrait.expression.Expression.StrLiteral; +import io.substrait.expression.Expression.StructLiteral; +import io.substrait.expression.Expression.Switch; +import io.substrait.expression.Expression.TimeLiteral; +import io.substrait.expression.Expression.TimestampLiteral; +import io.substrait.expression.Expression.TimestampTZLiteral; +import io.substrait.expression.Expression.UUIDLiteral; +import io.substrait.expression.Expression.UserDefinedLiteral; +import io.substrait.expression.Expression.VarCharLiteral; +import io.substrait.expression.Expression.WindowFunctionInvocation; +import io.substrait.expression.ExpressionVisitor; +import io.substrait.expression.FieldReference; + +/** ExpressionStringify gives a simple debug text output for Expressions */ +public class ExpressionStringify extends ParentStringify + implements ExpressionVisitor { + + public ExpressionStringify(int indent) { + super(indent); + } + + @Override + public String visit(NullLiteral expr) throws RuntimeException { + return ""; + } + + @Override + public String visit(BoolLiteral expr) throws RuntimeException { + return ""; + } + + @Override + public String visit(I8Literal expr) throws RuntimeException { + return ""; + } + + @Override + public String visit(I16Literal expr) throws RuntimeException { + return ""; + } + + @Override + public String visit(I32Literal expr) throws RuntimeException { + return ""; + } + + @Override + public String visit(I64Literal expr) throws RuntimeException { + return ""; + } + + @Override + public String visit(FP32Literal expr) throws RuntimeException { + return ""; + } + + @Override + public String visit(FP64Literal expr) throws RuntimeException { + return ""; + } + + @Override + public String visit(StrLiteral expr) throws RuntimeException { + return ""; + } + + @Override + public String visit(BinaryLiteral expr) throws RuntimeException { + return ""; + } + + @Override + public String visit(TimeLiteral expr) throws RuntimeException { + return ""; + } + + @Override + public String visit(DateLiteral expr) throws RuntimeException { + return ""; + } + + @Override + public String visit(TimestampLiteral expr) throws RuntimeException { + return ""; + } + + @Override + public String visit(TimestampTZLiteral expr) throws RuntimeException { + return ""; + } + + @Override + public String visit(IntervalYearLiteral expr) throws RuntimeException { + return ""; + } + + @Override + public String visit(IntervalDayLiteral expr) throws RuntimeException { + return ""; + } + + @Override + public String visit(UUIDLiteral expr) throws RuntimeException { + return ""; + } + + @Override + public String visit(FixedCharLiteral expr) throws RuntimeException { + return ""; + } + + @Override + public String visit(VarCharLiteral expr) throws RuntimeException { + return ""; + } + + @Override + public String visit(FixedBinaryLiteral expr) throws RuntimeException { + return ""; + } + + @Override + public String visit(DecimalLiteral expr) throws RuntimeException { + return ""; + } + + @Override + public String visit(MapLiteral expr) throws RuntimeException { + return ""; + } + + @Override + public String visit(ListLiteral expr) throws RuntimeException { + return ""; + } + + @Override + public String visit(EmptyListLiteral expr) throws RuntimeException { + return ""; + } + + @Override + public String visit(StructLiteral expr) throws RuntimeException { + return ""; + } + + @Override + public String visit(UserDefinedLiteral expr) throws RuntimeException { + return ""; + } + + @Override + public String visit(Switch expr) throws RuntimeException { + return ""; + } + + @Override + public String visit(IfThen expr) throws RuntimeException { + return ""; + } + + @Override + public String visit(ScalarFunctionInvocation expr) throws RuntimeException { + var sb = new StringBuilder(""); + + sb.append(expr.declaration()); + // sb.append(" ("); + var args = expr.arguments(); + for (var i = 0; i < args.size(); i++) { + var arg = args.get(i); + sb.append(getContinuationIndentString()); + sb.append("arg" + i + " = "); + var funcArgVisitor = new FunctionArgStringify(indent); + + sb.append(arg.accept(expr.declaration(), i, funcArgVisitor)); + sb.append(" "); + } + return sb.toString(); + } + + @Override + public String visit(WindowFunctionInvocation expr) throws RuntimeException { + var sb = new StringBuilder("WindowFunctionInvocation#"); + + return sb.toString(); + } + + @Override + public String visit(Cast expr) throws RuntimeException { + var sb = new StringBuilder(""); + return sb.toString(); + } + + @Override + public String visit(SingleOrList expr) throws RuntimeException { + var sb = new StringBuilder("SingleOrList#"); + + return sb.toString(); + } + + @Override + public String visit(MultiOrList expr) throws RuntimeException { + var sb = new StringBuilder("Cast#"); + + return sb.toString(); + } + + @Override + public String visit(FieldReference expr) throws RuntimeException { + StringBuilder sb = new StringBuilder("FieldRef#"); + var type = expr.getType(); + // sb.append(expr.inputExpression()); + sb.append("/").append(type.accept(new TypeStringify(indent))).append("/"); + expr.segments() + .forEach( + s -> { + sb.append(s).append(" "); + }); + + return sb.toString(); + } + + @Override + public String visit(SetPredicate expr) throws RuntimeException { + var sb = new StringBuilder("SetPredicate#"); + + return sb.toString(); + } + + @Override + public String visit(ScalarSubquery expr) throws RuntimeException { + var sb = new StringBuilder("ScalarSubquery#"); + + return sb.toString(); + } + + @Override + public String visit(InPredicate expr) throws RuntimeException { + var sb = new StringBuilder("InPredicate#"); + + return sb.toString(); + } +} diff --git a/examples/substrait-spark/src/main/java/io/substrait/examples/util/FunctionArgStringify.java b/examples/substrait-spark/src/main/java/io/substrait/examples/util/FunctionArgStringify.java new file mode 100644 index 000000000..d910a17ae --- /dev/null +++ b/examples/substrait-spark/src/main/java/io/substrait/examples/util/FunctionArgStringify.java @@ -0,0 +1,31 @@ +package io.substrait.examples.util; + +import io.substrait.expression.EnumArg; +import io.substrait.expression.Expression; +import io.substrait.expression.FunctionArg.FuncArgVisitor; +import io.substrait.extension.SimpleExtension.Function; +import io.substrait.type.Type; + +/** FunctionArgStrngify produces a simple debug string for Funcation Arguments */ +public class FunctionArgStringify extends ParentStringify + implements FuncArgVisitor { + + public FunctionArgStringify(int indent) { + super(indent); + } + + @Override + public String visitExpr(Function fnDef, int argIdx, Expression e) throws RuntimeException { + return e.accept(new ExpressionStringify(indent + 1)); + } + + @Override + public String visitType(Function fnDef, int argIdx, Type t) throws RuntimeException { + return t.accept(new TypeStringify(indent)); + } + + @Override + public String visitEnumArg(Function fnDef, int argIdx, EnumArg e) throws RuntimeException { + return e.toString(); + } +} diff --git a/examples/substrait-spark/src/main/java/io/substrait/examples/util/ParentStringify.java b/examples/substrait-spark/src/main/java/io/substrait/examples/util/ParentStringify.java new file mode 100644 index 000000000..ace9fb9a3 --- /dev/null +++ b/examples/substrait-spark/src/main/java/io/substrait/examples/util/ParentStringify.java @@ -0,0 +1,57 @@ +package io.substrait.examples.util; + +/** + * Parent class of all stringifiers Created as it seemed there could be a an optimization to share + * formatting fns between the various stringifiers + */ +public class ParentStringify { + + protected String indentChar = " "; + protected int indent = 0; + protected int indentSize = 3; + + /** + * Build with a specific indent at the start - note 'an indent' is set by default to be 3 spaces. + * + * @param indent number of indentes + */ + public ParentStringify(int indent) { + this.indent = indent; + } + + StringBuilder getIndent() { + + var sb = new StringBuilder(); + if (indent != 0) { + sb.append("\n"); + } + sb.append(getIndentString()); + + indent++; + return sb; + } + + StringBuilder getIndentString() { + + var sb = new StringBuilder(); + sb.append(indentChar.repeat(this.indent * this.indentSize)); + sb.append("+- "); + return sb; + } + + StringBuilder getContinuationIndentString() { + + var sb = new StringBuilder(); + if (indent != 0) { + sb.append("\n"); + } + sb.append(indentChar.repeat(this.indent * this.indentSize)); + sb.append(" : "); + return sb; + } + + protected String getOutdent(StringBuilder sb) { + indent--; + return (sb).toString(); + } +} diff --git a/examples/substrait-spark/src/main/java/io/substrait/examples/util/SubstraitStringify.java b/examples/substrait-spark/src/main/java/io/substrait/examples/util/SubstraitStringify.java new file mode 100644 index 000000000..070d64bb0 --- /dev/null +++ b/examples/substrait-spark/src/main/java/io/substrait/examples/util/SubstraitStringify.java @@ -0,0 +1,336 @@ +package io.substrait.examples.util; + +import io.substrait.relation.Aggregate; +import io.substrait.relation.ConsistentPartitionWindow; +import io.substrait.relation.Cross; +import io.substrait.relation.EmptyScan; +import io.substrait.relation.ExtensionLeaf; +import io.substrait.relation.ExtensionMulti; +import io.substrait.relation.ExtensionSingle; +import io.substrait.relation.ExtensionTable; +import io.substrait.relation.Fetch; +import io.substrait.relation.Filter; +import io.substrait.relation.Join; +import io.substrait.relation.LocalFiles; +import io.substrait.relation.NamedScan; +import io.substrait.relation.Project; +import io.substrait.relation.Rel; +import io.substrait.relation.RelVisitor; +import io.substrait.relation.Set; +import io.substrait.relation.Sort; +import io.substrait.relation.VirtualTableScan; +import io.substrait.relation.physical.HashJoin; +import io.substrait.relation.physical.MergeJoin; +import io.substrait.relation.physical.NestedLoopJoin; +import io.substrait.type.NamedStruct; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +/** + * SubstraitStringify produces a string format output of the Substrait plan or relation + * + *

This is intended for debug and development purposes only, and follows a similar style to the + * `explain` API in libraries suck as Spark Calcite etc. + * + *

Usage: + * + *

+ * io.substrait.plan.Plan plan = toSubstrait.convert(enginePlan);
+ * SubstraitStringify.explain(plan).forEach(System.out::println);
+ * 
+ * + * There is scope for improving this output; there are some gaps in the lesser used relations This + * is not a replacement for any canoncial form and is only for ease of debugging + */ +public class SubstraitStringify extends ParentStringify + implements RelVisitor { + + public SubstraitStringify() { + super(0); + } + + /** + * Explains the Sustrait plan + * + * @param plan Subsrait plan + * @return List of strings; typically these would then be logged or sent to stdout + */ + public static List explain(io.substrait.plan.Plan plan) { + var explanations = new ArrayList(); + explanations.add(""); + + plan.getRoots() + .forEach( + root -> { + var rel = root.getInput(); + + explanations.add("Root:: " + rel.getClass().getSimpleName() + " " + root.getNames()); + explanations.addAll(explain(rel)); + }); + + return explanations; + } + + /** + * Explains the Sustrait relation + * + * @param plan Subsrait relation + * @return List of strings; typically these would then be logged or sent to stdout + */ + public static List explain(io.substrait.relation.Rel rel) { + var s = new SubstraitStringify(); + + List explanation = new ArrayList(); + explanation.add(""); + explanation.addAll(Arrays.asList(rel.accept(s).split("\n"))); + return explanation; + } + + private boolean showRemap = false; + + private List fieldList(List fields) { + return fields.stream().map(t -> t.accept(new TypeStringify(0))).collect(Collectors.toList()); + } + + private String getRemap(Rel rel) { + if (!showRemap) { + return ""; + } + var fieldCount = rel.getRecordType().fields().size(); + var remap = rel.getRemap(); + var recordType = fieldList(rel.getRecordType().fields()); + + if (remap.isPresent()) { + return "/Remapping fields (" + + fieldCount + + ") " + + remap.get().indices() + + " as " + + recordType + + "/ "; + } else { + return "/No Remap (" + fieldCount + ") " + recordType + "/ "; + } + } + + @Override + public String visit(Aggregate aggregate) throws RuntimeException { + StringBuilder sb = getIndent().append("Aggregate:: ").append(getRemap(aggregate)); + aggregate + .getGroupings() + .forEach( + g -> { + g.getExpressions() + .forEach( + expr -> { + sb.append(expr.accept(new ExpressionStringify(this.indent))); + }); + }); + aggregate + .getInputs() + .forEach( + s -> { + sb.append(s.accept(this)); + }); + aggregate.getRemap().ifPresent(s -> sb.append(s.toString())); + + return getOutdent(sb); + } + + @Override + public String visit(EmptyScan emptyScan) throws RuntimeException { + var sb = new StringBuilder("EmptyScan:: ").append(getRemap(emptyScan)); + // sb.append(emptyScan.accept(this)); + return getOutdent(sb); + } + + @Override + public String visit(Fetch fetch) throws RuntimeException { + var sb = new StringBuilder("Fetch:: "); + // sb.append(fetch.accept(this)); + return getOutdent(sb); + } + + @Override + public String visit(Filter filter) throws RuntimeException { + var sb = getIndent().append("Filter:: ").append(getRemap(filter)); + // .append("{ "); + sb.append(filter.getCondition().accept(new ExpressionStringify(indent))) /* .append(")") */; + filter + .getInputs() + .forEach( + i -> { + sb.append(i.accept(this)); + }); + + return getOutdent(sb); + } + + @Override + public String visit(Join join) throws RuntimeException { + + var sb = + getIndent().append("Join:: ").append(join.getJoinType()).append(" ").append(getRemap(join)); + + if (join.getCondition().isPresent()) { + sb.append(join.getCondition().get().accept(new ExpressionStringify(indent))); + } + + sb.append(join.getLeft().accept(this)); + sb.append(join.getRight().accept(this)); + + return getOutdent(sb); + } + + @Override + public String visit(Set set) throws RuntimeException { + StringBuilder sb = getIndent().append("Set:: "); + return getOutdent(sb); + } + + @Override + public String visit(NamedScan namedScan) throws RuntimeException { + + StringBuilder sb = getIndent().append("NamedScan:: ").append(getRemap(namedScan)); + namedScan + .getInputs() + .forEach( + i -> { + sb.append(i.accept(this)); + }); + sb.append(" Tables="); + sb.append(namedScan.getNames()); + sb.append(" Fields="); + sb.append(namedStruct(namedScan.getInitialSchema())); + return getOutdent(sb); + } + + private String namedStruct(NamedStruct struct) { + var sb = new StringBuilder(); + + var names = struct.names(); + var types = fieldList(struct.struct().fields()); + + for (var x = 0; x < names.size(); x++) { + if (x != 0) { + sb.append(","); + } + sb.append(names.get(x)).append("[").append(types.get(x)).append("]"); + } + + return sb.toString(); + } + + @Override + public String visit(LocalFiles localFiles) throws RuntimeException { + StringBuilder sb = getIndent().append("LocalFiles:: "); + + for (var i : localFiles.getItems()) { + sb.append(getContinuationIndentString()); + var fileFormat = ""; + if (i.getFileFormat().isPresent()) { + fileFormat = i.getFileFormat().get().toString(); + } + + sb.append( + String.format( + "%s %s len=%d partition=%d start=%d", + fileFormat, i.getPath().get(), i.getLength(), i.getPartitionIndex(), i.getStart())); + } + + return getOutdent(sb); + } + + @Override + public String visit(Project project) throws RuntimeException { + StringBuilder sb = getIndent().append("Project:: ").append(getRemap(project)); + + sb.append(fieldList(project.deriveRecordType().fields())); + + var inputs = project.getInputs(); + inputs.forEach( + i -> { + sb.append(i.accept(this)); + }); + return getOutdent(sb); + } + + @Override + public String visit(Sort sort) throws RuntimeException { + StringBuilder sb = getIndent().append("Sort:: ").append(getRemap(sort)); + sort.getSortFields() + .forEach( + sf -> { + var expr = new ExpressionStringify(indent); + sb.append(sf.expr().accept(expr)).append(" ").append(sf.direction()); + }); + var inputs = sort.getInputs(); + inputs.forEach( + i -> { + sb.append(i.accept(this)); + }); + return getOutdent(sb); + } + + @Override + public String visit(Cross cross) throws RuntimeException { + StringBuilder sb = getIndent().append("Cross:: "); + return getOutdent(sb); + } + + @Override + public String visit(VirtualTableScan virtualTableScan) throws RuntimeException { + StringBuilder sb = getIndent().append("VirtualTableScan:: "); + return getOutdent(sb); + } + + @Override + public String visit(ExtensionLeaf extensionLeaf) throws RuntimeException { + StringBuilder sb = getIndent().append("extensionLeaf:: "); + return getOutdent(sb); + } + + @Override + public String visit(ExtensionSingle extensionSingle) throws RuntimeException { + StringBuilder sb = getIndent().append("extensionSingle:: "); + return getOutdent(sb); + } + + @Override + public String visit(ExtensionMulti extensionMulti) throws RuntimeException { + StringBuilder sb = getIndent().append("extensionMulti:: "); + return getOutdent(sb); + } + + @Override + public String visit(ExtensionTable extensionTable) throws RuntimeException { + StringBuilder sb = getIndent().append("extensionTable:: "); + return getOutdent(sb); + } + + @Override + public String visit(HashJoin hashJoin) throws RuntimeException { + StringBuilder sb = getIndent().append("hashJoin:: "); + return getOutdent(sb); + } + + @Override + public String visit(MergeJoin mergeJoin) throws RuntimeException { + StringBuilder sb = getIndent().append("mergeJoin:: "); + return getOutdent(sb); + } + + @Override + public String visit(NestedLoopJoin nestedLoopJoin) throws RuntimeException { + StringBuilder sb = getIndent().append("nestedLoopJoin:: "); + return getOutdent(sb); + } + + @Override + public String visit(ConsistentPartitionWindow consistentPartitionWindow) throws RuntimeException { + StringBuilder sb = getIndent().append("consistentPartitionWindow:: "); + return getOutdent(sb); + } +} diff --git a/examples/substrait-spark/src/main/java/io/substrait/examples/util/TypeStringify.java b/examples/substrait-spark/src/main/java/io/substrait/examples/util/TypeStringify.java new file mode 100644 index 000000000..d17a70709 --- /dev/null +++ b/examples/substrait-spark/src/main/java/io/substrait/examples/util/TypeStringify.java @@ -0,0 +1,175 @@ +package io.substrait.examples.util; + +import io.substrait.type.Type; +import io.substrait.type.Type.Binary; +import io.substrait.type.Type.Bool; +import io.substrait.type.Type.Date; +import io.substrait.type.Type.Decimal; +import io.substrait.type.Type.FP32; +import io.substrait.type.Type.FP64; +import io.substrait.type.Type.FixedBinary; +import io.substrait.type.Type.FixedChar; +import io.substrait.type.Type.I16; +import io.substrait.type.Type.I32; +import io.substrait.type.Type.I64; +import io.substrait.type.Type.I8; +import io.substrait.type.Type.IntervalDay; +import io.substrait.type.Type.IntervalYear; +import io.substrait.type.Type.ListType; +import io.substrait.type.Type.Map; +import io.substrait.type.Type.Str; +import io.substrait.type.Type.Struct; +import io.substrait.type.Type.Time; +import io.substrait.type.Type.Timestamp; +import io.substrait.type.Type.TimestampTZ; +import io.substrait.type.Type.UUID; +import io.substrait.type.Type.UserDefined; +import io.substrait.type.Type.VarChar; +import io.substrait.type.TypeVisitor; + +/** TypeStrinify produces a simple debug string of Substrait types */ +public class TypeStringify extends ParentStringify + implements TypeVisitor { + + protected TypeStringify(int indent) { + super(indent); + } + + @Override + public String visit(I64 type) throws RuntimeException { + return type.getClass().getSimpleName(); + } + + @Override + public String visit(Bool type) throws RuntimeException { + return type.getClass().getSimpleName(); + } + + @Override + public String visit(I8 type) throws RuntimeException { + return type.getClass().getSimpleName(); + } + + @Override + public String visit(I16 type) throws RuntimeException { + return type.getClass().getSimpleName(); + } + + @Override + public String visit(I32 type) throws RuntimeException { + return type.getClass().getSimpleName(); + } + + @Override + public String visit(FP32 type) throws RuntimeException { + return type.getClass().getSimpleName(); + } + + @Override + public String visit(FP64 type) throws RuntimeException { + return type.getClass().getSimpleName(); + } + + @Override + public String visit(Str type) throws RuntimeException { + return type.getClass().getSimpleName(); + } + + @Override + public String visit(Binary type) throws RuntimeException { + return type.getClass().getSimpleName(); + } + + @Override + public String visit(Date type) throws RuntimeException { + return type.getClass().getSimpleName(); + } + + @Override + public String visit(Time type) throws RuntimeException { + return type.getClass().getSimpleName(); + } + + @Override + @Deprecated + public String visit(TimestampTZ type) throws RuntimeException { + return type.getClass().getSimpleName(); + } + + @Override + @Deprecated + public String visit(Timestamp type) throws RuntimeException { + return type.getClass().getSimpleName(); + } + + @Override + public String visit(Type.PrecisionTimestamp type) throws RuntimeException { + return type.getClass().getSimpleName(); + } + + @Override + public String visit(Type.PrecisionTimestampTZ type) throws RuntimeException { + return type.getClass().getSimpleName(); + } + + @Override + public String visit(IntervalYear type) throws RuntimeException { + return type.getClass().getSimpleName(); + } + + @Override + public String visit(IntervalDay type) throws RuntimeException { + return type.getClass().getSimpleName(); + } + + @Override + public String visit(UUID type) throws RuntimeException { + return type.getClass().getSimpleName(); + } + + @Override + public String visit(FixedChar type) throws RuntimeException { + return type.getClass().getSimpleName(); + } + + @Override + public String visit(VarChar type) throws RuntimeException { + return type.getClass().getSimpleName(); + } + + @Override + public String visit(FixedBinary type) throws RuntimeException { + return type.getClass().getSimpleName(); + } + + @Override + public String visit(Decimal type) throws RuntimeException { + return type.getClass().getSimpleName(); + } + + @Override + public String visit(Struct type) throws RuntimeException { + var sb = new StringBuffer(type.getClass().getSimpleName()); + type.fields() + .forEach( + f -> { + sb.append(f.accept(this)); + }); + return sb.toString(); + } + + @Override + public String visit(ListType type) throws RuntimeException { + return type.getClass().getSimpleName(); + } + + @Override + public String visit(Map type) throws RuntimeException { + return type.getClass().getSimpleName(); + } + + @Override + public String visit(UserDefined type) throws RuntimeException { + return type.getClass().getSimpleName(); + } +} From dc89c80e285d47d1f55433282586fda4128d4c99 Mon Sep 17 00:00:00 2001 From: Victor Barua Date: Thu, 3 Oct 2024 16:01:57 -0700 Subject: [PATCH 7/8] docs: minor doc updates --- examples/substrait-spark/README.md | 18 +++++++++--------- .../examples/util/ExpressionStringify.java | 2 +- .../examples/util/FunctionArgStringify.java | 2 +- .../examples/util/ParentStringify.java | 2 +- .../examples/util/SubstraitStringify.java | 2 ++ .../substrait/examples/util/TypeStringify.java | 2 +- 6 files changed, 15 insertions(+), 13 deletions(-) diff --git a/examples/substrait-spark/README.md b/examples/substrait-spark/README.md index 8cbc42677..b3bca448b 100644 --- a/examples/substrait-spark/README.md +++ b/examples/substrait-spark/README.md @@ -127,7 +127,7 @@ Firstly the filenames are created, and the CSV files read. Temporary views need ### Creating the SQL query -The standard SQL query string as an example will find the counts of all cars (arranged by colour) of all vehicles that have passed the vehicle safety test. +The following standard SQL query string finds the counts of all cars (grouped by colour) of all vehicles that have passed the vehicle safety test. ```java String sqlQuery = """ @@ -202,7 +202,7 @@ Sort [colourcount#30L ASC NULLS FIRST], true ### Dataset API -Alternatively, the dataset API can be used to create the plans, the code for this in [`SparkDataset`](./app/src/main/java/io/substrait/examples/SparkDataset.java). The overall flow of the code is very similar +Alternatively, the Dataset API can be used to create the plans, the code for this in [`SparkDataset`](./app/src/main/java/io/substrait/examples/SparkDataset.java). The overall flow of the code is very similar Rather than create a temporary view, the reference to the datasets are kept in `dsVehicles` and `dsTests` ```java @@ -242,7 +242,7 @@ Sort [count#189L ASC NULLS FIRST], true ### Substrait Creation -This optimized plan is the best starting point to produce a Substrait Plan; there's a `createSubstrait(..)` function that does the work and writes a binary protobuf file (`spark) +This optimized plan is the best starting point to produce a Substrait Plan; there's a `createSubstrait(..)` function that does the work and produces a binary protobuf Substrait file. ``` LogicalPlan optimised = result.queryExecution().optimizedPlan(); @@ -258,7 +258,7 @@ Let's look at the APIs in the `createSubstrait(...)` method to see how it's usin io.substrait.plan.Plan plan = toSubstrait.convert(enginePlan); ``` -`ToSubstraitRel` is the main class and provides the convert method; this takes the Spark plan (optimized plan is best) and produce the Substrait Plan. The most common relations are supported currently - and the optimized plan is more likely to use these. +`ToSubstraitRel` is the main class and provides the convert method; this takes the Spark plan (optimized plan is best) and produces the Substrait Plan. The most common relations are supported currently - and the optimized plan is more likely to use these. The `io.substrait.plan.Plan` object is a high-level Substrait POJO representing a plan. This could be used directly or more likely be persisted. protobuf is the canonical serialization form. It's easy to convert this and store in a file @@ -274,7 +274,7 @@ The `io.substrait.plan.Plan` object is a high-level Substrait POJO representing For the dataset approach, the `spark_dataset_substrait.plan` is created, and for the SQL approach the `spark_sql_substrait.plan` is created. These Intermediate Representations of the query can be saved, transferred and reloaded into a Data Engine. -We can also review the Substrait plan's structure; the canonical format of the Substrait plan is the binary protobuf format, but it's possible to produce a textual version, an example is below (please see the [SubstraitStringify utility class](./src/main/java/io/substrait/examples/util/SubstraitStringify.java); it's also a good example of how to use some if the vistor patterns). Both the Substrait plans from the Dataset or SQL APIs generate the same output. +We can also review the Substrait plan's structure; the canonical format of the Substrait plan is the binary protobuf format, but it's possible to produce a textual version, an example is below (please see the [SubstraitStringify utility class](./src/main/java/io/substrait/examples/util/SubstraitStringify.java); it's also a good example of how to use some of the visitor patterns). Both the Substrait plans from the Dataset or SQL APIs generate the same output. ``` @@ -306,16 +306,16 @@ Root :: ImmutableSort [colour, count] : file:///opt/spark-data/tests_subset_2023.csv len=1491 partition=0 start=0 ``` -There is a more detail in this version that the Spark versions; details of the functions called for example are included. However, the structure of the overall plan is identical with 1 exception. There is an additional `project` relation included between the `sort` and `aggregate` - this is necessary to get the correct types of the output data. +There is more detail in this version than the Spark version; details of the functions called for example are included. However, the structure of the overall plan is identical with 1 exception. There is an additional `project` relation included between the `sort` and `aggregate` - this is necessary to get the correct types of the output data. We can also see in this case as the plan came from Spark directly it's also included the location of the datafiles. Below when we reload this into Spark, the locations of the files don't need to be explicitly included. -As `Substrait Spark` library also allows plans to be loaded and executed, so the next step is to consume these Substrait plans. +As the `Substrait Spark` library also allows plans to be loaded and executed, so the next step is to consume these Substrait plans. ## Consuming a Substrait Plan -The [`SparkConsumeSubstrait`](./app/src/main/java/io/substrait/examples/SparkConsumeSubstrait.java) code shows how to load this file, and most importantly how to convert it to a Spark engine plan to execute +The [`SparkConsumeSubstrait`](./app/src/main/java/io/substrait/examples/SparkConsumeSubstrait.java) code shows how to load this file, and most importantly how to convert it to a Spark engine plan to execute. Loading the binary protobuf file is the reverse of the writing process (in the code the file name comes from a command line argument, here we're showing the hardcoded file name ) @@ -327,7 +327,7 @@ Loading the binary protobuf file is the reverse of the writing process (in the c Plan plan = protoToPlan.from(proto); ``` -The loaded byte array is first converted into the protobuf Plan, and then into the Substrait Plan object. Note it can be useful to name the variables, and/or use the full class names to keep track of it's the ProtoBuf Plan or the high-level POJO Plan. For example `io.substrait.proto.Plan` or `io.substrait.Plan` +The loaded byte array is first converted into the protobuf Plan, and then into the Substrait Plan object. Note it can be useful to name the variables, and/or use the full class names to keep track whether it's the ProtoBuf Plan or the high-level POJO Plan. For example `io.substrait.proto.Plan` or `io.substrait.Plan` Finally this can be converted to a Spark Plan: diff --git a/examples/substrait-spark/src/main/java/io/substrait/examples/util/ExpressionStringify.java b/examples/substrait-spark/src/main/java/io/substrait/examples/util/ExpressionStringify.java index 6d0ddf69e..e8630200e 100644 --- a/examples/substrait-spark/src/main/java/io/substrait/examples/util/ExpressionStringify.java +++ b/examples/substrait-spark/src/main/java/io/substrait/examples/util/ExpressionStringify.java @@ -114,7 +114,7 @@ public String visit(TimestampLiteral expr) throws RuntimeException { @Override public String visit(TimestampTZLiteral expr) throws RuntimeException { - return ""; + return ""; } @Override diff --git a/examples/substrait-spark/src/main/java/io/substrait/examples/util/FunctionArgStringify.java b/examples/substrait-spark/src/main/java/io/substrait/examples/util/FunctionArgStringify.java index d910a17ae..bcaf6dc1e 100644 --- a/examples/substrait-spark/src/main/java/io/substrait/examples/util/FunctionArgStringify.java +++ b/examples/substrait-spark/src/main/java/io/substrait/examples/util/FunctionArgStringify.java @@ -6,7 +6,7 @@ import io.substrait.extension.SimpleExtension.Function; import io.substrait.type.Type; -/** FunctionArgStrngify produces a simple debug string for Funcation Arguments */ +/** FunctionArgStringify produces a simple debug string for Function Arguments */ public class FunctionArgStringify extends ParentStringify implements FuncArgVisitor { diff --git a/examples/substrait-spark/src/main/java/io/substrait/examples/util/ParentStringify.java b/examples/substrait-spark/src/main/java/io/substrait/examples/util/ParentStringify.java index ace9fb9a3..30cc30a68 100644 --- a/examples/substrait-spark/src/main/java/io/substrait/examples/util/ParentStringify.java +++ b/examples/substrait-spark/src/main/java/io/substrait/examples/util/ParentStringify.java @@ -1,7 +1,7 @@ package io.substrait.examples.util; /** - * Parent class of all stringifiers Created as it seemed there could be a an optimization to share + * Parent class of all stringifiers Created as it seemed there could be an optimization to share * formatting fns between the various stringifiers */ public class ParentStringify { diff --git a/examples/substrait-spark/src/main/java/io/substrait/examples/util/SubstraitStringify.java b/examples/substrait-spark/src/main/java/io/substrait/examples/util/SubstraitStringify.java index 070d64bb0..6b6d1b05b 100644 --- a/examples/substrait-spark/src/main/java/io/substrait/examples/util/SubstraitStringify.java +++ b/examples/substrait-spark/src/main/java/io/substrait/examples/util/SubstraitStringify.java @@ -43,6 +43,8 @@ * * There is scope for improving this output; there are some gaps in the lesser used relations This * is not a replacement for any canoncial form and is only for ease of debugging + * + * TODO: https://github.com/substrait-io/substrait-java/issues/302 which tracks the full implementation of this */ public class SubstraitStringify extends ParentStringify implements RelVisitor { diff --git a/examples/substrait-spark/src/main/java/io/substrait/examples/util/TypeStringify.java b/examples/substrait-spark/src/main/java/io/substrait/examples/util/TypeStringify.java index d17a70709..796e9ca6b 100644 --- a/examples/substrait-spark/src/main/java/io/substrait/examples/util/TypeStringify.java +++ b/examples/substrait-spark/src/main/java/io/substrait/examples/util/TypeStringify.java @@ -27,7 +27,7 @@ import io.substrait.type.Type.VarChar; import io.substrait.type.TypeVisitor; -/** TypeStrinify produces a simple debug string of Substrait types */ +/** TypeStringify produces a simple debug string of Substrait types */ public class TypeStringify extends ParentStringify implements TypeVisitor { From 6f81cc549a8eb3c3b665dfd86bffdac9a2a6f742 Mon Sep 17 00:00:00 2001 From: MBWhite Date: Fri, 4 Oct 2024 08:34:09 +0100 Subject: [PATCH 8/8] feat: clean up example to address later Signed-off-by: MBWhite --- examples/substrait-spark/README.md | 19 ------------------- .../examples/util/SubstraitStringify.java | 3 ++- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/examples/substrait-spark/README.md b/examples/substrait-spark/README.md index b3bca448b..d9885dd83 100644 --- a/examples/substrait-spark/README.md +++ b/examples/substrait-spark/README.md @@ -388,25 +388,6 @@ To recap on the steps above The structure of the query plans for both Spark and Substrait are structurally very similar. -### Aggregate and Sort - -Spark's plan has a Project that filters down to the colour, followed by the Aggregation and Sort. -``` -+- Sort [count(1)#18L ASC NULLS FIRST], true - +- Aggregate [colour#5], [colour#5, count(1) AS count(1)#18L] - +- Project [colour#5] -``` - -When converted to Substrait the Sort and Aggregate is in the same order, but there are additional projects; it's not reduced the number of fields as early. - -``` -+- Sort:: FieldRef#/I64/StructField{offset=1} ASC_NULLS_FIRST - +- Project:: [Str, I64, Str, I64] - +- Aggregate:: FieldRef#/Str/StructField{offset=0} -``` - -These look different due to two factors. Firstly the Spark optimizer has swapped the project and aggregate functions. -Secondly projects within the Substrait plan joined the fields together but don't reduce the number of fields. Any such filtering is done on the outer relations. ### Inner Join diff --git a/examples/substrait-spark/src/main/java/io/substrait/examples/util/SubstraitStringify.java b/examples/substrait-spark/src/main/java/io/substrait/examples/util/SubstraitStringify.java index 6b6d1b05b..f650e8f62 100644 --- a/examples/substrait-spark/src/main/java/io/substrait/examples/util/SubstraitStringify.java +++ b/examples/substrait-spark/src/main/java/io/substrait/examples/util/SubstraitStringify.java @@ -44,7 +44,8 @@ * There is scope for improving this output; there are some gaps in the lesser used relations This * is not a replacement for any canoncial form and is only for ease of debugging * - * TODO: https://github.com/substrait-io/substrait-java/issues/302 which tracks the full implementation of this + *

TODO: https://github.com/substrait-io/substrait-java/issues/302 which tracks the full + * implementation of this */ public class SubstraitStringify extends ParentStringify implements RelVisitor {