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
@@ -40,17 +40,25 @@ module SparkConnect
40
40
  end
41
41
 
42
42
  # An unresolved attribute reference by (possibly dotted) name. The special
43
- # name `"*"` expands to all columns.
43
+ # name `"*"` expands to all columns and `"t.*"` to all columns of `t`.
44
44
  #
45
45
  # @param name [String]
46
+ # @param plan_id [Integer, nil] resolve against the relation with this plan
47
+ # id (set by {DataFrame#[]} so self-joins stay unambiguous).
48
+ # @param is_metadata_column [Boolean, nil] reference a metadata column.
46
49
  # @return [Column]
47
- def from_name(name)
48
- if name == "*"
49
- new(Proto::Expression.new(unresolved_star: Proto::Expression::UnresolvedStar.new))
50
+ def from_name(name, plan_id: nil, is_metadata_column: nil)
51
+ name = name.to_s
52
+ if name == "*" || name.end_with?(".*")
53
+ star = Proto::Expression::UnresolvedStar.new
54
+ star.unparsed_target = name unless name == "*"
55
+ star.plan_id = plan_id if plan_id
56
+ new(Proto::Expression.new(unresolved_star: star))
50
57
  else
51
- new(Proto::Expression.new(
52
- unresolved_attribute: Proto::Expression::UnresolvedAttribute.new(unparsed_identifier: name.to_s)
53
- ))
58
+ attr = Proto::Expression::UnresolvedAttribute.new(unparsed_identifier: name)
59
+ attr.plan_id = plan_id if plan_id
60
+ attr.is_metadata_column = is_metadata_column unless is_metadata_column.nil?
61
+ new(Proto::Expression.new(unresolved_attribute: attr))
54
62
  end
55
63
  end
56
64
 
@@ -107,7 +115,10 @@ module SparkConnect
107
115
  when Float
108
116
  l.new(double: value)
109
117
  when BigDecimal
110
- l.new(decimal: l::Decimal.new(value: value.to_s("F")))
118
+ # Like PySpark, tag decimal literals as DecimalType(10, 0); the server
119
+ # widens that to fit the value (max of the declared and the value's own
120
+ # precision/scale), so nothing is rounded.
121
+ l.new(decimal: l::Decimal.new(value: decimal_string(value), precision: 10, scale: 0))
111
122
  when Rational
112
123
  l.new(double: value.to_f)
113
124
  when String
@@ -144,6 +155,31 @@ module SparkConnect
144
155
  end
145
156
  end
146
157
 
158
+ # Convert a protobuf `Expression.Literal` into the corresponding Ruby
159
+ # value (the inverse of {to_literal}; used for observed metrics).
160
+ #
161
+ # @param literal [Spark::Connect::Expression::Literal]
162
+ # @return [Object]
163
+ def literal_value(literal)
164
+ kind = literal.literal_type
165
+ case kind
166
+ when nil, :null then nil
167
+ when :decimal then BigDecimal(literal.decimal.value)
168
+ when :date then Date.new(1970, 1, 1) + literal.date
169
+ when :timestamp then Time.at(Rational(literal.timestamp, 1_000_000)).utc
170
+ when :timestamp_ntz then Time.at(Rational(literal.timestamp_ntz, 1_000_000)).utc
171
+ when :array then literal.array.elements.map { |e| literal_value(e) }
172
+ when :map then literal.map.keys.map { |k| literal_value(k) }.zip(literal.map.values.map { |v| literal_value(v) }).to_h
173
+ when :struct then literal.struct.elements.map { |e| literal_value(e) }
174
+ when :specialized_array
175
+ arr = literal.specialized_array
176
+ arr.public_send(arr.value_type).values.to_a
177
+ else
178
+ value = literal.public_send(kind)
179
+ value.is_a?(Google::Protobuf::MessageExts) ? value.to_h : value
180
+ end
181
+ end
182
+
147
183
  # Infer the Spark {Types::DataType} for a Ruby value (used when building
148
184
  # array/map literals). Mirrors PySpark's literal type inference.
149
185
  #
@@ -172,6 +208,13 @@ module SparkConnect
172
208
 
173
209
  private
174
210
 
211
+ # Render a BigDecimal like Python's `str(Decimal)` for the common cases:
212
+ # integral values without a fractional part ("12"), others in plain
213
+ # notation ("1.5").
214
+ def decimal_string(value)
215
+ value.frac.zero? ? value.to_i.to_s : value.to_s("F")
216
+ end
217
+
175
218
  def infer_array_element_type(array)
176
219
  sample = array.find { |v| !v.nil? }
177
220
  sample.nil? ? Types.null : infer_type(sample)
@@ -216,6 +259,13 @@ module SparkConnect
216
259
  def bitwise_and(other) = bin_op("&", other)
217
260
  def bitwise_or(other) = bin_op("|", other)
218
261
  def bitwise_xor(other) = bin_op("^", other)
262
+ alias bitwiseAND bitwise_and
263
+ alias bitwiseOR bitwise_or
264
+ alias bitwiseXOR bitwise_xor
265
+
266
+ # Null-safe equality alias matching PySpark's `eqNullSafe`.
267
+ # @return [Column]
268
+ def eqNullSafe(other) = eq_null_safe(other)
219
269
 
220
270
  # ---- Null / membership predicates -------------------------------------
221
271
  def is_null = Column.invoke("isNull", self)
@@ -223,10 +273,18 @@ module SparkConnect
223
273
  def is_nan = Column.invoke("isNaN", self)
224
274
  alias isNull is_null
225
275
  alias isNotNull is_not_null
276
+ alias isNaN is_nan
226
277
 
227
- # True if the column's value is in `values`.
278
+ # True if the column's value is in `values`, or - given a single-column
279
+ # {DataFrame} - in the rows of that subquery (`IN (SELECT ...)`). A struct
280
+ # column (`F.struct("a", "b")`) compares several columns against a
281
+ # multi-column subquery. IN subqueries need a Spark 4.1+ server.
282
+ #
283
+ # @param values [Array<Object>, DataFrame]
228
284
  # @return [Column]
229
285
  def isin(*values)
286
+ return in_subquery(values.first) if values.size == 1 && values.first.is_a?(DataFrame)
287
+
230
288
  values = values.first if values.size == 1 && values.first.is_a?(Array)
231
289
  Column.invoke("in", self, *Array(values))
232
290
  end
@@ -270,8 +328,41 @@ module SparkConnect
270
328
  # Extract a struct field by name.
271
329
  # @return [Column]
272
330
  def get_field(name)
273
- get_item(name.to_s)
331
+ get_item(name.is_a?(Column) ? name : name.to_s)
274
332
  end
333
+ alias getItem get_item
334
+ alias getField get_field
335
+
336
+ # Add or replace the struct field `field_name` with `col`.
337
+ #
338
+ # @param field_name [String]
339
+ # @param col [Column]
340
+ # @return [Column]
341
+ def with_field(field_name, col)
342
+ raise IllegalArgumentError, "field_name must be a String, got #{field_name.class}" unless field_name.is_a?(String)
343
+ raise IllegalArgumentError, "col must be a Column, got #{col.class}" unless col.is_a?(Column)
344
+
345
+ Column.new(Proto::Expression.new(update_fields: Proto::Expression::UpdateFields.new(
346
+ struct_expression: @expr, field_name: field_name, value_expression: col.to_expr
347
+ )))
348
+ end
349
+ alias withField with_field
350
+
351
+ # Drop one or more (possibly nested, dotted) fields from a struct column.
352
+ #
353
+ # @param field_names [Array<String>]
354
+ # @return [Column]
355
+ def drop_fields(*field_names)
356
+ raise IllegalArgumentError, "drop_fields requires at least one field name" if field_names.empty?
357
+
358
+ expr = field_names.reduce(@expr) do |acc, name|
359
+ raise IllegalArgumentError, "field name must be a String, got #{name.class}" unless name.is_a?(String)
360
+
361
+ Proto::Expression.new(update_fields: Proto::Expression::UpdateFields.new(struct_expression: acc, field_name: name))
362
+ end
363
+ Column.new(expr)
364
+ end
365
+ alias dropFields drop_fields
275
366
 
276
367
  # ---- Aliasing / naming -------------------------------------------------
277
368
  # Assign one or more output names. With multiple names the expression must
@@ -306,6 +397,38 @@ module SparkConnect
306
397
  alias as_type cast
307
398
  alias astype cast
308
399
 
400
+ # Like {#cast}, but returns null instead of failing when the value cannot
401
+ # be cast (TRY mode).
402
+ #
403
+ # @param data_type [Types::DataType, String]
404
+ # @return [Column]
405
+ def try_cast(data_type)
406
+ c = cast(data_type).to_expr.cast
407
+ c.eval_mode = :EVAL_MODE_TRY
408
+ Column.new(Proto::Expression.new(cast: c))
409
+ end
410
+
411
+ # Mark this column as a reference to the outer query inside a subquery
412
+ # (e.g. a correlated {DataFrame#scalar}). Over Spark Connect the server
413
+ # resolves outer references itself, so this returns an equivalent column,
414
+ # like PySpark.
415
+ # @return [Column]
416
+ def outer
417
+ Column.new(@expr)
418
+ end
419
+
420
+ # Apply a function (a block or callable) to this column and return its
421
+ # result, enabling fluent custom transformations.
422
+ #
423
+ # @yieldparam col [Column] self
424
+ # @return [Column]
425
+ def transform(fn = nil, &block)
426
+ fn ||= block
427
+ raise IllegalArgumentError, "transform requires a block or callable" unless fn
428
+
429
+ fn.call(self)
430
+ end
431
+
309
432
  # ---- Sort ordering -----------------------------------------------------
310
433
  def asc = sort_order(:SORT_DIRECTION_ASCENDING, :SORT_NULLS_FIRST)
311
434
  def desc = sort_order(:SORT_DIRECTION_DESCENDING, :SORT_NULLS_LAST)
@@ -364,6 +487,16 @@ module SparkConnect
364
487
 
365
488
  private
366
489
 
490
+ def in_subquery(df)
491
+ values =
492
+ if @expr.expr_type == :unresolved_function && @expr.unresolved_function.function_name == "struct"
493
+ @expr.unresolved_function.arguments.to_a
494
+ else
495
+ [@expr]
496
+ end
497
+ df.subquery_expression(:SUBQUERY_TYPE_IN, in_subquery_values: values)
498
+ end
499
+
367
500
  def bin_op(name, other)
368
501
  Column.invoke(name, self, Column.to_col(other))
369
502
  end
@@ -28,6 +28,20 @@ module SparkConnect
28
28
  nil
29
29
  end
30
30
 
31
+ # Set several configuration properties in one request.
32
+ #
33
+ # @param configs [Hash{String=>Object}]
34
+ # @param silent [Boolean] ignore failures (e.g. static configurations that
35
+ # cannot be changed at runtime) on Spark 4.0+ servers.
36
+ # @return [void]
37
+ def set_all(configs, silent: false)
38
+ pairs = configs.map { |k, v| Proto::KeyValue.new(key: k.to_s, value: v.to_s) }
39
+ set = CR::Set.new(pairs: pairs)
40
+ set.silent = true if silent
41
+ @client.config(Op.new(set: set))
42
+ nil
43
+ end
44
+
31
45
  # Get the value of a configuration property.
32
46
  #
33
47
  # @param key [String]
@@ -75,5 +89,7 @@ module SparkConnect
75
89
  resp = @client.config(Op.new(is_modifiable: CR::IsModifiable.new(keys: [key.to_s])))
76
90
  resp.pairs.first&.value == "true"
77
91
  end
92
+ alias is_modifiable modifiable?
93
+ alias isModifiable modifiable?
78
94
  end
79
95
  end