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
|
@@ -11,17 +11,24 @@ module SparkConnect
|
|
|
11
11
|
class GroupedData
|
|
12
12
|
Proto = SparkConnect::Proto
|
|
13
13
|
|
|
14
|
+
NUMERIC_TYPES = [
|
|
15
|
+
Types::ByteType, Types::ShortType, Types::IntegerType, Types::LongType,
|
|
16
|
+
Types::FloatType, Types::DoubleType, Types::DecimalType,
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
14
19
|
# @param df [DataFrame]
|
|
15
20
|
# @param grouping [Array<Column>] grouping columns.
|
|
16
21
|
# @param group_type [Symbol] a `GROUP_TYPE_*` enum symbol.
|
|
17
22
|
# @param pivot_col [Column, nil]
|
|
18
23
|
# @param pivot_values [Array, nil]
|
|
19
|
-
|
|
24
|
+
# @param grouping_sets [Array<Array<Column>>, nil] for `GROUP_TYPE_GROUPING_SETS`.
|
|
25
|
+
def initialize(df, grouping, group_type, pivot_col: nil, pivot_values: nil, grouping_sets: nil)
|
|
20
26
|
@df = df
|
|
21
27
|
@grouping = grouping
|
|
22
28
|
@group_type = group_type
|
|
23
29
|
@pivot_col = pivot_col
|
|
24
30
|
@pivot_values = pivot_values
|
|
31
|
+
@grouping_sets = grouping_sets
|
|
25
32
|
end
|
|
26
33
|
|
|
27
34
|
# Compute aggregate expressions.
|
|
@@ -33,6 +40,8 @@ module SparkConnect
|
|
|
33
40
|
# `{"age" => "max", "salary" => "avg"}`.
|
|
34
41
|
# @return [DataFrame]
|
|
35
42
|
def agg(*exprs)
|
|
43
|
+
raise IllegalArgumentError, "agg requires at least one expression" if exprs.empty?
|
|
44
|
+
|
|
36
45
|
agg_exprs =
|
|
37
46
|
if exprs.size == 1 && exprs.first.is_a?(Hash)
|
|
38
47
|
exprs.first.map { |col, fn| Column.invoke(fn.to_s, Functions.col(col.to_s)).to_expr }
|
|
@@ -65,22 +74,34 @@ module SparkConnect
|
|
|
65
74
|
# @return [DataFrame]
|
|
66
75
|
def min(*cols) = numeric_agg("min", cols)
|
|
67
76
|
|
|
68
|
-
# Pivot a column into multiple output columns.
|
|
77
|
+
# Pivot a column into multiple output columns. Only valid right after
|
|
78
|
+
# {DataFrame#group_by}.
|
|
69
79
|
#
|
|
70
80
|
# @param pivot_col [String, Column]
|
|
71
81
|
# @param values [Array, nil] optional explicit pivot values (faster, deterministic).
|
|
72
82
|
# @return [GroupedData]
|
|
73
83
|
def pivot(pivot_col, values = nil)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
84
|
+
unless @group_type == :GROUP_TYPE_GROUPBY
|
|
85
|
+
what = @group_type == :GROUP_TYPE_PIVOT ? "Repeated PIVOT operation" : "PIVOT after #{group_type_name}"
|
|
86
|
+
raise NotImplementedError, "#{what} is not supported"
|
|
87
|
+
end
|
|
88
|
+
raise IllegalArgumentError, "pivot values must be an Array, got #{values.class}" unless values.nil? || values.is_a?(Array)
|
|
89
|
+
|
|
90
|
+
col = pivot_col.is_a?(Column) ? pivot_col : @df.col(pivot_col.to_s)
|
|
91
|
+
GroupedData.new(@df, @grouping, :GROUP_TYPE_PIVOT, pivot_col: col, pivot_values: values)
|
|
77
92
|
end
|
|
78
93
|
|
|
79
94
|
private
|
|
80
95
|
|
|
96
|
+
def group_type_name
|
|
97
|
+
@group_type.to_s.delete_prefix("GROUP_TYPE_")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Like PySpark, aggregate every numeric column when none is named.
|
|
81
101
|
def numeric_agg(fn, cols)
|
|
82
|
-
|
|
83
|
-
|
|
102
|
+
names = cols.flatten.map(&:to_s)
|
|
103
|
+
names = @df.schema.fields.select { |f| NUMERIC_TYPES.any? { |t| f.data_type.is_a?(t) } }.map(&:name) if names.empty?
|
|
104
|
+
build(names.map { |c| Column.invoke(fn, Functions.col(c)).to_expr })
|
|
84
105
|
end
|
|
85
106
|
|
|
86
107
|
def build(agg_exprs)
|
|
@@ -95,6 +116,11 @@ module SparkConnect
|
|
|
95
116
|
pivot.values += @pivot_values.map { |v| Column.lit(v).to_expr.literal } if @pivot_values
|
|
96
117
|
agg.pivot = pivot
|
|
97
118
|
end
|
|
119
|
+
if @group_type == :GROUP_TYPE_GROUPING_SETS
|
|
120
|
+
agg.grouping_sets += (@grouping_sets || []).map do |set|
|
|
121
|
+
Proto::Aggregate::GroupingSets.new(grouping_set: set.map(&:to_expr))
|
|
122
|
+
end
|
|
123
|
+
end
|
|
98
124
|
@df.build(aggregate: agg)
|
|
99
125
|
end
|
|
100
126
|
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SparkConnect
|
|
4
|
+
# Builds and runs a `MERGE INTO` command from a source {DataFrame}. Returned by
|
|
5
|
+
# {DataFrame#merge_into}. Mirrors PySpark's `MergeIntoWriter` (Spark 4.0+).
|
|
6
|
+
#
|
|
7
|
+
# Each `when_*` clause returns a small builder whose action method
|
|
8
|
+
# (`update`, `update_all`, `delete`, `insert`, `insert_all`) records the
|
|
9
|
+
# action and returns this writer, so clauses chain:
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# source.merge_into("target", F.expr("target.id = source.id"))
|
|
13
|
+
# .when_matched(F.expr("source.deleted")).delete
|
|
14
|
+
# .when_matched.update("value" => F.col("source.value"))
|
|
15
|
+
# .when_not_matched.insert_all
|
|
16
|
+
# .when_not_matched_by_source.delete
|
|
17
|
+
# .merge
|
|
18
|
+
class MergeIntoWriter
|
|
19
|
+
Proto = SparkConnect::Proto
|
|
20
|
+
MA = Proto::MergeAction
|
|
21
|
+
|
|
22
|
+
# @param df [DataFrame] the source.
|
|
23
|
+
# @param table [String] the target table.
|
|
24
|
+
# @param condition [Column] the merge condition.
|
|
25
|
+
def initialize(df, table, condition)
|
|
26
|
+
raise IllegalArgumentError, "merge condition must be a Column, got #{condition.class}" unless condition.is_a?(Column)
|
|
27
|
+
|
|
28
|
+
@df = df
|
|
29
|
+
@table = table.to_s
|
|
30
|
+
@condition = condition
|
|
31
|
+
@schema_evolution = false
|
|
32
|
+
@matched = []
|
|
33
|
+
@not_matched = []
|
|
34
|
+
@not_matched_by_source = []
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Actions for target rows that match a source row.
|
|
38
|
+
# @param condition [Column, nil] an additional condition for this clause.
|
|
39
|
+
# @return [WhenMatched]
|
|
40
|
+
def when_matched(condition = nil) = WhenMatched.new(self, condition)
|
|
41
|
+
alias whenMatched when_matched
|
|
42
|
+
|
|
43
|
+
# Actions for source rows without a matching target row.
|
|
44
|
+
# @param condition [Column, nil]
|
|
45
|
+
# @return [WhenNotMatched]
|
|
46
|
+
def when_not_matched(condition = nil) = WhenNotMatched.new(self, condition)
|
|
47
|
+
alias whenNotMatched when_not_matched
|
|
48
|
+
|
|
49
|
+
# Actions for target rows without a matching source row.
|
|
50
|
+
# @param condition [Column, nil]
|
|
51
|
+
# @return [WhenNotMatchedBySource]
|
|
52
|
+
def when_not_matched_by_source(condition = nil) = WhenNotMatchedBySource.new(self, condition)
|
|
53
|
+
alias whenNotMatchedBySource when_not_matched_by_source
|
|
54
|
+
|
|
55
|
+
# Let the merge evolve the target schema (add source columns).
|
|
56
|
+
# @return [self]
|
|
57
|
+
def with_schema_evolution
|
|
58
|
+
@schema_evolution = true
|
|
59
|
+
self
|
|
60
|
+
end
|
|
61
|
+
alias withSchemaEvolution with_schema_evolution
|
|
62
|
+
|
|
63
|
+
# Run the merge. @return [void]
|
|
64
|
+
def merge
|
|
65
|
+
expr = ->(action) { Proto::Expression.new(merge_action: action) }
|
|
66
|
+
cmd = Proto::MergeIntoTableCommand.new(
|
|
67
|
+
target_table_name: @table, source_table_plan: @df.relation, merge_condition: @condition.to_expr,
|
|
68
|
+
match_actions: @matched.map(&expr), not_matched_actions: @not_matched.map(&expr),
|
|
69
|
+
not_matched_by_source_actions: @not_matched_by_source.map(&expr),
|
|
70
|
+
with_schema_evolution: @schema_evolution
|
|
71
|
+
)
|
|
72
|
+
@df.session.client.execute_command(Proto::Command.new(merge_into_table_command: cmd))
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Append a merge action of `kind` (matched, not matched, not matched by
|
|
77
|
+
# source) and return the writer.
|
|
78
|
+
# @api private
|
|
79
|
+
def add_action(kind, type, condition, assignments = nil)
|
|
80
|
+
action = MA.new(action_type: type)
|
|
81
|
+
action.condition = condition.to_expr if condition
|
|
82
|
+
if assignments
|
|
83
|
+
action.assignments += assignments.map do |key, value|
|
|
84
|
+
MA::Assignment.new(key: Functions.expr(key.to_s).to_expr, value: Column.to_col(value).to_expr)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
instance_variable_get(:"@#{kind}") << action
|
|
88
|
+
self
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Clause builder returned by {MergeIntoWriter#when_matched}.
|
|
92
|
+
class WhenMatched
|
|
93
|
+
def initialize(writer, condition)
|
|
94
|
+
@writer = writer
|
|
95
|
+
@condition = condition
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Update every column of the matched target row from the source row.
|
|
99
|
+
# @return [MergeIntoWriter]
|
|
100
|
+
def update_all = @writer.add_action(:matched, :ACTION_TYPE_UPDATE_STAR, @condition)
|
|
101
|
+
alias updateAll update_all
|
|
102
|
+
|
|
103
|
+
# @param assignments [Hash{String=>Column}] target column => new value.
|
|
104
|
+
# @return [MergeIntoWriter]
|
|
105
|
+
def update(assignments) = @writer.add_action(:matched, :ACTION_TYPE_UPDATE, @condition, assignments)
|
|
106
|
+
|
|
107
|
+
# Delete the matched target row. @return [MergeIntoWriter]
|
|
108
|
+
def delete = @writer.add_action(:matched, :ACTION_TYPE_DELETE, @condition)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Clause builder returned by {MergeIntoWriter#when_not_matched}.
|
|
112
|
+
class WhenNotMatched
|
|
113
|
+
def initialize(writer, condition)
|
|
114
|
+
@writer = writer
|
|
115
|
+
@condition = condition
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Insert the source row with all its columns. @return [MergeIntoWriter]
|
|
119
|
+
def insert_all = @writer.add_action(:not_matched, :ACTION_TYPE_INSERT_STAR, @condition)
|
|
120
|
+
alias insertAll insert_all
|
|
121
|
+
|
|
122
|
+
# @param assignments [Hash{String=>Column}] target column => value.
|
|
123
|
+
# @return [MergeIntoWriter]
|
|
124
|
+
def insert(assignments) = @writer.add_action(:not_matched, :ACTION_TYPE_INSERT, @condition, assignments)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Clause builder returned by {MergeIntoWriter#when_not_matched_by_source}.
|
|
128
|
+
class WhenNotMatchedBySource
|
|
129
|
+
def initialize(writer, condition)
|
|
130
|
+
@writer = writer
|
|
131
|
+
@condition = condition
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @return [MergeIntoWriter]
|
|
135
|
+
def update_all = @writer.add_action(:not_matched_by_source, :ACTION_TYPE_UPDATE_STAR, @condition)
|
|
136
|
+
alias updateAll update_all
|
|
137
|
+
|
|
138
|
+
# @param assignments [Hash{String=>Column}] target column => new value.
|
|
139
|
+
# @return [MergeIntoWriter]
|
|
140
|
+
def update(assignments) = @writer.add_action(:not_matched_by_source, :ACTION_TYPE_UPDATE, @condition, assignments)
|
|
141
|
+
|
|
142
|
+
# Delete the unmatched target row. @return [MergeIntoWriter]
|
|
143
|
+
def delete = @writer.add_action(:not_matched_by_source, :ACTION_TYPE_DELETE, @condition)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -12,6 +12,10 @@ module SparkConnect
|
|
|
12
12
|
class DataFrameNaFunctions
|
|
13
13
|
Proto = SparkConnect::Proto
|
|
14
14
|
|
|
15
|
+
# Marker for an omitted `value` argument of {#replace} (distinct from `nil`,
|
|
16
|
+
# which replaces with null).
|
|
17
|
+
NO_VALUE = Object.new.freeze
|
|
18
|
+
|
|
15
19
|
# @param df [DataFrame]
|
|
16
20
|
def initialize(df)
|
|
17
21
|
@df = df
|
|
@@ -26,11 +30,17 @@ module SparkConnect
|
|
|
26
30
|
# @return [DataFrame]
|
|
27
31
|
def drop(how: :any, thresh: nil, subset: nil)
|
|
28
32
|
cols = Array(subset).map(&:to_s)
|
|
29
|
-
min_non_nulls =
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
min_non_nulls =
|
|
34
|
+
case how&.to_sym
|
|
35
|
+
when :all then 1
|
|
36
|
+
when :any, nil then nil
|
|
37
|
+
else raise IllegalArgumentError, "how must be :any or :all, got #{how.inspect}"
|
|
38
|
+
end
|
|
39
|
+
unless thresh.nil?
|
|
40
|
+
raise IllegalArgumentError, "thresh must be an Integer, got #{thresh.class}" unless thresh.is_a?(Integer)
|
|
41
|
+
|
|
42
|
+
min_non_nulls = thresh # `thresh` overrides `how`, like PySpark
|
|
43
|
+
end
|
|
34
44
|
nd = Proto::NADrop.new(input: @df.relation, cols: cols)
|
|
35
45
|
nd.min_non_nulls = min_non_nulls if min_non_nulls
|
|
36
46
|
@df.build(drop_na: nd)
|
|
@@ -46,10 +56,14 @@ module SparkConnect
|
|
|
46
56
|
def fill(value, subset: nil)
|
|
47
57
|
cols, values =
|
|
48
58
|
if value.is_a?(Hash)
|
|
59
|
+
raise IllegalArgumentError, "fill value Hash must not be empty" if value.empty?
|
|
60
|
+
|
|
49
61
|
[value.keys.map(&:to_s), value.values]
|
|
50
62
|
else
|
|
51
|
-
|
|
63
|
+
# A single value applies to every column in `subset` (or all columns).
|
|
64
|
+
[Array(subset).map(&:to_s), [value]]
|
|
52
65
|
end
|
|
66
|
+
values.each { |v| check_fill_value(v) }
|
|
53
67
|
nf = Proto::NAFill.new(
|
|
54
68
|
input: @df.relation, cols: cols, values: values.map { |v| na_literal(v) }
|
|
55
69
|
)
|
|
@@ -64,16 +78,26 @@ module SparkConnect
|
|
|
64
78
|
# is not a Hash.
|
|
65
79
|
# @param subset [Array<String>, nil]
|
|
66
80
|
# @return [DataFrame]
|
|
67
|
-
def replace(to_replace, value =
|
|
81
|
+
def replace(to_replace, value = NO_VALUE, subset: nil)
|
|
68
82
|
mapping =
|
|
69
83
|
if to_replace.is_a?(Hash)
|
|
70
84
|
to_replace
|
|
71
85
|
else
|
|
72
|
-
|
|
86
|
+
raise IllegalArgumentError, "value is required when to_replace is not a Hash" if value.equal?(NO_VALUE)
|
|
87
|
+
|
|
88
|
+
olds = Array(to_replace)
|
|
89
|
+
news = value.is_a?(Array) ? value : Array.new(olds.size, value)
|
|
90
|
+
if olds.size != news.size
|
|
91
|
+
raise IllegalArgumentError, "to_replace and value must have the same length (#{olds.size} vs #{news.size})"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
olds.zip(news).to_h
|
|
73
95
|
end
|
|
96
|
+
# Like PySpark, numeric replacements are sent as doubles (Spark converts
|
|
97
|
+
# them per column type).
|
|
74
98
|
replacements = mapping.map do |old, new_value|
|
|
75
99
|
Proto::NAReplace::Replacement.new(
|
|
76
|
-
old_value:
|
|
100
|
+
old_value: replace_literal(old), new_value: replace_literal(new_value)
|
|
77
101
|
)
|
|
78
102
|
end
|
|
79
103
|
nr = Proto::NAReplace.new(
|
|
@@ -94,5 +118,15 @@ module SparkConnect
|
|
|
94
118
|
else Column.to_literal(value)
|
|
95
119
|
end
|
|
96
120
|
end
|
|
121
|
+
|
|
122
|
+
def replace_literal(value)
|
|
123
|
+
value.is_a?(Integer) ? Proto::Expression::Literal.new(double: value.to_f) : na_literal(value)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def check_fill_value(value)
|
|
127
|
+
return if [true, false].include?(value) || value.is_a?(Numeric) || value.is_a?(String)
|
|
128
|
+
|
|
129
|
+
raise IllegalArgumentError, "fill values must be Boolean, Numeric, or String, got #{value.class}"
|
|
130
|
+
end
|
|
97
131
|
end
|
|
98
132
|
end
|
|
@@ -47,15 +47,36 @@ module SparkConnect
|
|
|
47
47
|
result = @df.session.client.execute_plan(@df.relation)
|
|
48
48
|
observed = result.observed_metrics.find { |m| m.name == @name } || result.observed_metrics.first
|
|
49
49
|
return {} unless observed
|
|
50
|
+
# Spark 4.2+ reports a failure to compute the metrics (e.g. an error in a
|
|
51
|
+
# metric expression) in the metrics message instead of failing the query.
|
|
52
|
+
raise Observation.error_from(observed) if observed.has_root_error_idx?
|
|
50
53
|
|
|
51
54
|
keys = observed.keys.to_a
|
|
52
|
-
|
|
53
|
-
keys.zip(
|
|
55
|
+
keys = Array.new(observed.values.size) { |i| "observed_metric_#{i}" } if keys.empty?
|
|
56
|
+
keys.zip(observed.values.map { |lit| Column.literal_value(lit) }).to_h
|
|
54
57
|
end
|
|
55
58
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
+
class << self
|
|
60
|
+
# Convert the error payload of an observed-metrics message into the
|
|
61
|
+
# matching {SparkConnectError} subclass.
|
|
62
|
+
# @api private
|
|
63
|
+
def error_from(observed)
|
|
64
|
+
errors = observed.errors.to_a
|
|
65
|
+
idx = observed.root_error_idx
|
|
66
|
+
return SparkConnectError.new("Observation error: invalid root_error_idx #{idx}") unless idx.between?(0, errors.size - 1)
|
|
67
|
+
|
|
68
|
+
root = errors[idx]
|
|
69
|
+
hierarchy = root.error_type_hierarchy.to_a
|
|
70
|
+
klass =
|
|
71
|
+
if hierarchy.any? { |c| c.end_with?("ParseException") } then ParseError
|
|
72
|
+
elsif hierarchy.any? { |c| c.end_with?("AnalysisException") } then AnalysisError
|
|
73
|
+
else SparkConnectError
|
|
74
|
+
end
|
|
75
|
+
throwable = root.spark_throwable
|
|
76
|
+
klass.new(root.message,
|
|
77
|
+
error_class: throwable&.has_error_class? ? throwable.error_class : nil,
|
|
78
|
+
sql_state: throwable&.has_sql_state? ? throwable.sql_state : nil)
|
|
79
|
+
end
|
|
59
80
|
end
|
|
60
81
|
end
|
|
61
82
|
end
|
|
@@ -70,6 +70,79 @@ module SparkConnect
|
|
|
70
70
|
table_properties: table_properties, schema: schema)
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
+
# Define a streaming table that flows (e.g. {#create_auto_cdc_flow} or
|
|
74
|
+
# {#define_flow}) can target, without a defining query. Mirrors PySpark's
|
|
75
|
+
# `create_streaming_table`.
|
|
76
|
+
# @return [String] the resolved output identifier.
|
|
77
|
+
def create_streaming_table(name, comment: nil, format: nil, partition_cols: [], clustering_columns: [],
|
|
78
|
+
table_properties: {}, schema: nil)
|
|
79
|
+
define_table_output(name, :table, nil, comment: comment, format: format, partition_cols: partition_cols,
|
|
80
|
+
clustering_columns: clustering_columns, table_properties: table_properties,
|
|
81
|
+
schema: schema)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Define an Auto CDC flow that applies the Change Data Capture events of
|
|
85
|
+
# `source` to the streaming table `target` (Spark 4.2+). Mirrors PySpark's
|
|
86
|
+
# `create_auto_cdc_flow`.
|
|
87
|
+
#
|
|
88
|
+
# @example
|
|
89
|
+
# pipe.create_streaming_table("customers")
|
|
90
|
+
# pipe.create_auto_cdc_flow(target: "customers", source: "customers_cdc", keys: ["id"],
|
|
91
|
+
# sequence_by: "ts", apply_as_deletes: "op = 'DELETE'", stored_as_scd_type: 2)
|
|
92
|
+
#
|
|
93
|
+
# @param target [String] the target streaming table.
|
|
94
|
+
# @param source [String] the CDC source to stream from.
|
|
95
|
+
# @param keys [Array<String, Column>] the columns identifying a row (unqualified).
|
|
96
|
+
# @param sequence_by [String, Column] the ordering expression (a SQL String or Column).
|
|
97
|
+
# @param apply_as_deletes [String, Column, nil] the condition marking a delete event.
|
|
98
|
+
# @param column_list [Array<String, Column>, nil] columns to keep in the target.
|
|
99
|
+
# @param except_column_list [Array<String, Column>, nil] columns to drop from the target.
|
|
100
|
+
# @param stored_as_scd_type [Integer, String, nil] 1 or 2 (server default when nil).
|
|
101
|
+
# @param name [String, nil] the flow name (defaults to `target`).
|
|
102
|
+
# @param track_history_column_list [Array<String, Column>, nil] SCD2 only: columns whose change opens a new history record.
|
|
103
|
+
# @param track_history_except_column_list [Array<String, Column>, nil] SCD2 only: columns excluded from history tracking.
|
|
104
|
+
# @param sql_conf [Hash{String=>String}] SQL configs applied to this flow.
|
|
105
|
+
# @return [String] the resolved flow name.
|
|
106
|
+
def create_auto_cdc_flow(target:, source:, keys:, sequence_by:, apply_as_deletes: nil, column_list: nil,
|
|
107
|
+
except_column_list: nil, stored_as_scd_type: nil, name: nil,
|
|
108
|
+
track_history_column_list: nil, track_history_except_column_list: nil, sql_conf: {})
|
|
109
|
+
%i[target source].zip([target, source]).each do |arg, value|
|
|
110
|
+
raise IllegalArgumentError, "#{arg} must be a String, got #{value.class}" unless value.is_a?(String)
|
|
111
|
+
end
|
|
112
|
+
raise IllegalArgumentError, "name must be a String, got #{name.class}" unless name.nil? || name.is_a?(String)
|
|
113
|
+
|
|
114
|
+
key_cols = cdc_columns("keys", keys)
|
|
115
|
+
columns = cdc_columns("column_list", column_list)
|
|
116
|
+
except = cdc_columns("except_column_list", except_column_list)
|
|
117
|
+
track = cdc_columns("track_history_column_list", track_history_column_list)
|
|
118
|
+
track_except = cdc_columns("track_history_except_column_list", track_history_except_column_list)
|
|
119
|
+
reject_together("column_list", columns, "except_column_list", except)
|
|
120
|
+
reject_together("track_history_column_list", track, "track_history_except_column_list", track_except)
|
|
121
|
+
unless stored_as_scd_type.nil? || %w[1 2].include?(stored_as_scd_type.to_s)
|
|
122
|
+
raise IllegalArgumentError, "stored_as_scd_type must be 1 or 2, got #{stored_as_scd_type.inspect}"
|
|
123
|
+
end
|
|
124
|
+
if stored_as_scd_type.to_s != "2" && (track&.any? || track_except&.any?)
|
|
125
|
+
raise IllegalArgumentError,
|
|
126
|
+
"track_history_column_list/track_history_except_column_list require stored_as_scd_type 2"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
details = PC::DefineFlow::AutoCdcFlowDetails.new(
|
|
130
|
+
source: source, keys: key_cols.map(&:to_expr), sequence_by: cdc_expression("sequence_by", sequence_by).to_expr,
|
|
131
|
+
column_list: (columns || []).map(&:to_expr), except_column_list: (except || []).map(&:to_expr),
|
|
132
|
+
track_history_column_list: (track || []).map(&:to_expr),
|
|
133
|
+
track_history_except_column_list: (track_except || []).map(&:to_expr)
|
|
134
|
+
)
|
|
135
|
+
details.apply_as_deletes = cdc_expression("apply_as_deletes", apply_as_deletes).to_expr unless apply_as_deletes.nil?
|
|
136
|
+
details.stored_as_scd_type = :"SCD_TYPE_#{stored_as_scd_type}" unless stored_as_scd_type.nil?
|
|
137
|
+
flow_name = (name || target).to_s
|
|
138
|
+
flow = PC::DefineFlow.new(
|
|
139
|
+
dataflow_graph_id: @graph_id, flow_name: flow_name, target_dataset_name: target,
|
|
140
|
+
sql_conf: stringify(sql_conf), auto_cdc_flow_details: details
|
|
141
|
+
)
|
|
142
|
+
result = dispatch(PC.new(define_flow: flow))
|
|
143
|
+
identifier_string(result.pipeline_command_result&.define_flow_result&.resolved_identifier) || flow_name
|
|
144
|
+
end
|
|
145
|
+
|
|
73
146
|
# Define a materialized view and the flow that populates it.
|
|
74
147
|
# @return [String]
|
|
75
148
|
def create_materialized_view(name, df = nil, comment: nil, format: nil, partition_cols: [],
|
|
@@ -195,6 +268,34 @@ module SparkConnect
|
|
|
195
268
|
identifier_string(result.pipeline_command_result&.define_output_result&.resolved_identifier) || name.to_s
|
|
196
269
|
end
|
|
197
270
|
|
|
271
|
+
def cdc_columns(arg, cols)
|
|
272
|
+
return nil if cols.nil?
|
|
273
|
+
raise IllegalArgumentError, "#{arg} must be an Array of Strings or Columns, got #{cols.class}" unless cols.is_a?(Array)
|
|
274
|
+
|
|
275
|
+
cols.map do |c|
|
|
276
|
+
case c
|
|
277
|
+
when Column then c
|
|
278
|
+
when String then Functions.col(c)
|
|
279
|
+
else raise IllegalArgumentError, "#{arg} must contain only Strings or Columns, got #{c.class}"
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def cdc_expression(arg, value)
|
|
285
|
+
case value
|
|
286
|
+
when Column then value
|
|
287
|
+
when String then Functions.expr(value)
|
|
288
|
+
else raise IllegalArgumentError, "#{arg} must be a SQL String or Column, got #{value.class}"
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# An empty list is the same as an omitted one, so only non-empty lists clash.
|
|
293
|
+
def reject_together(include_arg, include_cols, except_arg, except_cols)
|
|
294
|
+
return unless include_cols&.any? && except_cols&.any?
|
|
295
|
+
|
|
296
|
+
raise IllegalArgumentError, "#{include_arg} and #{except_arg} cannot be set together"
|
|
297
|
+
end
|
|
298
|
+
|
|
198
299
|
def apply_schema(details, schema)
|
|
199
300
|
if schema.is_a?(Types::DataType)
|
|
200
301
|
details.schema_data_type = schema.to_proto
|
data/lib/spark_connect/plan.rb
CHANGED
|
@@ -35,5 +35,57 @@ module SparkConnect
|
|
|
35
35
|
def command_plan(command)
|
|
36
36
|
Proto::Plan.new(command: command)
|
|
37
37
|
end
|
|
38
|
+
|
|
39
|
+
# When the expressions of `relation` (not of its child relations, which
|
|
40
|
+
# were wrapped when they were built) reference subquery DataFrames
|
|
41
|
+
# ({DataFrame#scalar}, {DataFrame#exists}, {Column#isin} with a DataFrame,
|
|
42
|
+
# {DataFrame#as_table}), wrap it in a `WithRelations` that carries those
|
|
43
|
+
# subquery plans, like PySpark:
|
|
44
|
+
#
|
|
45
|
+
# with_relations [new plan id]
|
|
46
|
+
# root: relation
|
|
47
|
+
# references: [subquery plans...]
|
|
48
|
+
#
|
|
49
|
+
# @param session [SparkSession] the session that registered the subqueries.
|
|
50
|
+
# @param relation [Spark::Connect::Relation]
|
|
51
|
+
# @return [Spark::Connect::Relation]
|
|
52
|
+
def with_subquery_references(session, relation)
|
|
53
|
+
ids = []
|
|
54
|
+
body = relation.rel_type && relation.public_send(relation.rel_type)
|
|
55
|
+
collect_subquery_plan_ids(body, ids) if body
|
|
56
|
+
return relation if ids.empty?
|
|
57
|
+
|
|
58
|
+
references = ids.uniq.map do |id|
|
|
59
|
+
session.subquery_relation(id) or
|
|
60
|
+
raise IllegalArgumentError, "Subquery DataFrame (plan id #{id}) does not belong to this SparkSession"
|
|
61
|
+
end
|
|
62
|
+
Proto::Relation.new(
|
|
63
|
+
common: Proto::RelationCommon.new(plan_id: session.next_plan_id),
|
|
64
|
+
with_relations: Proto::WithRelations.new(root: relation, references: references)
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Collect into `acc` the plan ids of the subquery expressions in `msg`,
|
|
69
|
+
# without descending into child relations.
|
|
70
|
+
# @api private
|
|
71
|
+
def collect_subquery_plan_ids(msg, acc)
|
|
72
|
+
return acc if msg.is_a?(Proto::Relation)
|
|
73
|
+
|
|
74
|
+
acc << msg.plan_id if msg.is_a?(Proto::SubqueryExpression)
|
|
75
|
+
msg.class.descriptor.each do |field|
|
|
76
|
+
next unless field.type == :message
|
|
77
|
+
|
|
78
|
+
value = msg[field.name]
|
|
79
|
+
case value
|
|
80
|
+
when Google::Protobuf::RepeatedField then value.each { |v| collect_subquery_plan_ids(v, acc) }
|
|
81
|
+
when Google::Protobuf::Map
|
|
82
|
+
# Google::Protobuf::Map has no #each_value; iterate its message values.
|
|
83
|
+
value.values.grep(Google::Protobuf::MessageExts).each { |v| collect_subquery_plan_ids(v, acc) }
|
|
84
|
+
when nil then nil
|
|
85
|
+
else collect_subquery_plan_ids(value, acc)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
acc
|
|
89
|
+
end
|
|
38
90
|
end
|
|
39
91
|
end
|