spark-connect 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +173 -1
- data/README.md +37 -13
- data/lib/spark_connect/arrow.rb +5 -1
- data/lib/spark_connect/catalog.rb +157 -12
- data/lib/spark_connect/channel_builder.rb +119 -12
- data/lib/spark_connect/client.rb +237 -17
- data/lib/spark_connect/column.rb +143 -10
- data/lib/spark_connect/conf.rb +17 -1
- data/lib/spark_connect/data_frame.rb +447 -25
- data/lib/spark_connect/functions.rb +1948 -494
- data/lib/spark_connect/grouped_data.rb +33 -7
- data/lib/spark_connect/merge.rb +146 -0
- data/lib/spark_connect/na_functions.rb +43 -9
- data/lib/spark_connect/observation.rb +26 -5
- data/lib/spark_connect/pipelines.rb +101 -0
- data/lib/spark_connect/plan.rb +52 -0
- data/lib/spark_connect/proto/spark/connect/base_pb.rb +6 -1
- data/lib/spark_connect/proto/spark/connect/base_services_pb.rb +2 -0
- data/lib/spark_connect/proto/spark/connect/catalog_pb.rb +11 -1
- data/lib/spark_connect/proto/spark/connect/commands_pb.rb +1 -1
- data/lib/spark_connect/proto/spark/connect/expressions_pb.rb +3 -1
- data/lib/spark_connect/proto/spark/connect/pipelines_pb.rb +5 -1
- data/lib/spark_connect/proto/spark/connect/relations_pb.rb +4 -1
- data/lib/spark_connect/proto/spark/connect/types_pb.rb +3 -1
- data/lib/spark_connect/reader.rb +98 -17
- data/lib/spark_connect/session.rb +232 -30
- data/lib/spark_connect/stat_functions.rb +19 -9
- data/lib/spark_connect/storage_level.rb +79 -0
- data/lib/spark_connect/streaming.rb +335 -20
- data/lib/spark_connect/table_arg.rb +73 -0
- data/lib/spark_connect/tvf.rb +78 -0
- data/lib/spark_connect/types.rb +149 -0
- data/lib/spark_connect/version.rb +2 -2
- data/lib/spark_connect/writer.rb +80 -10
- data/lib/spark_connect.rb +4 -0
- data/proto/spark/connect/base.proto +93 -1
- data/proto/spark/connect/catalog.proto +81 -0
- data/proto/spark/connect/commands.proto +7 -0
- data/proto/spark/connect/expressions.proto +28 -0
- data/proto/spark/connect/pipelines.proto +92 -5
- data/proto/spark/connect/relations.proto +71 -1
- data/proto/spark/connect/types.proto +19 -0
- metadata +10 -3
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
require 'google/protobuf'
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
descriptor_data = "\n\x19spark/connect/types.proto\x12\rspark.connect\"\
|
|
8
|
+
descriptor_data = "\n\x19spark/connect/types.proto\x12\rspark.connect\"\x8a\x1f\n\x08\x44\x61taType\x12,\n\x04null\x18\x01 \x01(\x0b\x32\x1c.spark.connect.DataType.NULLH\x00\x12\x30\n\x06\x62inary\x18\x02 \x01(\x0b\x32\x1e.spark.connect.DataType.BinaryH\x00\x12\x32\n\x07\x62oolean\x18\x03 \x01(\x0b\x32\x1f.spark.connect.DataType.BooleanH\x00\x12,\n\x04\x62yte\x18\x04 \x01(\x0b\x32\x1c.spark.connect.DataType.ByteH\x00\x12.\n\x05short\x18\x05 \x01(\x0b\x32\x1d.spark.connect.DataType.ShortH\x00\x12\x32\n\x07integer\x18\x06 \x01(\x0b\x32\x1f.spark.connect.DataType.IntegerH\x00\x12,\n\x04long\x18\x07 \x01(\x0b\x32\x1c.spark.connect.DataType.LongH\x00\x12.\n\x05\x66loat\x18\x08 \x01(\x0b\x32\x1d.spark.connect.DataType.FloatH\x00\x12\x30\n\x06\x64ouble\x18\t \x01(\x0b\x32\x1e.spark.connect.DataType.DoubleH\x00\x12\x32\n\x07\x64\x65\x63imal\x18\n \x01(\x0b\x32\x1f.spark.connect.DataType.DecimalH\x00\x12\x30\n\x06string\x18\x0b \x01(\x0b\x32\x1e.spark.connect.DataType.StringH\x00\x12,\n\x04\x63har\x18\x0c \x01(\x0b\x32\x1c.spark.connect.DataType.CharH\x00\x12\x33\n\x08var_char\x18\r \x01(\x0b\x32\x1f.spark.connect.DataType.VarCharH\x00\x12,\n\x04\x64\x61te\x18\x0e \x01(\x0b\x32\x1c.spark.connect.DataType.DateH\x00\x12\x36\n\ttimestamp\x18\x0f \x01(\x0b\x32!.spark.connect.DataType.TimestampH\x00\x12=\n\rtimestamp_ntz\x18\x10 \x01(\x0b\x32$.spark.connect.DataType.TimestampNTZH\x00\x12\x45\n\x11\x63\x61lendar_interval\x18\x11 \x01(\x0b\x32(.spark.connect.DataType.CalendarIntervalH\x00\x12H\n\x13year_month_interval\x18\x12 \x01(\x0b\x32).spark.connect.DataType.YearMonthIntervalH\x00\x12\x44\n\x11\x64\x61y_time_interval\x18\x13 \x01(\x0b\x32\'.spark.connect.DataType.DayTimeIntervalH\x00\x12.\n\x05\x61rray\x18\x14 \x01(\x0b\x32\x1d.spark.connect.DataType.ArrayH\x00\x12\x30\n\x06struct\x18\x15 \x01(\x0b\x32\x1e.spark.connect.DataType.StructH\x00\x12*\n\x03map\x18\x16 \x01(\x0b\x32\x1b.spark.connect.DataType.MapH\x00\x12\x32\n\x07variant\x18\x19 \x01(\x0b\x32\x1f.spark.connect.DataType.VariantH\x00\x12*\n\x03udt\x18\x17 \x01(\x0b\x32\x1b.spark.connect.DataType.UDTH\x00\x12\x34\n\x08geometry\x18\x1a \x01(\x0b\x32 .spark.connect.DataType.GeometryH\x00\x12\x36\n\tgeography\x18\x1b \x01(\x0b\x32!.spark.connect.DataType.GeographyH\x00\x12\x34\n\x08unparsed\x18\x18 \x01(\x0b\x32 .spark.connect.DataType.UnparsedH\x00\x12,\n\x04time\x18\x1c \x01(\x0b\x32\x1c.spark.connect.DataType.TimeH\x00\x12H\n\x13timestamp_ntz_nanos\x18\x1d \x01(\x0b\x32).spark.connect.DataType.TimestampNTZNanosH\x00\x12H\n\x13timestamp_ltz_nanos\x18\x1e \x01(\x0b\x32).spark.connect.DataType.TimestampLTZNanosH\x00\x1a+\n\x07\x42oolean\x12 \n\x18type_variation_reference\x18\x01 \x01(\r\x1a(\n\x04\x42yte\x12 \n\x18type_variation_reference\x18\x01 \x01(\r\x1a)\n\x05Short\x12 \n\x18type_variation_reference\x18\x01 \x01(\r\x1a+\n\x07Integer\x12 \n\x18type_variation_reference\x18\x01 \x01(\r\x1a(\n\x04Long\x12 \n\x18type_variation_reference\x18\x01 \x01(\r\x1a)\n\x05\x46loat\x12 \n\x18type_variation_reference\x18\x01 \x01(\r\x1a*\n\x06\x44ouble\x12 \n\x18type_variation_reference\x18\x01 \x01(\r\x1a=\n\x06String\x12 \n\x18type_variation_reference\x18\x01 \x01(\r\x12\x11\n\tcollation\x18\x02 \x01(\t\x1a*\n\x06\x42inary\x12 \n\x18type_variation_reference\x18\x01 \x01(\r\x1a(\n\x04NULL\x12 \n\x18type_variation_reference\x18\x01 \x01(\r\x1a-\n\tTimestamp\x12 \n\x18type_variation_reference\x18\x01 \x01(\r\x1a(\n\x04\x44\x61te\x12 \n\x18type_variation_reference\x18\x01 \x01(\r\x1a\x30\n\x0cTimestampNTZ\x12 \n\x18type_variation_reference\x18\x01 \x01(\r\x1aN\n\x04Time\x12\x16\n\tprecision\x18\x01 \x01(\x05H\x00\x88\x01\x01\x12 \n\x18type_variation_reference\x18\x02 \x01(\rB\x0c\n\n_precision\x1a[\n\x11TimestampNTZNanos\x12\x16\n\tprecision\x18\x01 \x01(\x05H\x00\x88\x01\x01\x12 \n\x18type_variation_reference\x18\x02 \x01(\rB\x0c\n\n_precision\x1a[\n\x11TimestampLTZNanos\x12\x16\n\tprecision\x18\x01 \x01(\x05H\x00\x88\x01\x01\x12 \n\x18type_variation_reference\x18\x02 \x01(\rB\x0c\n\n_precision\x1a\x34\n\x10\x43\x61lendarInterval\x12 \n\x18type_variation_reference\x18\x01 \x01(\r\x1a\x85\x01\n\x11YearMonthInterval\x12\x18\n\x0bstart_field\x18\x01 \x01(\x05H\x00\x88\x01\x01\x12\x16\n\tend_field\x18\x02 \x01(\x05H\x01\x88\x01\x01\x12 \n\x18type_variation_reference\x18\x03 \x01(\rB\x0e\n\x0c_start_fieldB\x0c\n\n_end_field\x1a\x83\x01\n\x0f\x44\x61yTimeInterval\x12\x18\n\x0bstart_field\x18\x01 \x01(\x05H\x00\x88\x01\x01\x12\x16\n\tend_field\x18\x02 \x01(\x05H\x01\x88\x01\x01\x12 \n\x18type_variation_reference\x18\x03 \x01(\rB\x0e\n\x0c_start_fieldB\x0c\n\n_end_field\x1a\x38\n\x04\x43har\x12\x0e\n\x06length\x18\x01 \x01(\x05\x12 \n\x18type_variation_reference\x18\x02 \x01(\r\x1a;\n\x07VarChar\x12\x0e\n\x06length\x18\x01 \x01(\x05\x12 \n\x18type_variation_reference\x18\x02 \x01(\r\x1ao\n\x07\x44\x65\x63imal\x12\x12\n\x05scale\x18\x01 \x01(\x05H\x00\x88\x01\x01\x12\x16\n\tprecision\x18\x02 \x01(\x05H\x01\x88\x01\x01\x12 \n\x18type_variation_reference\x18\x03 \x01(\rB\x08\n\x06_scaleB\x0c\n\n_precision\x1a}\n\x0bStructField\x12\x0c\n\x04name\x18\x01 \x01(\t\x12*\n\tdata_type\x18\x02 \x01(\x0b\x32\x17.spark.connect.DataType\x12\x10\n\x08nullable\x18\x03 \x01(\x08\x12\x15\n\x08metadata\x18\x04 \x01(\tH\x00\x88\x01\x01\x42\x0b\n\t_metadata\x1a_\n\x06Struct\x12\x33\n\x06\x66ields\x18\x01 \x03(\x0b\x32#.spark.connect.DataType.StructField\x12 \n\x18type_variation_reference\x18\x02 \x01(\r\x1ao\n\x05\x41rray\x12-\n\x0c\x65lement_type\x18\x01 \x01(\x0b\x32\x17.spark.connect.DataType\x12\x15\n\rcontains_null\x18\x02 \x01(\x08\x12 \n\x18type_variation_reference\x18\x03 \x01(\r\x1a\x9c\x01\n\x03Map\x12)\n\x08key_type\x18\x01 \x01(\x0b\x32\x17.spark.connect.DataType\x12+\n\nvalue_type\x18\x02 \x01(\x0b\x32\x17.spark.connect.DataType\x12\x1b\n\x13value_contains_null\x18\x03 \x01(\x08\x12 \n\x18type_variation_reference\x18\x04 \x01(\r\x1a:\n\x08Geometry\x12\x0c\n\x04srid\x18\x01 \x01(\x05\x12 \n\x18type_variation_reference\x18\x02 \x01(\r\x1a;\n\tGeography\x12\x0c\n\x04srid\x18\x01 \x01(\x05\x12 \n\x18type_variation_reference\x18\x02 \x01(\r\x1a+\n\x07Variant\x12 \n\x18type_variation_reference\x18\x01 \x01(\r\x1a\xe4\x01\n\x03UDT\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\x16\n\tjvm_class\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x19\n\x0cpython_class\x18\x03 \x01(\tH\x01\x88\x01\x01\x12$\n\x17serialized_python_class\x18\x04 \x01(\tH\x02\x88\x01\x01\x12.\n\x08sql_type\x18\x05 \x01(\x0b\x32\x17.spark.connect.DataTypeH\x03\x88\x01\x01\x42\x0c\n\n_jvm_classB\x0f\n\r_python_classB\x1a\n\x18_serialized_python_classB\x0b\n\t_sql_type\x1a$\n\x08Unparsed\x12\x18\n\x10\x64\x61ta_type_string\x18\x01 \x01(\tB\x06\n\x04kindB6\n\x1eorg.apache.spark.connect.protoP\x01Z\x12internal/generatedb\x06proto3"
|
|
9
9
|
|
|
10
10
|
pool = ::Google::Protobuf::DescriptorPool.generated_pool
|
|
11
11
|
pool.add_serialized_file(descriptor_data)
|
|
@@ -27,6 +27,8 @@ module Spark
|
|
|
27
27
|
DataType::Date = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("spark.connect.DataType.Date").msgclass
|
|
28
28
|
DataType::TimestampNTZ = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("spark.connect.DataType.TimestampNTZ").msgclass
|
|
29
29
|
DataType::Time = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("spark.connect.DataType.Time").msgclass
|
|
30
|
+
DataType::TimestampNTZNanos = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("spark.connect.DataType.TimestampNTZNanos").msgclass
|
|
31
|
+
DataType::TimestampLTZNanos = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("spark.connect.DataType.TimestampLTZNanos").msgclass
|
|
30
32
|
DataType::CalendarInterval = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("spark.connect.DataType.CalendarInterval").msgclass
|
|
31
33
|
DataType::YearMonthInterval = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("spark.connect.DataType.YearMonthInterval").msgclass
|
|
32
34
|
DataType::DayTimeInterval = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("spark.connect.DataType.DayTimeInterval").msgclass
|
data/lib/spark_connect/reader.rb
CHANGED
|
@@ -8,6 +8,7 @@ module SparkConnect
|
|
|
8
8
|
# spark.read.format("csv").option("header", true).load("data.csv")
|
|
9
9
|
# spark.read.json("events.json")
|
|
10
10
|
# spark.read.table("my_table")
|
|
11
|
+
# spark.read.json(lines_df) # parse a DataFrame of JSON strings
|
|
11
12
|
class DataFrameReader
|
|
12
13
|
Proto = SparkConnect::Proto
|
|
13
14
|
|
|
@@ -29,21 +30,25 @@ module SparkConnect
|
|
|
29
30
|
# Set the input schema (a {Types::StructType} or DDL string).
|
|
30
31
|
# @return [self]
|
|
31
32
|
def schema(schema)
|
|
32
|
-
|
|
33
|
+
unless schema.is_a?(Types::StructType) || schema.is_a?(String)
|
|
34
|
+
raise IllegalArgumentError, "schema must be a StructType or DDL String, got #{schema.class}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
@schema = schema
|
|
33
38
|
self
|
|
34
39
|
end
|
|
35
40
|
|
|
36
|
-
# Set a single read option.
|
|
41
|
+
# Set a single read option. A `nil` value is ignored.
|
|
37
42
|
# @return [self]
|
|
38
43
|
def option(key, value)
|
|
39
|
-
@options[key.to_s] = value.to_s
|
|
44
|
+
@options[key.to_s] = value.to_s unless value.nil?
|
|
40
45
|
self
|
|
41
46
|
end
|
|
42
47
|
|
|
43
48
|
# Set multiple read options.
|
|
44
49
|
# @return [self]
|
|
45
50
|
def options(opts)
|
|
46
|
-
opts.each { |k, v|
|
|
51
|
+
opts.each { |k, v| option(k, v) }
|
|
47
52
|
self
|
|
48
53
|
end
|
|
49
54
|
|
|
@@ -52,10 +57,7 @@ module SparkConnect
|
|
|
52
57
|
# @param paths [Array<String>]
|
|
53
58
|
# @return [DataFrame]
|
|
54
59
|
def load(*paths)
|
|
55
|
-
|
|
56
|
-
ds.format = @format if @format
|
|
57
|
-
ds.schema = @schema if @schema
|
|
58
|
-
read_relation(data_source: ds)
|
|
60
|
+
read_relation(data_source: data_source(paths.flatten.map(&:to_s)))
|
|
59
61
|
end
|
|
60
62
|
|
|
61
63
|
# Read a registered table or view.
|
|
@@ -67,10 +69,31 @@ module SparkConnect
|
|
|
67
69
|
read_relation(named_table: nt)
|
|
68
70
|
end
|
|
69
71
|
|
|
70
|
-
#
|
|
71
|
-
|
|
72
|
-
#
|
|
73
|
-
|
|
72
|
+
# The row-level changes (Change Data Capture) of a table as a batch
|
|
73
|
+
# DataFrame (Spark 4.2+; the table's catalog must support change logs).
|
|
74
|
+
# Configure the range with options such as `startingVersion`,
|
|
75
|
+
# `endingVersion`, `startingTimestamp`, `endingTimestamp`,
|
|
76
|
+
# `deduplicationMode`, and `computeUpdates`.
|
|
77
|
+
#
|
|
78
|
+
# @param table_name [String]
|
|
79
|
+
# @return [DataFrame]
|
|
80
|
+
def changes(table_name)
|
|
81
|
+
raise AnalysisError.new("User specified schema not supported with `changes`.", error_class: "_LEGACY_ERROR_TEMP_1189") if @schema
|
|
82
|
+
|
|
83
|
+
rc = Proto::RelationChanges.new(unparsed_identifier: table_name.to_s, options: @options)
|
|
84
|
+
DataFrame.new(@session, PlanBuilder.relation(@session, relation_changes: rc))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# CSV at `paths`, or parse a single-string-column {DataFrame} of CSV lines.
|
|
88
|
+
# @return [DataFrame]
|
|
89
|
+
def csv(*paths) = text_source("csv", :PARSE_FORMAT_CSV, paths)
|
|
90
|
+
# JSON at `paths`, or parse a single-string-column {DataFrame} of JSON documents.
|
|
91
|
+
# @return [DataFrame]
|
|
92
|
+
def json(*paths) = text_source("json", :PARSE_FORMAT_JSON, paths)
|
|
93
|
+
# XML at `paths` (set `option("rowTag", ...)`), or parse a single-string-column
|
|
94
|
+
# {DataFrame} of XML records (Spark 4.2+ for the DataFrame form).
|
|
95
|
+
# @return [DataFrame]
|
|
96
|
+
def xml(*paths) = text_source("xml", :PARSE_FORMAT_XML, paths)
|
|
74
97
|
# @return [DataFrame] Parquet at `paths`.
|
|
75
98
|
def parquet(*paths) = format("parquet").load(*paths)
|
|
76
99
|
# @return [DataFrame] ORC at `paths`.
|
|
@@ -78,19 +101,77 @@ module SparkConnect
|
|
|
78
101
|
# @return [DataFrame] text at `paths` (one `value` column per line).
|
|
79
102
|
def text(*paths) = format("text").load(*paths)
|
|
80
103
|
|
|
81
|
-
|
|
104
|
+
JDBC_PARTITION_KEYS = %i[column lower_bound upper_bound num_partitions predicates].freeze
|
|
105
|
+
|
|
106
|
+
# Read from a JDBC source, optionally partitioned on a numeric column
|
|
107
|
+
# (`column:` with `lower_bound:`, `upper_bound:`, `num_partitions:`) or by a
|
|
108
|
+
# list of WHERE-clause `predicates:` (one partition each).
|
|
82
109
|
#
|
|
110
|
+
# @example
|
|
111
|
+
# spark.read.jdbc(url, "people", "user" => "u", "password" => "p")
|
|
112
|
+
# spark.read.jdbc(url, "people", { "user" => "u" }, column: "id", lower_bound: 0, upper_bound: 100, num_partitions: 4)
|
|
83
113
|
# @param url [String] the JDBC URL.
|
|
84
114
|
# @param table [String] the table name (or subquery).
|
|
85
|
-
# @param properties [Hash] connection properties (`user`, `password`, ...)
|
|
115
|
+
# @param properties [Hash] connection properties (`user`, `password`, ...);
|
|
116
|
+
# String-keyed keyword arguments are merged into it.
|
|
86
117
|
# @return [DataFrame]
|
|
87
|
-
def jdbc(url, table, properties = {})
|
|
88
|
-
|
|
89
|
-
|
|
118
|
+
def jdbc(url, table, properties = {}, **opts)
|
|
119
|
+
# Symbol keys are partitioning options; anything else is a connection property.
|
|
120
|
+
partitioning, extra = opts.partition { |k, _| JDBC_PARTITION_KEYS.include?(k) }.map(&:to_h)
|
|
121
|
+
properties = properties.merge(extra)
|
|
122
|
+
column, lower_bound, upper_bound, num_partitions, predicates = partitioning.values_at(*JDBC_PARTITION_KEYS)
|
|
123
|
+
format("jdbc")
|
|
124
|
+
unless column.nil?
|
|
125
|
+
if lower_bound.nil? || upper_bound.nil? || num_partitions.nil?
|
|
126
|
+
raise IllegalArgumentError, "lower_bound, upper_bound, and num_partitions are required with column"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
options("partitionColumn" => column, "lowerBound" => lower_bound, "upperBound" => upper_bound,
|
|
130
|
+
"numPartitions" => num_partitions)
|
|
131
|
+
end
|
|
132
|
+
options(properties.transform_keys(&:to_s))
|
|
133
|
+
options("url" => url, "dbtable" => table)
|
|
134
|
+
return load if predicates.nil? || !column.nil?
|
|
135
|
+
|
|
136
|
+
read_relation(data_source: data_source([], predicates: predicates.map(&:to_s)))
|
|
90
137
|
end
|
|
91
138
|
|
|
92
139
|
private
|
|
93
140
|
|
|
141
|
+
def data_source(paths, predicates: [])
|
|
142
|
+
ds = Proto::Read::DataSource.new(options: @options, paths: paths, predicates: predicates)
|
|
143
|
+
ds.format = @format if @format
|
|
144
|
+
ds.schema = schema_string if @schema
|
|
145
|
+
ds
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Like PySpark, a StructType is sent as its JSON form (which the server
|
|
149
|
+
# parses losslessly); a String is sent as given (DDL or JSON).
|
|
150
|
+
def schema_string
|
|
151
|
+
@schema.is_a?(Types::StructType) ? @schema.json : @schema
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def text_source(fmt, parse_format, paths)
|
|
155
|
+
return parse(paths.first, parse_format) if paths.size == 1 && paths.first.is_a?(DataFrame)
|
|
156
|
+
|
|
157
|
+
format(fmt).load(*paths)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def parse(df, parse_format)
|
|
161
|
+
parse = Proto::Parse.new(input: df.relation, format: parse_format, options: @options)
|
|
162
|
+
parse.schema = parse_schema.to_proto if @schema
|
|
163
|
+
DataFrame.new(@session, PlanBuilder.relation(@session, parse: parse))
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# The Parse relation carries a typed schema; resolve a DDL string through
|
|
167
|
+
# the server's parser.
|
|
168
|
+
def parse_schema
|
|
169
|
+
return @schema if @schema.is_a?(Types::StructType)
|
|
170
|
+
|
|
171
|
+
parsed = @session.client.analyze(ddl_parse: Proto::AnalyzePlanRequest::DDLParse.new(ddl_string: @schema)).ddl_parse.parsed
|
|
172
|
+
Types.from_proto(parsed)
|
|
173
|
+
end
|
|
174
|
+
|
|
94
175
|
def read_relation(**read_kw)
|
|
95
176
|
DataFrame.new(@session, PlanBuilder.relation(@session, read: Proto::Read.new(**read_kw)))
|
|
96
177
|
end
|
|
@@ -25,26 +25,99 @@ module SparkConnect
|
|
|
25
25
|
def initialize(client)
|
|
26
26
|
@client = client
|
|
27
27
|
@plan_id = -1
|
|
28
|
+
@plan_id_lock = Mutex.new
|
|
28
29
|
@conf = RuntimeConfig.new(client)
|
|
30
|
+
@subquery_relations = {}
|
|
31
|
+
@release_session_on_close = true
|
|
29
32
|
end
|
|
30
33
|
|
|
34
|
+
# Whether {#stop} releases the server-side session.
|
|
35
|
+
# @api private
|
|
36
|
+
attr_accessor :release_session_on_close
|
|
37
|
+
|
|
38
|
+
@default_session = nil
|
|
39
|
+
@session_lock = Mutex.new
|
|
40
|
+
ACTIVE_SESSION_KEY = :spark_connect_active_session
|
|
41
|
+
|
|
31
42
|
class << self
|
|
32
43
|
# @return [Builder] a new session builder.
|
|
33
44
|
def builder
|
|
34
45
|
Builder.new
|
|
35
46
|
end
|
|
36
47
|
|
|
37
|
-
# The
|
|
48
|
+
# The active session of the current thread, else the process-wide
|
|
49
|
+
# default session (the first session built by {Builder#get_or_create} /
|
|
50
|
+
# {Builder#create}); stopped sessions are skipped.
|
|
51
|
+
#
|
|
52
|
+
# Unlike PySpark's `SparkSession.active()`, this returns nil (instead of
|
|
53
|
+
# raising) when there is no session, which is the idiomatic Ruby shape for
|
|
54
|
+
# an optional lookup.
|
|
55
|
+
#
|
|
38
56
|
# @return [SparkSession, nil]
|
|
39
|
-
|
|
57
|
+
def active
|
|
58
|
+
get_active_session || default_session
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Make `session` the active session of the current thread (and the
|
|
62
|
+
# process-wide default when none is set). `nil` clears the active session.
|
|
63
|
+
# @param session [SparkSession, nil]
|
|
64
|
+
def active=(session)
|
|
65
|
+
Thread.current[ACTIVE_SESSION_KEY] = session
|
|
66
|
+
@session_lock.synchronize { @default_session ||= session } if session
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @return [SparkSession, nil] the active session of the current thread, if not stopped.
|
|
70
|
+
def get_active_session
|
|
71
|
+
s = Thread.current[ACTIVE_SESSION_KEY]
|
|
72
|
+
s && !s.stopped? ? s : nil
|
|
73
|
+
end
|
|
74
|
+
alias getActiveSession get_active_session
|
|
40
75
|
|
|
76
|
+
# @return [SparkSession, nil] the process-wide default session, if not stopped.
|
|
77
|
+
def default_session
|
|
78
|
+
s = @session_lock.synchronize { @default_session }
|
|
79
|
+
s && !s.stopped? ? s : nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Clear the process-wide default session (it is not stopped). @return [void]
|
|
83
|
+
def clear_default_session
|
|
84
|
+
@session_lock.synchronize { @default_session = nil }
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Clear the current thread's active session (it is not stopped). @return [void]
|
|
89
|
+
def clear_active_session
|
|
90
|
+
Thread.current[ACTIVE_SESSION_KEY] = nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Forget `session` wherever it is the default/active one.
|
|
41
94
|
# @api private
|
|
95
|
+
def forget(session)
|
|
96
|
+
@session_lock.synchronize { @default_session = nil if @default_session.equal?(session) }
|
|
97
|
+
Thread.current[ACTIVE_SESSION_KEY] = nil if Thread.current[ACTIVE_SESSION_KEY].equal?(session)
|
|
98
|
+
end
|
|
42
99
|
end
|
|
43
100
|
|
|
44
101
|
# Allocate the next unique plan id. Used by {PlanBuilder.relation}.
|
|
45
102
|
# @return [Integer]
|
|
46
103
|
def next_plan_id
|
|
47
|
-
@plan_id += 1
|
|
104
|
+
@plan_id_lock.synchronize { @plan_id += 1 }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @api private - remember a DataFrame's relation so a subquery expression
|
|
108
|
+
# that refers to it by plan id can be expanded into a `WithRelations`.
|
|
109
|
+
# @param relation [Spark::Connect::Relation]
|
|
110
|
+
# @return [Integer] the relation's plan id.
|
|
111
|
+
def register_subquery_relation(relation)
|
|
112
|
+
plan_id = relation.common.plan_id
|
|
113
|
+
@plan_id_lock.synchronize { @subquery_relations[plan_id] = relation }
|
|
114
|
+
plan_id
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @api private
|
|
118
|
+
# @return [Spark::Connect::Relation, nil]
|
|
119
|
+
def subquery_relation(plan_id)
|
|
120
|
+
@plan_id_lock.synchronize { @subquery_relations[plan_id] }
|
|
48
121
|
end
|
|
49
122
|
|
|
50
123
|
# @return [String] the client session id (UUID).
|
|
@@ -105,7 +178,13 @@ module SparkConnect
|
|
|
105
178
|
|
|
106
179
|
# @return [StreamingQueryManager] the manager for this session's streaming queries.
|
|
107
180
|
def streams
|
|
108
|
-
StreamingQueryManager.new(self)
|
|
181
|
+
@streams ||= StreamingQueryManager.new(self)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# @return [TableValuedFunction] built-in table-valued functions
|
|
185
|
+
# (`spark.tvf.explode(...)`, `spark.tvf.range(...)`, ...).
|
|
186
|
+
def tvf
|
|
187
|
+
TableValuedFunction.new(self)
|
|
109
188
|
end
|
|
110
189
|
|
|
111
190
|
# Create a new Spark Declarative Pipeline (a dataflow graph) in this session.
|
|
@@ -134,6 +213,15 @@ module SparkConnect
|
|
|
134
213
|
alias create_dataframe create_data_frame
|
|
135
214
|
alias createDataFrame create_data_frame
|
|
136
215
|
|
|
216
|
+
# An empty {DataFrame} with the given schema.
|
|
217
|
+
#
|
|
218
|
+
# @param schema [Types::StructType, String] a schema or DDL string.
|
|
219
|
+
# @return [DataFrame]
|
|
220
|
+
def empty_data_frame(schema)
|
|
221
|
+
create_data_frame([], schema)
|
|
222
|
+
end
|
|
223
|
+
alias emptyDataFrame empty_data_frame
|
|
224
|
+
|
|
137
225
|
# @return [RuntimeConfig] runtime configuration facade.
|
|
138
226
|
attr_reader :conf
|
|
139
227
|
|
|
@@ -147,19 +235,60 @@ module SparkConnect
|
|
|
147
235
|
@client.analyze(spark_version: Proto::AnalyzePlanRequest::SparkVersion.new).spark_version.version
|
|
148
236
|
end
|
|
149
237
|
|
|
150
|
-
# Make this the active
|
|
238
|
+
# Make this the active (and, if unset, the default) session.
|
|
151
239
|
# @return [self]
|
|
152
240
|
def set_active
|
|
153
241
|
SparkSession.active = self
|
|
154
242
|
self
|
|
155
243
|
end
|
|
156
244
|
|
|
157
|
-
# Start a brand-new session against the same endpoint
|
|
158
|
-
#
|
|
245
|
+
# Start a brand-new session against the same endpoint: a fresh server-side
|
|
246
|
+
# session with no configuration, temporary views, or functions carried over
|
|
247
|
+
# (Spark Connect `newSession` semantics, like PySpark 4.3). Configurations
|
|
248
|
+
# set through the builder are not reapplied.
|
|
159
249
|
# @return [SparkSession]
|
|
160
250
|
def new_session
|
|
161
|
-
SparkSession.new(
|
|
251
|
+
session = SparkSession.new(@client.new_session_client)
|
|
252
|
+
session.release_session_on_close = true
|
|
253
|
+
session
|
|
254
|
+
end
|
|
255
|
+
alias newSession new_session
|
|
256
|
+
|
|
257
|
+
# Clone this session on the server: the new session starts with a copy of
|
|
258
|
+
# this session's SQL configurations, temporary views, registered functions,
|
|
259
|
+
# and catalog state, and is isolated from it afterwards (Spark 4.1+).
|
|
260
|
+
#
|
|
261
|
+
# @param new_session_id [String, nil] a UUID for the clone (generated when omitted).
|
|
262
|
+
# @return [SparkSession]
|
|
263
|
+
def clone_session(new_session_id = nil)
|
|
264
|
+
session = SparkSession.new(@client.clone_session(new_session_id))
|
|
265
|
+
session.release_session_on_close = true
|
|
266
|
+
session
|
|
162
267
|
end
|
|
268
|
+
alias cloneSession clone_session
|
|
269
|
+
|
|
270
|
+
# Register a callable invoked with execution progress of every query run by
|
|
271
|
+
# this session. It is called with the keywords `stages:` (an Array of
|
|
272
|
+
# {StageInfo}), `inflight_tasks:`, `operation_id:`, and `done:`.
|
|
273
|
+
#
|
|
274
|
+
# @param handler [#call] a Proc/lambda or any object responding to `call`.
|
|
275
|
+
# @return [void]
|
|
276
|
+
def register_progress_handler(handler = nil, &block)
|
|
277
|
+
@client.register_progress_handler(handler || block)
|
|
278
|
+
end
|
|
279
|
+
alias registerProgressHandler register_progress_handler
|
|
280
|
+
|
|
281
|
+
# Remove a handler added with {#register_progress_handler}. @return [void]
|
|
282
|
+
def remove_progress_handler(handler)
|
|
283
|
+
@client.remove_progress_handler(handler)
|
|
284
|
+
end
|
|
285
|
+
alias removeProgressHandler remove_progress_handler
|
|
286
|
+
|
|
287
|
+
# Remove all progress handlers. @return [void]
|
|
288
|
+
def clear_progress_handlers
|
|
289
|
+
@client.clear_progress_handlers
|
|
290
|
+
end
|
|
291
|
+
alias clearProgressHandlers clear_progress_handlers
|
|
163
292
|
|
|
164
293
|
# Interrupt all operations running in this session.
|
|
165
294
|
# @return [Array<String>] the ids of the interrupted operations.
|
|
@@ -200,14 +329,24 @@ module SparkConnect
|
|
|
200
329
|
@client.clear_tags
|
|
201
330
|
end
|
|
202
331
|
|
|
203
|
-
# Release the server-side session and stop the client.
|
|
332
|
+
# Release the server-side session and stop the client. Idempotent.
|
|
204
333
|
# @return [void]
|
|
205
334
|
def stop
|
|
206
|
-
|
|
207
|
-
|
|
335
|
+
return nil if stopped?
|
|
336
|
+
|
|
337
|
+
@streams&.close
|
|
338
|
+
@client.release_session if @release_session_on_close
|
|
339
|
+
@client.close
|
|
340
|
+
SparkSession.forget(self)
|
|
208
341
|
nil
|
|
209
342
|
end
|
|
210
343
|
|
|
344
|
+
# @return [Boolean] whether {#stop} has been called.
|
|
345
|
+
def stopped?
|
|
346
|
+
@client.respond_to?(:closed?) && @client.closed?
|
|
347
|
+
end
|
|
348
|
+
alias is_stopped stopped?
|
|
349
|
+
|
|
211
350
|
# @api private
|
|
212
351
|
def create_data_frame_from_relation(relation)
|
|
213
352
|
DataFrame.new(self, relation)
|
|
@@ -264,9 +403,14 @@ module SparkConnect
|
|
|
264
403
|
# Fluent builder for {SparkSession}. Returned by {SparkSession.builder}.
|
|
265
404
|
class SparkSession
|
|
266
405
|
class Builder
|
|
406
|
+
# Options that only make sense when connecting and are never applied as
|
|
407
|
+
# session configurations.
|
|
408
|
+
CONNECT_ONLY_OPTIONS = %w[spark.remote spark.master].freeze
|
|
409
|
+
|
|
267
410
|
def initialize
|
|
268
411
|
@options = {}
|
|
269
412
|
@remote = nil
|
|
413
|
+
@channel_builder = nil
|
|
270
414
|
end
|
|
271
415
|
|
|
272
416
|
# Set the connection string (`sc://...`).
|
|
@@ -276,42 +420,100 @@ module SparkConnect
|
|
|
276
420
|
self
|
|
277
421
|
end
|
|
278
422
|
|
|
423
|
+
# Use a custom {ChannelBuilder} (e.g. a subclass that customizes the gRPC
|
|
424
|
+
# channel) instead of a connection string.
|
|
425
|
+
# @param channel_builder [ChannelBuilder]
|
|
426
|
+
# @return [self]
|
|
427
|
+
def channel_builder(channel_builder)
|
|
428
|
+
@channel_builder = channel_builder
|
|
429
|
+
self
|
|
430
|
+
end
|
|
431
|
+
alias channelBuilder channel_builder
|
|
432
|
+
|
|
433
|
+
# Accepted for PySpark compatibility; the master is chosen by the Spark
|
|
434
|
+
# Connect server, so this is a no-op.
|
|
435
|
+
# @return [self]
|
|
436
|
+
def master(_master)
|
|
437
|
+
self
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# Hive support is a server-side setting and cannot be enabled from a
|
|
441
|
+
# Spark Connect client (PySpark raises too).
|
|
442
|
+
# @raise [NotImplementedError]
|
|
443
|
+
def enable_hive_support
|
|
444
|
+
raise NotImplementedError, "enableHiveSupport is not supported over Spark Connect; configure the server instead"
|
|
445
|
+
end
|
|
446
|
+
alias enableHiveSupport enable_hive_support
|
|
447
|
+
|
|
279
448
|
# Set the application name.
|
|
280
449
|
# @return [self]
|
|
281
450
|
def app_name(name)
|
|
282
451
|
@options["spark.app.name"] = name
|
|
283
452
|
self
|
|
284
453
|
end
|
|
454
|
+
alias appName app_name
|
|
285
455
|
|
|
286
456
|
# Set an arbitrary configuration option to apply after connecting.
|
|
457
|
+
# @overload config(key, value)
|
|
458
|
+
# @overload config(map)
|
|
459
|
+
# @param map [Hash] several options at once.
|
|
287
460
|
# @return [self]
|
|
288
|
-
def config(key, value)
|
|
289
|
-
|
|
461
|
+
def config(key, value = nil)
|
|
462
|
+
if key.is_a?(Hash)
|
|
463
|
+
key.each { |k, v| @options[k.to_s] = v }
|
|
464
|
+
else
|
|
465
|
+
@options[key.to_s] = value
|
|
466
|
+
end
|
|
290
467
|
self
|
|
291
468
|
end
|
|
292
469
|
|
|
293
|
-
#
|
|
294
|
-
#
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
session
|
|
470
|
+
# Reuse the active (or default) session, else build a new one. Builder
|
|
471
|
+
# options are applied to the returned session either way, like PySpark.
|
|
472
|
+
# With a block, yields the session and stops it afterwards (the Ruby
|
|
473
|
+
# counterpart of PySpark's `with ... as spark:`), returning the block's value.
|
|
474
|
+
#
|
|
475
|
+
# @yieldparam session [SparkSession]
|
|
476
|
+
# @return [SparkSession, Object]
|
|
477
|
+
def get_or_create(&block)
|
|
478
|
+
session = SparkSession.active
|
|
479
|
+
if session
|
|
480
|
+
apply_options(session)
|
|
481
|
+
else
|
|
482
|
+
session = create
|
|
483
|
+
end
|
|
484
|
+
block ? use(session, &block) : session
|
|
302
485
|
end
|
|
303
486
|
alias getOrCreate get_or_create
|
|
304
487
|
|
|
305
|
-
# Always build a new {SparkSession}
|
|
306
|
-
#
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
488
|
+
# Always build a new {SparkSession} (which becomes the active session,
|
|
489
|
+
# and the default one when none is set). With a block, behaves like
|
|
490
|
+
# {#get_or_create} with a block.
|
|
491
|
+
#
|
|
492
|
+
# @yieldparam session [SparkSession]
|
|
493
|
+
# @return [SparkSession, Object]
|
|
494
|
+
def create(&block)
|
|
495
|
+
raise IllegalArgumentError, "Set either remote(...) or channel_builder(...), not both" if @remote && @channel_builder
|
|
496
|
+
|
|
497
|
+
channel = @channel_builder || ChannelBuilder.new(@remote || ENV["SPARK_REMOTE"] || "sc://localhost:15002")
|
|
498
|
+
session = SparkSession.new(SparkConnectClient.new(channel))
|
|
499
|
+
SparkSession.active = session
|
|
500
|
+
apply_options(session)
|
|
501
|
+
block ? use(session, &block) : session
|
|
313
502
|
end
|
|
314
503
|
alias build create
|
|
504
|
+
|
|
505
|
+
private
|
|
506
|
+
|
|
507
|
+
def apply_options(session)
|
|
508
|
+
opts = @options.except(*CONNECT_ONLY_OPTIONS)
|
|
509
|
+
session.conf.set_all(opts, silent: true) unless opts.empty?
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def use(session)
|
|
513
|
+
yield session
|
|
514
|
+
ensure
|
|
515
|
+
session.stop
|
|
516
|
+
end
|
|
315
517
|
end
|
|
316
518
|
end
|
|
317
519
|
end
|
|
@@ -22,10 +22,13 @@ module SparkConnect
|
|
|
22
22
|
scalar(@df.build(cov: Proto::StatCov.new(input: @df.relation, col1: col1.to_s, col2: col2.to_s)))
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
# Correlation of two columns (`method` is `"pearson"
|
|
25
|
+
# Correlation of two columns (`method` is `"pearson"`, the only supported one).
|
|
26
26
|
# @return [Float]
|
|
27
|
-
def corr(col1, col2, method =
|
|
28
|
-
|
|
27
|
+
def corr(col1, col2, method = nil)
|
|
28
|
+
method = "pearson" if method.nil? || method.to_s.empty?
|
|
29
|
+
raise IllegalArgumentError, "corr method must be 'pearson', got #{method.inspect}" unless method.to_s == "pearson"
|
|
30
|
+
|
|
31
|
+
rel = Proto::StatCorr.new(input: @df.relation, col1: col1.to_s, col2: col2.to_s, method: method.to_s)
|
|
29
32
|
scalar(@df.build(corr: rel))
|
|
30
33
|
end
|
|
31
34
|
|
|
@@ -40,8 +43,9 @@ module SparkConnect
|
|
|
40
43
|
# @param cols [Array<String>]
|
|
41
44
|
# @param support [Float]
|
|
42
45
|
# @return [DataFrame]
|
|
43
|
-
def freq_items(cols, support =
|
|
44
|
-
|
|
46
|
+
def freq_items(cols, support = nil)
|
|
47
|
+
support = 0.01 if support.nil? || support.zero?
|
|
48
|
+
rel = Proto::StatFreqItems.new(input: @df.relation, cols: Array(cols).map(&:to_s), support: support.to_f)
|
|
45
49
|
@df.build(freq_items: rel)
|
|
46
50
|
end
|
|
47
51
|
|
|
@@ -52,10 +56,15 @@ module SparkConnect
|
|
|
52
56
|
# @param relative_error [Float]
|
|
53
57
|
# @return [Array<Float>, Array<Array<Float>>] one list per column.
|
|
54
58
|
def approx_quantile(cols, probabilities, relative_error)
|
|
59
|
+
unless probabilities.all? { |p| p.is_a?(Numeric) && p.between?(0, 1) }
|
|
60
|
+
raise IllegalArgumentError, "probabilities must be numbers in [0, 1], got #{probabilities.inspect}"
|
|
61
|
+
end
|
|
62
|
+
raise IllegalArgumentError, "relative_error must be non-negative, got #{relative_error}" if relative_error.negative?
|
|
63
|
+
|
|
55
64
|
single = !cols.is_a?(Array)
|
|
56
65
|
rel = Proto::StatApproxQuantile.new(
|
|
57
66
|
input: @df.relation, cols: Array(cols).map(&:to_s),
|
|
58
|
-
probabilities: probabilities, relative_error: relative_error
|
|
67
|
+
probabilities: probabilities.map(&:to_f), relative_error: relative_error.to_f
|
|
59
68
|
)
|
|
60
69
|
row = @df.build(approx_quantile: rel).collect.first
|
|
61
70
|
result = row.to_a
|
|
@@ -71,10 +80,11 @@ module SparkConnect
|
|
|
71
80
|
def sample_by(col, fractions, seed = nil)
|
|
72
81
|
col_expr = (col.is_a?(Column) ? col : Functions.col(col.to_s)).to_expr
|
|
73
82
|
frac = fractions.map do |stratum, fraction|
|
|
74
|
-
Proto::StatSampleBy::Fraction.new(stratum: Column.to_literal(stratum), fraction: fraction)
|
|
83
|
+
Proto::StatSampleBy::Fraction.new(stratum: Column.to_literal(stratum), fraction: fraction.to_f)
|
|
75
84
|
end
|
|
76
|
-
|
|
77
|
-
rel
|
|
85
|
+
# Like PySpark, pick the seed on the client so the sample is stable across actions.
|
|
86
|
+
rel = Proto::StatSampleBy.new(input: @df.relation, col: col_expr, fractions: frac,
|
|
87
|
+
seed: seed.nil? ? Random.rand(2**63) : Integer(seed))
|
|
78
88
|
@df.build(sample_by: rel)
|
|
79
89
|
end
|
|
80
90
|
|