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
@@ -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.
@@ -241,7 +252,7 @@ module SparkConnect
241
252
  # @param cols [Array<Column, String>]
242
253
  # @return [GroupedData]
243
254
  def group_by(*cols)
244
- GroupedData.new(self, normalize_columns(cols), :GROUP_TYPE_GROUPBY)
255
+ GroupedData.new(self, grouping_columns(cols), :GROUP_TYPE_GROUPBY)
245
256
  end
246
257
  alias groupBy group_by
247
258
  alias groupby group_by
@@ -249,15 +260,28 @@ module SparkConnect
249
260
  # Multi-dimensional rollup.
250
261
  # @return [GroupedData]
251
262
  def rollup(*cols)
252
- GroupedData.new(self, normalize_columns(cols), :GROUP_TYPE_ROLLUP)
263
+ GroupedData.new(self, grouping_columns(cols), :GROUP_TYPE_ROLLUP)
253
264
  end
254
265
 
255
266
  # Multi-dimensional cube.
256
267
  # @return [GroupedData]
257
268
  def cube(*cols)
258
- GroupedData.new(self, normalize_columns(cols), :GROUP_TYPE_CUBE)
269
+ GroupedData.new(self, grouping_columns(cols), :GROUP_TYPE_CUBE)
259
270
  end
260
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
+
261
285
  # Aggregate over the whole DataFrame (a group-by with no grouping columns).
262
286
  #
263
287
  # @param exprs [Array<Column>, Hash]
@@ -271,23 +295,82 @@ module SparkConnect
271
295
  # Join with another DataFrame.
272
296
  #
273
297
  # @param other [DataFrame]
274
- # @param on [String, Array<String>, Column, nil] join key column name(s) or a
275
- # boolean join condition.
276
- # @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"`).
277
303
  # @return [DataFrame]
278
304
  def join(other, on: nil, how: :inner)
279
- jt = JOIN_TYPES[how.to_s.downcase.to_sym] or
280
- raise IllegalArgumentError, "Unsupported join type: #{how}"
281
- 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))
282
307
  case on
283
308
  when nil then nil
284
309
  when Column then j.join_condition = on.to_expr
285
- 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
286
316
  else j.using_columns << on.to_s
287
317
  end
288
318
  build(join: j)
289
319
  end
290
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
+
291
374
  # Cartesian product with another DataFrame.
292
375
  # @return [DataFrame]
293
376
  def cross_join(other)
@@ -359,6 +442,25 @@ module SparkConnect
359
442
  end
360
443
  end
361
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
+
362
464
  # Reduce to `num_partitions` without a full shuffle.
363
465
  # @return [DataFrame]
364
466
  def coalesce(num_partitions)
@@ -398,13 +500,34 @@ module SparkConnect
398
500
  # @param seed [Integer, nil]
399
501
  # @return [DataFrame]
400
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.
401
504
  s = Proto::Sample.new(
402
- 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)
403
507
  )
404
- s.seed = seed if seed
405
508
  build(sample: s)
406
509
  end
407
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
+
408
531
  # ---- Misc transforms ---------------------------------------------------
409
532
 
410
533
  # Alias this DataFrame (a subquery alias usable in join conditions).
@@ -420,8 +543,9 @@ module SparkConnect
420
543
  # @param params [Array]
421
544
  # @return [DataFrame]
422
545
  def hint(name, *params)
546
+ params = params.first if params.size == 1 && params.first.is_a?(Array)
423
547
  h = Proto::Hint.new(input: @relation, name: name.to_s,
424
- parameters: params.map { |p| Column.to_col(p).to_expr })
548
+ parameters: params.map { |p| Column.lit(p).to_expr })
425
549
  build(hint: h)
426
550
  end
427
551
 
@@ -456,6 +580,45 @@ module SparkConnect
456
580
  DataFrameStatFunctions.new(self)
457
581
  end
458
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
+
459
622
  # @return [DataFrameWriter] interface for saving this DataFrame.
460
623
  def write
461
624
  DataFrameWriter.new(self)
@@ -474,6 +637,21 @@ module SparkConnect
474
637
  end
475
638
  alias writeStream write_stream
476
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
+
477
655
  # ---- Temporary views ---------------------------------------------------
478
656
 
479
657
  # Register this DataFrame as a session-scoped temporary view, failing if a
@@ -505,15 +683,83 @@ module SparkConnect
505
683
  end
506
684
  alias createOrReplaceGlobalTempView create_or_replace_global_temp_view
507
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
+
508
693
  # Select columns by a regular expression matched against their names.
509
694
  #
510
695
  # @param regex [String]
511
696
  # @return [Column]
512
697
  def col_regex(regex)
513
- 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
+ ))
514
703
  end
515
704
  alias colRegex col_regex
516
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
+
517
763
  # @return [DataFrame] a single-column (`value`) DataFrame of each row encoded
518
764
  # as a JSON string.
519
765
  def to_json(*_args)
@@ -556,9 +802,10 @@ module SparkConnect
556
802
  # storage), which is faster but not fault-tolerant.
557
803
  #
558
804
  # @param eager [Boolean]
805
+ # @param storage_level [StorageLevel, nil] where to keep the checkpointed data.
559
806
  # @return [DataFrame]
560
- def local_checkpoint(eager: true)
561
- 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)
562
809
  end
563
810
  alias localCheckpoint local_checkpoint
564
811
 
@@ -568,6 +815,9 @@ module SparkConnect
568
815
  # @param exprs [Array<Column>]
569
816
  # @return [DataFrame]
570
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
+
571
821
  obs_name = name.is_a?(Observation) ? name.name : name.to_s
572
822
  cm = Proto::CollectMetrics.new(
573
823
  input: @relation, name: obs_name, metrics: exprs.map { |e| Column.to_col(e).to_expr }
@@ -577,6 +827,67 @@ module SparkConnect
577
827
  df
578
828
  end
579
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
+
580
891
  # ---- Schema introspection ---------------------------------------------
581
892
 
582
893
  # @return [Types::StructType] the DataFrame's schema.
@@ -606,21 +917,25 @@ module SparkConnect
606
917
  end
607
918
  alias printSchema print_schema
608
919
 
609
- # 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"]]`).
610
923
  #
611
- # @param key [String, Symbol, Integer]
612
- # @return [Column]
924
+ # @param key [String, Symbol, Integer, Column, Array]
925
+ # @return [Column, DataFrame]
613
926
  def [](key)
614
927
  case key
615
928
  when Integer then Functions.col(columns[key])
616
- else Functions.col(key.to_s)
929
+ when Column then filter(key)
930
+ when Array then select(*key)
931
+ else col(key.to_s)
617
932
  end
618
933
  end
619
934
 
620
935
  # Allows `df.column_name` for valid identifier column names.
621
936
  def method_missing(name, *args)
622
937
  if args.empty? && columns.include?(name.to_s)
623
- Functions.col(name.to_s)
938
+ col(name.to_s)
624
939
  else
625
940
  super
626
941
  end
@@ -666,6 +981,11 @@ module SparkConnect
666
981
  limit(n).collect
667
982
  end
668
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
+
669
989
  # @return [Array<Row>, Row] `n` rows (array) or the single first row when called with no arg.
670
990
  def head(n = nil)
671
991
  return first if n.nil?
@@ -695,6 +1015,7 @@ module SparkConnect
695
1015
  limit(1).collect.empty?
696
1016
  end
697
1017
  alias is_empty empty?
1018
+ alias isEmpty empty?
698
1019
 
699
1020
  # Render the first `n` rows as a formatted table.
700
1021
  #
@@ -752,16 +1073,21 @@ module SparkConnect
752
1073
  def input_files
753
1074
  analyze(input_files: Proto::AnalyzePlanRequest::InputFiles.new(plan: plan)).input_files.files.to_a
754
1075
  end
1076
+ alias inputFiles input_files
755
1077
 
756
1078
  # @return [Boolean] whether the data is small enough to be local.
757
1079
  def local?
758
1080
  analyze(is_local: Proto::AnalyzePlanRequest::IsLocal.new(plan: plan)).is_local.is_local
759
1081
  end
1082
+ alias is_local local?
1083
+ alias isLocal local?
760
1084
 
761
1085
  # @return [Boolean] whether this is a streaming DataFrame.
762
1086
  def streaming?
763
1087
  analyze(is_streaming: Proto::AnalyzePlanRequest::IsStreaming.new(plan: plan)).is_streaming.is_streaming
764
1088
  end
1089
+ alias is_streaming streaming?
1090
+ alias isStreaming streaming?
765
1091
 
766
1092
  # @return [Boolean] whether `other` has the same logical plan.
767
1093
  def same_semantics?(other)
@@ -769,11 +1095,18 @@ module SparkConnect
769
1095
  target_plan: plan, other_plan: other.plan
770
1096
  )).same_semantics.result
771
1097
  end
1098
+ alias same_semantics same_semantics?
1099
+ alias sameSemantics same_semantics?
772
1100
 
773
1101
  # @return [Integer] a hash of the logical plan.
774
1102
  def semantic_hash
775
1103
  analyze(semantic_hash: Proto::AnalyzePlanRequest::SemanticHash.new(plan: plan)).semantic_hash.result
776
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
777
1110
 
778
1111
  # @api private - the executable plan rooted at this relation.
779
1112
  # @return [Spark::Connect::Plan]
@@ -789,7 +1122,23 @@ module SparkConnect
789
1122
  # @api private - build a derived DataFrame from a relation built by W-owned
790
1123
  # facades (GroupedData, NaFunctions, ...).
791
1124
  def build(**rel)
792
- 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))
793
1142
  end
794
1143
 
795
1144
  private
@@ -806,8 +1155,9 @@ module SparkConnect
806
1155
  nil
807
1156
  end
808
1157
 
809
- def checkpoint_command(local:, eager:)
1158
+ def checkpoint_command(local:, eager:, storage_level: nil)
810
1159
  cmd = Proto::CheckpointCommand.new(relation: @relation, local: local, eager: eager)
1160
+ cmd.storage_level = storage_level.to_proto if storage_level
811
1161
  result = @session.client.execute_command(Proto::Command.new(checkpoint_command: cmd))
812
1162
  cached = result.checkpoint_relation
813
1163
  raise SparkConnectError, "Server did not return a checkpointed relation" unless cached
@@ -823,6 +1173,61 @@ module SparkConnect
823
1173
  cols.flatten.map { |c| c.is_a?(Column) ? c : Functions.col(c.to_s) }
824
1174
  end
825
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
+
826
1231
  def to_sort_order(col)
827
1232
  expr = col.to_expr
828
1233
  if expr.expr_type == :sort_order