spark-connect 0.3.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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +144 -27
  3. data/README.md +29 -11
  4. data/lib/spark_connect/catalog.rb +157 -12
  5. data/lib/spark_connect/channel_builder.rb +119 -12
  6. data/lib/spark_connect/client.rb +233 -17
  7. data/lib/spark_connect/column.rb +143 -10
  8. data/lib/spark_connect/conf.rb +16 -0
  9. data/lib/spark_connect/data_frame.rb +428 -23
  10. data/lib/spark_connect/functions.rb +1947 -493
  11. data/lib/spark_connect/grouped_data.rb +33 -7
  12. data/lib/spark_connect/merge.rb +146 -0
  13. data/lib/spark_connect/na_functions.rb +43 -9
  14. data/lib/spark_connect/observation.rb +26 -5
  15. data/lib/spark_connect/pipelines.rb +101 -0
  16. data/lib/spark_connect/plan.rb +52 -0
  17. data/lib/spark_connect/proto/spark/connect/base_pb.rb +6 -1
  18. data/lib/spark_connect/proto/spark/connect/base_services_pb.rb +2 -0
  19. data/lib/spark_connect/proto/spark/connect/catalog_pb.rb +11 -1
  20. data/lib/spark_connect/proto/spark/connect/commands_pb.rb +1 -1
  21. data/lib/spark_connect/proto/spark/connect/expressions_pb.rb +3 -1
  22. data/lib/spark_connect/proto/spark/connect/pipelines_pb.rb +5 -1
  23. data/lib/spark_connect/proto/spark/connect/relations_pb.rb +4 -1
  24. data/lib/spark_connect/proto/spark/connect/types_pb.rb +3 -1
  25. data/lib/spark_connect/reader.rb +98 -17
  26. data/lib/spark_connect/session.rb +232 -30
  27. data/lib/spark_connect/stat_functions.rb +19 -9
  28. data/lib/spark_connect/storage_level.rb +79 -0
  29. data/lib/spark_connect/streaming.rb +335 -20
  30. data/lib/spark_connect/table_arg.rb +73 -0
  31. data/lib/spark_connect/tvf.rb +78 -0
  32. data/lib/spark_connect/types.rb +149 -0
  33. data/lib/spark_connect/version.rb +2 -2
  34. data/lib/spark_connect/writer.rb +80 -10
  35. data/lib/spark_connect.rb +4 -0
  36. data/proto/spark/connect/base.proto +93 -1
  37. data/proto/spark/connect/catalog.proto +81 -0
  38. data/proto/spark/connect/commands.proto +7 -0
  39. data/proto/spark/connect/expressions.proto +28 -0
  40. data/proto/spark/connect/pipelines.proto +92 -5
  41. data/proto/spark/connect/relations.proto +71 -1
  42. data/proto/spark/connect/types.proto +19 -0
  43. metadata +6 -2
@@ -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
- def initialize(df, grouping, group_type, pivot_col: nil, pivot_values: nil)
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
- GroupedData.new(@df, @grouping, :GROUP_TYPE_PIVOT,
75
- pivot_col: Column.to_col(pivot_col.is_a?(String) ? Functions.col(pivot_col) : pivot_col),
76
- pivot_values: values)
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
- exprs = cols.flatten.map { |c| Column.invoke(fn, Functions.col(c.to_s)).to_expr }
83
- build(exprs)
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 = thresh || (if how.to_sym == :all
30
- 1
31
- else
32
- (cols.empty? ? nil : cols.size)
33
- end)
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
- [Array(subset).map(&:to_s), Array(subset).empty? ? [value] : Array(subset).map { value }]
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 = nil, subset: nil)
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
- Array(to_replace).zip(Array(value)).to_h
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: na_literal(old), new_value: na_literal(new_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
- decoded = observed.values.map { |lit| decode_literal(lit) }
53
- keys.zip(decoded).to_h
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
- def decode_literal(literal)
57
- kind = literal.literal_type
58
- kind ? literal.public_send(kind) : nil
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
@@ -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