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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +173 -1
  3. data/README.md +37 -13
  4. data/lib/spark_connect/arrow.rb +5 -1
  5. data/lib/spark_connect/catalog.rb +157 -12
  6. data/lib/spark_connect/channel_builder.rb +119 -12
  7. data/lib/spark_connect/client.rb +237 -17
  8. data/lib/spark_connect/column.rb +143 -10
  9. data/lib/spark_connect/conf.rb +17 -1
  10. data/lib/spark_connect/data_frame.rb +447 -25
  11. data/lib/spark_connect/functions.rb +1948 -494
  12. data/lib/spark_connect/grouped_data.rb +33 -7
  13. data/lib/spark_connect/merge.rb +146 -0
  14. data/lib/spark_connect/na_functions.rb +43 -9
  15. data/lib/spark_connect/observation.rb +26 -5
  16. data/lib/spark_connect/pipelines.rb +101 -0
  17. data/lib/spark_connect/plan.rb +52 -0
  18. data/lib/spark_connect/proto/spark/connect/base_pb.rb +6 -1
  19. data/lib/spark_connect/proto/spark/connect/base_services_pb.rb +2 -0
  20. data/lib/spark_connect/proto/spark/connect/catalog_pb.rb +11 -1
  21. data/lib/spark_connect/proto/spark/connect/commands_pb.rb +1 -1
  22. data/lib/spark_connect/proto/spark/connect/expressions_pb.rb +3 -1
  23. data/lib/spark_connect/proto/spark/connect/pipelines_pb.rb +5 -1
  24. data/lib/spark_connect/proto/spark/connect/relations_pb.rb +4 -1
  25. data/lib/spark_connect/proto/spark/connect/types_pb.rb +3 -1
  26. data/lib/spark_connect/reader.rb +98 -17
  27. data/lib/spark_connect/session.rb +232 -30
  28. data/lib/spark_connect/stat_functions.rb +19 -9
  29. data/lib/spark_connect/storage_level.rb +79 -0
  30. data/lib/spark_connect/streaming.rb +335 -20
  31. data/lib/spark_connect/table_arg.rb +73 -0
  32. data/lib/spark_connect/tvf.rb +78 -0
  33. data/lib/spark_connect/types.rb +149 -0
  34. data/lib/spark_connect/version.rb +2 -2
  35. data/lib/spark_connect/writer.rb +80 -10
  36. data/lib/spark_connect.rb +4 -0
  37. data/proto/spark/connect/base.proto +93 -1
  38. data/proto/spark/connect/catalog.proto +81 -0
  39. data/proto/spark/connect/commands.proto +7 -0
  40. data/proto/spark/connect/expressions.proto +28 -0
  41. data/proto/spark/connect/pipelines.proto +92 -5
  42. data/proto/spark/connect/relations.proto +71 -1
  43. data/proto/spark/connect/types.proto +19 -0
  44. metadata +10 -3
@@ -43,6 +43,17 @@ module SparkConnect
43
43
  left_anti: :JOIN_TYPE_LEFT_ANTI,
44
44
  }.freeze
45
45
 
46
+ # Join types accepted by {#lateral_join}.
47
+ LATERAL_JOIN_TYPES = {
48
+ inner: :JOIN_TYPE_INNER, left: :JOIN_TYPE_LEFT_OUTER, leftouter: :JOIN_TYPE_LEFT_OUTER, cross: :JOIN_TYPE_CROSS,
49
+ }.freeze
50
+
51
+ # {#nearest_by_join} argument domains (mirroring Spark's `NearestByJoinValidation`).
52
+ NEAREST_BY_JOIN_MAX_NUM_RESULTS = 100_000
53
+ NEAREST_BY_JOIN_JOIN_TYPES = %w[inner leftouter left].freeze
54
+ NEAREST_BY_JOIN_MODES = %w[approx exact].freeze
55
+ NEAREST_BY_JOIN_DIRECTIONS = %w[distance similarity].freeze
56
+
46
57
  # @return [SparkSession]
47
58
  attr_reader :session
48
59
  # @return [Spark::Connect::Relation] the logical plan this DataFrame builds.
@@ -182,7 +193,24 @@ module SparkConnect
182
193
  build(deduplicate: dedup)
183
194
  end
184
195
  alias dropDuplicates drop_duplicates
185
- alias drop_duplicates_within_watermark drop_duplicates
196
+
197
+ # Drop duplicate rows within the event-time watermark, optionally restricted
198
+ # to a subset of columns. Unlike {#drop_duplicates}, this is watermark-aware
199
+ # and is intended for streaming DataFrames (mirrors PySpark's
200
+ # `dropDuplicatesWithinWatermark`).
201
+ #
202
+ # @param subset [Array<String>, nil]
203
+ # @return [DataFrame]
204
+ def drop_duplicates_within_watermark(subset = nil)
205
+ dedup =
206
+ if subset.nil? || subset.empty?
207
+ Proto::Deduplicate.new(input: @relation, all_columns_as_keys: true, within_watermark: true)
208
+ else
209
+ Proto::Deduplicate.new(input: @relation, column_names: Array(subset).map(&:to_s), within_watermark: true)
210
+ end
211
+ build(deduplicate: dedup)
212
+ end
213
+ alias dropDuplicatesWithinWatermark drop_duplicates_within_watermark
186
214
 
187
215
  # ---- Ordering ----------------------------------------------------------
188
216
 
@@ -224,7 +252,7 @@ module SparkConnect
224
252
  # @param cols [Array<Column, String>]
225
253
  # @return [GroupedData]
226
254
  def group_by(*cols)
227
- GroupedData.new(self, normalize_columns(cols), :GROUP_TYPE_GROUPBY)
255
+ GroupedData.new(self, grouping_columns(cols), :GROUP_TYPE_GROUPBY)
228
256
  end
229
257
  alias groupBy group_by
230
258
  alias groupby group_by
@@ -232,15 +260,28 @@ module SparkConnect
232
260
  # Multi-dimensional rollup.
233
261
  # @return [GroupedData]
234
262
  def rollup(*cols)
235
- GroupedData.new(self, normalize_columns(cols), :GROUP_TYPE_ROLLUP)
263
+ GroupedData.new(self, grouping_columns(cols), :GROUP_TYPE_ROLLUP)
236
264
  end
237
265
 
238
266
  # Multi-dimensional cube.
239
267
  # @return [GroupedData]
240
268
  def cube(*cols)
241
- GroupedData.new(self, normalize_columns(cols), :GROUP_TYPE_CUBE)
269
+ GroupedData.new(self, grouping_columns(cols), :GROUP_TYPE_CUBE)
242
270
  end
243
271
 
272
+ # Aggregate over explicit grouping sets (`GROUP BY ... GROUPING SETS (...)`).
273
+ #
274
+ # @example
275
+ # df.grouping_sets([["dept", "year"], ["dept"], []], "dept", "year").agg(F.sum("x"))
276
+ # @param grouping_sets [Array<Array<Column, String, Integer>>]
277
+ # @param cols [Array<Column, String, Integer>] the grouping columns.
278
+ # @return [GroupedData]
279
+ def grouping_sets(grouping_sets, *cols)
280
+ sets = grouping_sets.map { |set| grouping_columns(Array(set)) }
281
+ GroupedData.new(self, grouping_columns(cols), :GROUP_TYPE_GROUPING_SETS, grouping_sets: sets)
282
+ end
283
+ alias groupingSets grouping_sets
284
+
244
285
  # Aggregate over the whole DataFrame (a group-by with no grouping columns).
245
286
  #
246
287
  # @param exprs [Array<Column>, Hash]
@@ -254,23 +295,82 @@ module SparkConnect
254
295
  # Join with another DataFrame.
255
296
  #
256
297
  # @param other [DataFrame]
257
- # @param on [String, Array<String>, Column, nil] join key column name(s) or a
258
- # boolean join condition.
259
- # @param how [Symbol, String] join type (see {JOIN_TYPES}).
298
+ # @param on [String, Array<String>, Column, Array<Column>, nil] join key
299
+ # column name(s), a boolean join condition, or several conditions that
300
+ # are combined with AND.
301
+ # @param how [Symbol, String, nil] join type (see {JOIN_TYPES}); `inner` by
302
+ # default. Case and underscores are ignored (`"LEFT_OUTER"` == `"leftouter"`).
260
303
  # @return [DataFrame]
261
304
  def join(other, on: nil, how: :inner)
262
- jt = JOIN_TYPES[how.to_s.downcase.to_sym] or
263
- raise IllegalArgumentError, "Unsupported join type: #{how}"
264
- j = Proto::Join.new(left: @relation, right: other.relation, join_type: jt)
305
+ check_same_session(other)
306
+ j = Proto::Join.new(left: @relation, right: other.relation, join_type: join_type(how))
265
307
  case on
266
308
  when nil then nil
267
309
  when Column then j.join_condition = on.to_expr
268
- when Array then j.using_columns += on.map(&:to_s)
310
+ when Array
311
+ if on.all?(Column)
312
+ j.join_condition = on.reduce(:&).to_expr unless on.empty?
313
+ else
314
+ j.using_columns += on.map(&:to_s)
315
+ end
269
316
  else j.using_columns << on.to_s
270
317
  end
271
318
  build(join: j)
272
319
  end
273
320
 
321
+ # Lateral join with a DataFrame that may reference this one's columns via
322
+ # {Column#outer} (e.g. a table-valued function over each row).
323
+ #
324
+ # @param other [DataFrame]
325
+ # @param on [Column, nil] the join condition.
326
+ # @param how [Symbol, String, nil] `inner` (default), `left`/`leftouter`, or `cross`.
327
+ # @return [DataFrame]
328
+ def lateral_join(other, on: nil, how: nil)
329
+ check_same_session(other)
330
+ jt = how.nil? ? :JOIN_TYPE_INNER : LATERAL_JOIN_TYPES[normalize_how(how)]
331
+ raise IllegalArgumentError, "Unsupported lateral join type: #{how} (supported: inner, left, leftouter, cross)" unless jt
332
+
333
+ lj = Proto::LateralJoin.new(left: @relation, right: other.relation, join_type: jt)
334
+ lj.join_condition = on.to_expr if on
335
+ build(lateral_join: lj)
336
+ end
337
+ alias lateralJoin lateral_join
338
+
339
+ # Combine the columns of this DataFrame with those of `other` side by side
340
+ # (Spark 4.3+). Both must derive from the same source through
341
+ # projection-only operations (`select`, `with_column`, ...) so that their
342
+ # rows stay aligned; the server rejects anything else.
343
+ #
344
+ # @param other [DataFrame]
345
+ # @return [DataFrame] the columns of `self` followed by those of `other`.
346
+ def zip(other)
347
+ check_same_session(other)
348
+ build(zip: Proto::Zip.new(left: @relation, right: other.relation))
349
+ end
350
+
351
+ # For each row of this DataFrame (the query side), join up to
352
+ # `num_results` rows of `other` (the base side) ranked by
353
+ # `ranking_expression` (Spark 4.2+).
354
+ #
355
+ # @param other [DataFrame]
356
+ # @param ranking_expression [Column] a scalar ranking expression.
357
+ # @param num_results [Integer] matches per row, 1..100000.
358
+ # @param mode [String] `"approx"` or `"exact"`.
359
+ # @param direction [String] `"distance"` (smallest first) or `"similarity"` (largest first).
360
+ # @param join_type [String] `"inner"` (default) or `"leftouter"`.
361
+ # @raise [AnalysisError] for invalid arguments (`NEAREST_BY_JOIN.*` error classes).
362
+ # @return [DataFrame]
363
+ def nearest_by_join(other, ranking_expression, num_results, mode, direction, join_type: "inner")
364
+ check_same_session(other)
365
+ validate_nearest_by_join(num_results, join_type.to_s, mode.to_s, direction.to_s)
366
+ nbj = Proto::NearestByJoin.new(
367
+ left: @relation, right: other.relation, ranking_expression: Column.to_col(ranking_expression).to_expr,
368
+ num_results: Integer(num_results), join_type: join_type.to_s, mode: mode.to_s, direction: direction.to_s
369
+ )
370
+ build(nearest_by_join: nbj)
371
+ end
372
+ alias nearestByJoin nearest_by_join
373
+
274
374
  # Cartesian product with another DataFrame.
275
375
  # @return [DataFrame]
276
376
  def cross_join(other)
@@ -311,7 +411,7 @@ module SparkConnect
311
411
  end
312
412
  alias intersectAll intersect_all
313
413
 
314
- # Rows in this DataFrame not in `other` (distinct).
414
+ # Rows in this DataFrame not in `other`, keeping duplicates - Spark's `EXCEPT ALL`.
315
415
  # @return [DataFrame]
316
416
  def except_all(other)
317
417
  set_op(other, :SET_OP_TYPE_EXCEPT, is_all: true)
@@ -342,6 +442,25 @@ module SparkConnect
342
442
  end
343
443
  end
344
444
 
445
+ # Repartition into `num_partitions` sending each row directly to the
446
+ # partition given by the integer column `partition_id_col` (Spark 4.1+).
447
+ #
448
+ # @param num_partitions [Integer]
449
+ # @param partition_id_col [Column, String]
450
+ # @return [DataFrame]
451
+ def repartition_by_id(num_partitions, partition_id_col)
452
+ unless num_partitions.is_a?(Integer) && num_partitions.positive?
453
+ raise IllegalArgumentError, "num_partitions must be a positive Integer, got #{num_partitions.inspect}"
454
+ end
455
+
456
+ pid = Functions._col(partition_id_col).to_expr
457
+ expr = Proto::Expression.new(direct_shuffle_partition_id: Proto::Expression::DirectShufflePartitionID.new(child: pid))
458
+ build(repartition_by_expression: Proto::RepartitionByExpression.new(
459
+ input: @relation, partition_exprs: [expr], num_partitions: num_partitions
460
+ ))
461
+ end
462
+ alias repartitionById repartition_by_id
463
+
345
464
  # Reduce to `num_partitions` without a full shuffle.
346
465
  # @return [DataFrame]
347
466
  def coalesce(num_partitions)
@@ -381,13 +500,34 @@ module SparkConnect
381
500
  # @param seed [Integer, nil]
382
501
  # @return [DataFrame]
383
502
  def sample(fraction, with_replacement: false, seed: nil)
503
+ # Like PySpark, pick the seed on the client so the sample is stable across actions.
384
504
  s = Proto::Sample.new(
385
- input: @relation, lower_bound: 0.0, upper_bound: fraction, with_replacement: with_replacement
505
+ input: @relation, lower_bound: 0.0, upper_bound: fraction.to_f, with_replacement: with_replacement,
506
+ seed: seed.nil? ? random_seed : Integer(seed)
386
507
  )
387
- s.seed = seed if seed
388
508
  build(sample: s)
389
509
  end
390
510
 
511
+ # Randomly split this DataFrame by `weights` (normalized to sum to 1).
512
+ #
513
+ # @param weights [Array<Numeric>] non-negative weights.
514
+ # @param seed [Integer, nil] random seed (generated when omitted).
515
+ # @return [Array<DataFrame>]
516
+ def random_split(weights, seed = nil)
517
+ raise IllegalArgumentError, "weights must be non-negative, got #{weights.inspect}" if weights.any?(&:negative?)
518
+
519
+ total = weights.sum.to_f
520
+ raise IllegalArgumentError, "the sum of weights must be positive, got #{total}" unless total.positive?
521
+
522
+ seed = seed.nil? ? random_seed : Integer(seed)
523
+ bounds = weights.each_with_object([0.0]) { |w, acc| acc << (acc.last + (w / total)) }
524
+ bounds.each_cons(2).map do |lower, upper|
525
+ build(sample: Proto::Sample.new(input: @relation, lower_bound: lower, upper_bound: upper,
526
+ with_replacement: false, seed: seed, deterministic_order: true))
527
+ end
528
+ end
529
+ alias randomSplit random_split
530
+
391
531
  # ---- Misc transforms ---------------------------------------------------
392
532
 
393
533
  # Alias this DataFrame (a subquery alias usable in join conditions).
@@ -403,8 +543,9 @@ module SparkConnect
403
543
  # @param params [Array]
404
544
  # @return [DataFrame]
405
545
  def hint(name, *params)
546
+ params = params.first if params.size == 1 && params.first.is_a?(Array)
406
547
  h = Proto::Hint.new(input: @relation, name: name.to_s,
407
- parameters: params.map { |p| Column.to_col(p).to_expr })
548
+ parameters: params.map { |p| Column.lit(p).to_expr })
408
549
  build(hint: h)
409
550
  end
410
551
 
@@ -439,6 +580,45 @@ module SparkConnect
439
580
  DataFrameStatFunctions.new(self)
440
581
  end
441
582
 
583
+ # Shortcut for {DataFrameNaFunctions#drop} (PySpark's `dropna`).
584
+ # @return [DataFrame]
585
+ def dropna(how: :any, thresh: nil, subset: nil) = na.drop(how: how, thresh: thresh, subset: subset)
586
+
587
+ # Shortcut for {DataFrameNaFunctions#fill} (PySpark's `fillna`).
588
+ # @return [DataFrame]
589
+ def fillna(value, subset: nil) = na.fill(value, subset: subset)
590
+
591
+ # Shortcut for {DataFrameNaFunctions#replace}.
592
+ # @return [DataFrame]
593
+ def replace(to_replace, value = DataFrameNaFunctions::NO_VALUE, subset: nil) = na.replace(to_replace, value, subset: subset)
594
+
595
+ # Shortcut for {DataFrameStatFunctions#approx_quantile}.
596
+ # @return [Array<Float>, Array<Array<Float>>]
597
+ def approx_quantile(cols, probabilities, relative_error) = stat.approx_quantile(cols, probabilities, relative_error)
598
+ alias approxQuantile approx_quantile
599
+
600
+ # Shortcut for {DataFrameStatFunctions#corr}.
601
+ # @return [Float]
602
+ def corr(col1, col2, method = nil) = stat.corr(col1, col2, method)
603
+
604
+ # Shortcut for {DataFrameStatFunctions#cov}.
605
+ # @return [Float]
606
+ def cov(col1, col2) = stat.cov(col1, col2)
607
+
608
+ # Shortcut for {DataFrameStatFunctions#crosstab}.
609
+ # @return [DataFrame]
610
+ def crosstab(col1, col2) = stat.crosstab(col1, col2)
611
+
612
+ # Shortcut for {DataFrameStatFunctions#freq_items}.
613
+ # @return [DataFrame]
614
+ def freq_items(cols, support = nil) = stat.freq_items(cols, support)
615
+ alias freqItems freq_items
616
+
617
+ # Shortcut for {DataFrameStatFunctions#sample_by}.
618
+ # @return [DataFrame]
619
+ def sample_by(col, fractions, seed = nil) = stat.sample_by(col, fractions, seed)
620
+ alias sampleBy sample_by
621
+
442
622
  # @return [DataFrameWriter] interface for saving this DataFrame.
443
623
  def write
444
624
  DataFrameWriter.new(self)
@@ -457,6 +637,21 @@ module SparkConnect
457
637
  end
458
638
  alias writeStream write_stream
459
639
 
640
+ # Start a `MERGE INTO table` from this (source) DataFrame.
641
+ #
642
+ # @example
643
+ # source.merge_into("target", F.col("t.id") == F.col("s.id"))
644
+ # .when_matched.update_all
645
+ # .when_not_matched.insert_all
646
+ # .merge
647
+ # @param table [String] the target table.
648
+ # @param condition [Column] the merge condition.
649
+ # @return [MergeIntoWriter]
650
+ def merge_into(table, condition)
651
+ MergeIntoWriter.new(self, table, condition)
652
+ end
653
+ alias mergeInto merge_into
654
+
460
655
  # ---- Temporary views ---------------------------------------------------
461
656
 
462
657
  # Register this DataFrame as a session-scoped temporary view, failing if a
@@ -488,15 +683,83 @@ module SparkConnect
488
683
  end
489
684
  alias createOrReplaceGlobalTempView create_or_replace_global_temp_view
490
685
 
686
+ # Deprecated alias of {#create_or_replace_temp_view} (kept for PySpark parity).
687
+ # @return [void]
688
+ def register_temp_table(name)
689
+ create_or_replace_temp_view(name)
690
+ end
691
+ alias registerTempTable register_temp_table
692
+
491
693
  # Select columns by a regular expression matched against their names.
492
694
  #
493
695
  # @param regex [String]
494
696
  # @return [Column]
495
697
  def col_regex(regex)
496
- Column.new(Proto::Expression.new(unresolved_regex: Proto::Expression::UnresolvedRegex.new(col_name: regex.to_s)))
698
+ raise IllegalArgumentError, "col_regex expects a String, got #{regex.class}" unless regex.is_a?(String)
699
+
700
+ Column.new(Proto::Expression.new(
701
+ unresolved_regex: Proto::Expression::UnresolvedRegex.new(col_name: regex, plan_id: plan_id)
702
+ ))
497
703
  end
498
704
  alias colRegex col_regex
499
705
 
706
+ # A column of this DataFrame bound to it by plan id, so it stays
707
+ # unambiguous in self-joins (like `df.col` / `df["col"]` in PySpark).
708
+ #
709
+ # @param name [String]
710
+ # @return [Column]
711
+ def col(name)
712
+ Column.from_name(name.to_s, plan_id: plan_id)
713
+ end
714
+
715
+ # A metadata column (e.g. `_metadata` of file sources) of this DataFrame.
716
+ #
717
+ # @param name [String]
718
+ # @return [Column]
719
+ def metadata_column(name)
720
+ raise IllegalArgumentError, "metadata_column expects a String, got #{name.class}" unless name.is_a?(String)
721
+
722
+ Column.from_name(name, plan_id: plan_id, is_metadata_column: true)
723
+ end
724
+ alias metadataColumn metadata_column
725
+
726
+ # Attach JSON-serializable `metadata` to the column `column_name`.
727
+ #
728
+ # @param column_name [String]
729
+ # @param metadata [Hash]
730
+ # @return [DataFrame]
731
+ def with_metadata(column_name, metadata)
732
+ raise IllegalArgumentError, "metadata must be a Hash, got #{metadata.class}" unless metadata.is_a?(Hash)
733
+
734
+ a = Proto::Expression::Alias.new(expr: col(column_name.to_s).to_expr, name: [column_name.to_s],
735
+ metadata: JSON.generate(metadata))
736
+ build(with_columns: Proto::WithColumns.new(input: @relation, aliases: [a]))
737
+ end
738
+ alias withMetadata with_metadata
739
+
740
+ # Append a column of consecutive 0-based Long row indices (Spark 4.2+),
741
+ # like `RDD#zipWithIndex`.
742
+ #
743
+ # @param index_col_name [String]
744
+ # @return [DataFrame]
745
+ def zip_with_index(index_col_name = "index")
746
+ seq = Proto::Expression.new(
747
+ unresolved_function: Proto::Expression::UnresolvedFunction.new(function_name: "distributed_sequence_id")
748
+ )
749
+ select(Functions.col("*"), Column.new(seq).alias(index_col_name.to_s))
750
+ end
751
+ alias zipWithIndex zip_with_index
752
+
753
+ # Transpose rows and columns. The `index_column` (the first column when
754
+ # omitted) supplies the new column names.
755
+ #
756
+ # @param index_column [Column, String, nil]
757
+ # @return [DataFrame]
758
+ def transpose(index_column = nil)
759
+ cols = index_column.nil? ? [] : [Functions._col(index_column).to_expr]
760
+ build(transpose: Proto::Transpose.new(input: @relation, index_columns: cols))
761
+ end
762
+
500
763
  # @return [DataFrame] a single-column (`value`) DataFrame of each row encoded
501
764
  # as a JSON string.
502
765
  def to_json(*_args)
@@ -539,9 +802,10 @@ module SparkConnect
539
802
  # storage), which is faster but not fault-tolerant.
540
803
  #
541
804
  # @param eager [Boolean]
805
+ # @param storage_level [StorageLevel, nil] where to keep the checkpointed data.
542
806
  # @return [DataFrame]
543
- def local_checkpoint(eager: true)
544
- checkpoint_command(local: true, eager: eager)
807
+ def local_checkpoint(eager: true, storage_level: nil)
808
+ checkpoint_command(local: true, eager: eager, storage_level: storage_level)
545
809
  end
546
810
  alias localCheckpoint local_checkpoint
547
811
 
@@ -551,6 +815,9 @@ module SparkConnect
551
815
  # @param exprs [Array<Column>]
552
816
  # @return [DataFrame]
553
817
  def observe(name, *exprs)
818
+ raise IllegalArgumentError, "observe requires at least one expression" if exprs.empty?
819
+ raise IllegalArgumentError, "observe expressions must be Columns" unless exprs.all?(Column)
820
+
554
821
  obs_name = name.is_a?(Observation) ? name.name : name.to_s
555
822
  cm = Proto::CollectMetrics.new(
556
823
  input: @relation, name: obs_name, metrics: exprs.map { |e| Column.to_col(e).to_expr }
@@ -560,6 +827,67 @@ module SparkConnect
560
827
  df
561
828
  end
562
829
 
830
+ # ---- Subqueries ----------------------------------------------------------
831
+
832
+ # This (single-row, single-column) DataFrame as a scalar subquery
833
+ # {Column}, e.g. `df.filter(F.col("x") > other.select(F.max("x")).scalar)`.
834
+ # Correlated references to the outer query use {Column#outer}.
835
+ # @return [Column]
836
+ def scalar
837
+ subquery_expression(:SUBQUERY_TYPE_SCALAR)
838
+ end
839
+
840
+ # An `EXISTS (subquery)` {Column} over this DataFrame.
841
+ # @return [Column]
842
+ def exists
843
+ subquery_expression(:SUBQUERY_TYPE_EXISTS)
844
+ end
845
+
846
+ # This DataFrame as a table argument for a table-valued function, with
847
+ # optional partitioning/ordering (see {TableArg}).
848
+ # @return [TableArg]
849
+ def as_table
850
+ TableArg.new(self)
851
+ end
852
+ alias asTable as_table
853
+
854
+ # ---- Caching -------------------------------------------------------------
855
+
856
+ # Persist with the default storage level (`MEMORY_AND_DISK_DESER`).
857
+ # @return [self]
858
+ def cache
859
+ persist
860
+ end
861
+
862
+ # Persist this DataFrame with the given storage level.
863
+ # @param storage_level [StorageLevel]
864
+ # @return [self]
865
+ def persist(storage_level = StorageLevel::MEMORY_AND_DISK_DESER)
866
+ analyze(persist: Proto::AnalyzePlanRequest::Persist.new(relation: @relation, storage_level: storage_level.to_proto))
867
+ self
868
+ end
869
+
870
+ # Remove this DataFrame from the cache.
871
+ # @param blocking [Boolean] wait until all blocks are deleted.
872
+ # @return [self]
873
+ def unpersist(blocking = false)
874
+ analyze(unpersist: Proto::AnalyzePlanRequest::Unpersist.new(relation: @relation, blocking: blocking))
875
+ self
876
+ end
877
+
878
+ # @return [StorageLevel] the current storage level (`StorageLevel::NONE` when not cached).
879
+ def storage_level
880
+ resp = analyze(get_storage_level: Proto::AnalyzePlanRequest::GetStorageLevel.new(relation: @relation))
881
+ StorageLevel.from_proto(resp.get_storage_level.storage_level)
882
+ end
883
+ alias storageLevel storage_level
884
+
885
+ # @return [Boolean] whether this DataFrame is cached.
886
+ def cached?
887
+ storage_level != StorageLevel::NONE
888
+ end
889
+ alias is_cached cached?
890
+
563
891
  # ---- Schema introspection ---------------------------------------------
564
892
 
565
893
  # @return [Types::StructType] the DataFrame's schema.
@@ -589,21 +917,25 @@ module SparkConnect
589
917
  end
590
918
  alias printSchema print_schema
591
919
 
592
- # Index into a column by name (`df["id"]`) or position (`df[0]`).
920
+ # Index into a column by name (`df["id"]`, bound to this DataFrame like
921
+ # {#col}) or position (`df[0]`), filter by a boolean {Column}
922
+ # (`df[df["x"] > 1]`), or select several columns (`df[["a", "b"]]`).
593
923
  #
594
- # @param key [String, Symbol, Integer]
595
- # @return [Column]
924
+ # @param key [String, Symbol, Integer, Column, Array]
925
+ # @return [Column, DataFrame]
596
926
  def [](key)
597
927
  case key
598
928
  when Integer then Functions.col(columns[key])
599
- else Functions.col(key.to_s)
929
+ when Column then filter(key)
930
+ when Array then select(*key)
931
+ else col(key.to_s)
600
932
  end
601
933
  end
602
934
 
603
935
  # Allows `df.column_name` for valid identifier column names.
604
936
  def method_missing(name, *args)
605
937
  if args.empty? && columns.include?(name.to_s)
606
- Functions.col(name.to_s)
938
+ col(name.to_s)
607
939
  else
608
940
  super
609
941
  end
@@ -649,6 +981,11 @@ module SparkConnect
649
981
  limit(n).collect
650
982
  end
651
983
 
984
+ # @return [Array<Row>] the last `n` rows.
985
+ def tail(n)
986
+ build(tail: Proto::Tail.new(input: @relation, limit: n)).collect
987
+ end
988
+
652
989
  # @return [Array<Row>, Row] `n` rows (array) or the single first row when called with no arg.
653
990
  def head(n = nil)
654
991
  return first if n.nil?
@@ -678,6 +1015,7 @@ module SparkConnect
678
1015
  limit(1).collect.empty?
679
1016
  end
680
1017
  alias is_empty empty?
1018
+ alias isEmpty empty?
681
1019
 
682
1020
  # Render the first `n` rows as a formatted table.
683
1021
  #
@@ -735,16 +1073,21 @@ module SparkConnect
735
1073
  def input_files
736
1074
  analyze(input_files: Proto::AnalyzePlanRequest::InputFiles.new(plan: plan)).input_files.files.to_a
737
1075
  end
1076
+ alias inputFiles input_files
738
1077
 
739
1078
  # @return [Boolean] whether the data is small enough to be local.
740
1079
  def local?
741
1080
  analyze(is_local: Proto::AnalyzePlanRequest::IsLocal.new(plan: plan)).is_local.is_local
742
1081
  end
1082
+ alias is_local local?
1083
+ alias isLocal local?
743
1084
 
744
1085
  # @return [Boolean] whether this is a streaming DataFrame.
745
1086
  def streaming?
746
1087
  analyze(is_streaming: Proto::AnalyzePlanRequest::IsStreaming.new(plan: plan)).is_streaming.is_streaming
747
1088
  end
1089
+ alias is_streaming streaming?
1090
+ alias isStreaming streaming?
748
1091
 
749
1092
  # @return [Boolean] whether `other` has the same logical plan.
750
1093
  def same_semantics?(other)
@@ -752,11 +1095,18 @@ module SparkConnect
752
1095
  target_plan: plan, other_plan: other.plan
753
1096
  )).same_semantics.result
754
1097
  end
1098
+ alias same_semantics same_semantics?
1099
+ alias sameSemantics same_semantics?
755
1100
 
756
1101
  # @return [Integer] a hash of the logical plan.
757
1102
  def semantic_hash
758
1103
  analyze(semantic_hash: Proto::AnalyzePlanRequest::SemanticHash.new(plan: plan)).semantic_hash.result
759
1104
  end
1105
+ alias semanticHash semantic_hash
1106
+
1107
+ # @return [SparkSession] the session this DataFrame belongs to.
1108
+ def spark_session = @session
1109
+ alias sparkSession spark_session
760
1110
 
761
1111
  # @api private - the executable plan rooted at this relation.
762
1112
  # @return [Spark::Connect::Plan]
@@ -772,7 +1122,23 @@ module SparkConnect
772
1122
  # @api private - build a derived DataFrame from a relation built by W-owned
773
1123
  # facades (GroupedData, NaFunctions, ...).
774
1124
  def build(**rel)
775
- DataFrame.new(@session, PlanBuilder.relation(@session, **rel))
1125
+ relation = PlanBuilder.relation(@session, **rel)
1126
+ DataFrame.new(@session, PlanBuilder.with_subquery_references(@session, relation))
1127
+ end
1128
+
1129
+ # @api private - the plan id of this DataFrame's relation.
1130
+ # @return [Integer]
1131
+ def plan_id
1132
+ @relation.common.plan_id
1133
+ end
1134
+
1135
+ # @api private - a subquery expression referring to this DataFrame.
1136
+ # @return [Column]
1137
+ def subquery_expression(type, in_subquery_values: [], table_arg_options: nil)
1138
+ @session.register_subquery_relation(@relation)
1139
+ sq = Proto::SubqueryExpression.new(plan_id: plan_id, subquery_type: type, in_subquery_values: in_subquery_values)
1140
+ sq.table_arg_options = table_arg_options if table_arg_options
1141
+ Column.new(Proto::Expression.new(subquery_expression: sq))
776
1142
  end
777
1143
 
778
1144
  private
@@ -789,8 +1155,9 @@ module SparkConnect
789
1155
  nil
790
1156
  end
791
1157
 
792
- def checkpoint_command(local:, eager:)
1158
+ def checkpoint_command(local:, eager:, storage_level: nil)
793
1159
  cmd = Proto::CheckpointCommand.new(relation: @relation, local: local, eager: eager)
1160
+ cmd.storage_level = storage_level.to_proto if storage_level
794
1161
  result = @session.client.execute_command(Proto::Command.new(checkpoint_command: cmd))
795
1162
  cached = result.checkpoint_relation
796
1163
  raise SparkConnectError, "Server did not return a checkpointed relation" unless cached
@@ -806,6 +1173,61 @@ module SparkConnect
806
1173
  cols.flatten.map { |c| c.is_a?(Column) ? c : Functions.col(c.to_s) }
807
1174
  end
808
1175
 
1176
+ # Like {#normalize_columns} but an Integer is a 1-based column ordinal
1177
+ # (`group_by(1)`), like PySpark.
1178
+ def grouping_columns(cols)
1179
+ cols.flatten.map do |c|
1180
+ case c
1181
+ when Column then c
1182
+ when Integer
1183
+ raise IllegalArgumentError, "column ordinal must be positive, got #{c}" if c < 1
1184
+
1185
+ self[c - 1]
1186
+ else Functions.col(c.to_s)
1187
+ end
1188
+ end
1189
+ end
1190
+
1191
+ def check_same_session(other)
1192
+ raise IllegalArgumentError, "expected a DataFrame, got #{other.class}" unless other.is_a?(DataFrame)
1193
+ return if other.session.session_id == @session.session_id
1194
+
1195
+ raise IllegalArgumentError, "the DataFrames belong to different SparkSessions"
1196
+ end
1197
+
1198
+ def normalize_how(how)
1199
+ how.to_s.downcase.delete("_").to_sym
1200
+ end
1201
+
1202
+ def join_type(how)
1203
+ return :JOIN_TYPE_INNER if how.nil?
1204
+
1205
+ JOIN_TYPES[normalize_how(how)] or raise IllegalArgumentError, "Unsupported join type: #{how}"
1206
+ end
1207
+
1208
+ def random_seed = Random.rand(2**63)
1209
+
1210
+ def validate_nearest_by_join(num_results, join_type, mode, direction)
1211
+ unless num_results.is_a?(Integer) && num_results.between?(1, NEAREST_BY_JOIN_MAX_NUM_RESULTS)
1212
+ raise AnalysisError.new(
1213
+ "numResults must be between 1 and #{NEAREST_BY_JOIN_MAX_NUM_RESULTS}, got #{num_results}",
1214
+ error_class: "NEAREST_BY_JOIN.NUM_RESULTS_OUT_OF_RANGE"
1215
+ )
1216
+ end
1217
+ unless NEAREST_BY_JOIN_JOIN_TYPES.include?(join_type.downcase.delete("_"))
1218
+ raise AnalysisError.new("Unsupported join type #{join_type}; supported: 'INNER', 'LEFT OUTER'",
1219
+ error_class: "NEAREST_BY_JOIN.UNSUPPORTED_JOIN_TYPE")
1220
+ end
1221
+ unless NEAREST_BY_JOIN_MODES.include?(mode.downcase)
1222
+ raise AnalysisError.new("Unsupported mode #{mode}; supported: 'approx', 'exact'",
1223
+ error_class: "NEAREST_BY_JOIN.UNSUPPORTED_MODE")
1224
+ end
1225
+ return if NEAREST_BY_JOIN_DIRECTIONS.include?(direction.downcase)
1226
+
1227
+ raise AnalysisError.new("Unsupported direction #{direction}; supported: 'distance', 'similarity'",
1228
+ error_class: "NEAREST_BY_JOIN.UNSUPPORTED_DIRECTION")
1229
+ end
1230
+
809
1231
  def to_sort_order(col)
810
1232
  expr = col.to_expr
811
1233
  if expr.expr_type == :sort_order