activerecord-refined 0.6.1 → 0.8.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.
@@ -217,15 +217,23 @@ module ActiveRecord
217
217
 
218
218
  # Reading inside a JSON document, by the name of what Hash does. A
219
219
  # string or symbol steps into an object, an integer into an array, and
220
- # what comes back is the value rather than the JSON, since that is what
221
- # a comparison wants. dig_json keeps it JSON, for a document to be dug
222
- # into further or compared whole.
220
+ # what comes back is still JSON, the way Hash#dig hands back the
221
+ # structure itself -- for a document to be dug into further or asked
222
+ # the JSON questions. dig_text gives the value as text instead,
223
+ # which is what a comparison wants.
223
224
  def dig(*path)
225
+ JsonPath.new(self, path, as_json: true)
226
+ end
227
+
228
+ def dig_text(*path)
224
229
  JsonPath.new(self, path)
225
230
  end
226
231
 
227
- def dig_json(*path)
228
- JsonPath.new(self, path, as_json: true)
232
+ # Keys taken out of a JSON document, by the name of what Hash does,
233
+ # and taking keys as Hash#except takes them. Like bury it gives back
234
+ # the document changed rather than writing it anywhere.
235
+ def except(*keys)
236
+ JsonExcept.new(self, keys)
229
237
  end
230
238
 
231
239
  # What dig reads, bury sets: the last argument is the value and the
@@ -359,7 +367,7 @@ module ActiveRecord
359
367
  # A literal standing where an expression would: `select { value(0).as(:depth) }`.
360
368
  #
361
369
  # Values reach the SQL quoted wherever they appear as an operand, but the
362
- # top of a select list is ActiveRecord's, and a bare string there is SQL
370
+ # top of a select list is Active Record's, and a bare string there is SQL
363
371
  # rather than a string. Saying `value` is how you ask for the other
364
372
  # meaning, and it carries the predications with it, so a literal can be
365
373
  # compared and combined like anything else.
@@ -441,7 +449,7 @@ module ActiveRecord
441
449
  end
442
450
 
443
451
  # What a `when` is until its `then` arrives. A Node so that using it
444
- # as one says what is missing rather than reaching ActiveRecord as
452
+ # as one says what is missing rather than reaching Active Record as
445
453
  # something it cannot read.
446
454
  class Pending < Node
447
455
  def initialize(kase, condition)
@@ -474,20 +482,23 @@ module ActiveRecord
474
482
  end
475
483
 
476
484
  # PostgreSQL takes the steps as a text array, where every element is
477
- # quoted so that a comma or a brace in a key is part of it.
478
- def steps_array
479
- "{#{path.map {|step| %("#{escape_step(step)}") }.join(',')}}"
485
+ # quoted so that a comma or a brace in a key is part of it. except
486
+ # writes its keys the same way, which are steps of no one path.
487
+ def steps_array(steps = path)
488
+ "{#{steps.map {|step| %("#{escape_step(step)}") }.join(',')}}"
480
489
  end
481
490
 
482
491
  # MySQL and SQLite take a path expression instead, where an integer is
483
492
  # a subscript and a name that is not plain has to be quoted.
484
493
  def dollar_path
485
- path.inject(+'$') do |so_far, step|
486
- next so_far << "[#{step}]" if step.is_a?(::Integer)
487
- name = step.to_s
488
- so_far << '.' << (name.match?(/\A[[:alpha:]_][[:alnum:]_]*\z/) ?
489
- name : %("#{escape_step(step)}"))
490
- end
494
+ path.inject(+'$') {|so_far, step| so_far << dollar_step(step) }
495
+ end
496
+
497
+ def dollar_step(step)
498
+ return "[#{step}]" if step.is_a?(::Integer)
499
+ name = step.to_s
500
+ '.' + (name.match?(/\A[[:alpha:]_][[:alnum:]_]*\z/) ?
501
+ name : %("#{escape_step(step)}"))
491
502
  end
492
503
 
493
504
  def escape_step(step)
@@ -503,10 +514,85 @@ module ActiveRecord
503
514
  #
504
515
  # The path is turned into a string either way, so a key with a space or
505
516
  # a quote in it travels as itself rather than having to be refused.
517
+ # What a dug value may be compared with. dig_text gives text on every
518
+ # adapter, and what a text value compared with a number means is a
519
+ # question the three answer three ways: `dig_text(:n) == 5` is true on
520
+ # SQLite, an error on PostgreSQL and true on MySQL, while
521
+ # `dig_text(:flag) == true` is true, an error, and false. cast is what
522
+ # says which type was meant, and then all three agree.
523
+ #
524
+ # dig is refused the other way about: the JSON for a string carries
525
+ # its quotes, so `dig(:name) == 'alice'` is false on SQLite, an
526
+ # error on PostgreSQL and true on MySQL. dig_text is the one that
527
+ # gives the value.
528
+ #
529
+ # A string against dig_text, and anything the block itself built -- a
530
+ # column, a function, another dug value -- go through untouched.
531
+ module JsonComparable
532
+ %i[== != < <= > >=].each do |operator|
533
+ define_method(operator) do |other|
534
+ check_comparable(other)
535
+ super(other)
536
+ end
537
+ end
538
+
539
+ def in?(values) = super(check_each(values))
540
+ def not_in?(values) = super(check_each(values))
541
+ def between?(min, max) = super(*check_each([min, max]))
542
+ def not_between?(min, max) = super(*check_each([min, max]))
543
+
544
+ private
545
+
546
+ # nil is left to the comparison itself, which says to use null?, and
547
+ # so is anything the block built rather than wrote as a literal.
548
+ def check_comparable(other)
549
+ return if other.nil? || other.is_a?(Node) || other.is_a?(::Symbol) ||
550
+ other.is_a?(Arel::Nodes::Node) ||
551
+ other.is_a?(Arel::Attributes::Attribute) ||
552
+ other.is_a?(ActiveRecord::Relation)
553
+ return if other.is_a?(::String) && !as_json
554
+
555
+ raise ArgumentError, as_json ?
556
+ "dig gives JSON, and comparing it with #{other.inspect} " \
557
+ "means something different on every adapter; dig_text gives the value" :
558
+ "dig_text gives text, and comparing it with #{other.inspect} means " \
559
+ "something different on every adapter; cast it to the type meant"
560
+ end
561
+
562
+ def check_each(values)
563
+ case values
564
+ when ActiveRecord::Relation then values
565
+ when ::Range then [values.begin, values.end].each {|v| check_comparable(v) }
566
+ else values.each {|value| check_comparable(value) }
567
+ end
568
+ values
569
+ end
570
+ end
571
+
572
+ # The JSON operations read a document, and what dig gives is one:
573
+ # `dig(:author).key?(:email)` and `dig(:tags).contains?(...)` are the
574
+ # same question asked of a part of it, and the adapters answer them
575
+ # alike. What dig_text gives is text, and reading that as a document
576
+ # again is where they part company: SQLite parses it back and MySQL
577
+ # takes it as written, where PostgreSQL has no such function for text.
578
+ module JsonDocument
579
+ %i[dig dig_text key? contains? bury except].each do |name|
580
+ define_method(name) do |*args|
581
+ unless as_json
582
+ raise ArgumentError,
583
+ "dig_text gives text, and #{name} reads JSON; dig keeps it"
584
+ end
585
+ super(*args)
586
+ end
587
+ end
588
+ end
589
+
506
590
  class JsonPath < Node
507
591
  include Predications
508
592
  include Arithmetics
509
593
  include JsonSteps
594
+ include JsonComparable
595
+ include JsonDocument
510
596
 
511
597
  attr_reader :operand, :path, :as_json
512
598
 
@@ -530,8 +616,8 @@ module ActiveRecord
530
616
  extracted = Arel::Nodes::InfixOperation.new(
531
617
  as_json ? :"->" : :"->>", document, Arel::Nodes.build_quoted(dollar_path))
532
618
  # SQLite's ->> gives back the value with its type, where the other
533
- # two give text. Cast so that `dig(:n) == '5'` means the same
534
- # thing everywhere, and a number wants a cast everywhere too.
619
+ # two give text. Cast so that `dig_text(:n) == '5'` means the
620
+ # same thing everywhere, and a number wants a cast everywhere too.
535
621
  as_json ? extracted : Arel::Nodes::NamedFunction.new(
536
622
  'CAST', [Arel::Nodes::As.new(extracted, Arel::Nodes::SqlLiteral.new('text'))])
537
623
  end
@@ -595,6 +681,55 @@ module ActiveRecord
595
681
  end
596
682
  end
597
683
 
684
+ # Keys taken out of a JSON document. PostgreSQL subtracts them, the
685
+ # other two remove a path apiece.
686
+ class JsonExcept < Node
687
+ include Predications
688
+ include JsonSteps
689
+
690
+ attr_reader :operand, :keys
691
+
692
+ def initialize(operand, keys)
693
+ @operand = operand
694
+ @keys = check_keys(keys)
695
+ end
696
+
697
+ def to_arel(table, model)
698
+ document = to_arel_operand(operand, table, model)
699
+ return Arel::Nodes::InfixOperation.new(:-, document, key_array) if
700
+ AST.adapter_family(model) == :postgresql
701
+
702
+ Arel::Nodes::NamedFunction.new(
703
+ 'JSON_REMOVE',
704
+ [document, *keys.map {|key| Arel::Nodes.build_quoted("$#{dollar_step(key)}") }])
705
+ end
706
+
707
+ private
708
+
709
+ # jsonb has three subtractions -- a key, an array of keys, an element
710
+ # by index -- and an array literal written without a type is read as
711
+ # the first of them: `meta - '{draft}'` takes out the key spelled
712
+ # {draft}, which is nothing, and says nothing about it.
713
+ def key_array
714
+ Arel::Nodes::NamedFunction.new(
715
+ 'CAST',
716
+ [Arel::Nodes::As.new(Arel::Nodes.build_quoted(steps_array(keys)),
717
+ Arel::Nodes::SqlLiteral.new('text[]'))])
718
+ end
719
+
720
+ # Keys, as Hash#except takes them: an index into an array is not what
721
+ # the name says anywhere, and is bury's business through a path.
722
+ def check_keys(keys)
723
+ raise ArgumentError, 'except needs a key' if keys.empty?
724
+ keys.each do |key|
725
+ next if key.is_a?(::String) || key.is_a?(::Symbol)
726
+ raise ArgumentError,
727
+ "except takes keys of the document, not #{key.inspect}"
728
+ end
729
+ keys
730
+ end
731
+ end
732
+
598
733
  # JSON containment: whether the document holds what is given.
599
734
  class JsonContains < Predicate
600
735
  attr_reader :operand, :value
@@ -1128,7 +1263,7 @@ module ActiveRecord
1128
1263
 
1129
1264
  # A plain SQL comparison. The value is passed through as it is, so a Range
1130
1265
  # or an Array compares against a PostgreSQL range or array column, the way
1131
- # ActiveRecord's own force_equality? types do.
1266
+ # Active Record's own force_equality? types do.
1132
1267
  class Comparison < Predicate
1133
1268
  OPERATOR_MAP = {
1134
1269
  :== => :eq, :!= => :not_eq,
@@ -1187,7 +1322,7 @@ module ActiveRecord
1187
1322
  end
1188
1323
 
1189
1324
  # A relation standing for a set of values, which is what IN and the
1190
- # quantifiers each take. The treatment is ActiveRecord's own
1325
+ # quantifiers each take. The treatment is Active Record's own
1191
1326
  # RelationHandler's: without an explicit select list the subquery
1192
1327
  # selects the model's primary key.
1193
1328
  module SetSubquery
@@ -251,7 +251,7 @@ module ActiveRecord
251
251
  #
252
252
  # select { [:id, value(0).as(:depth)] }
253
253
  #
254
- # Needed because the top of a select list is ActiveRecord's, and a bare
254
+ # Needed because the top of a select list is Active Record's, and a bare
255
255
  # string there is SQL rather than a string. Numbers have a shorthand --
256
256
  # `0.as(:depth)` -- since nothing else could be meant by one.
257
257
  def value(literal)
@@ -367,7 +367,7 @@ module ActiveRecord
367
367
  end
368
368
  end
369
369
 
370
- # A symbol names a table, which ActiveRecord's own from only takes as a
370
+ # A symbol names a table, which Active Record's own from only takes as a
371
371
  # string. With `as` it is selected under another name; when that name
372
372
  # is the model's own, from_cte says the same thing without repeating it.
373
373
  def from(value, subquery_name = nil, as: nil)
@@ -383,7 +383,7 @@ module ActiveRecord
383
383
  end
384
384
 
385
385
  # Selects a CTE in place of the model's own table. The alias is not a
386
- # choice -- ActiveRecord keeps qualifying columns with the table name,
386
+ # choice -- Active Record keeps qualifying columns with the table name,
387
387
  # so the model's is the only name that works -- which is why it is
388
388
  # taken from the model rather than asked for:
389
389
  # with_recursive(tree: [...]).from_cte(:tree)
@@ -432,7 +432,7 @@ module ActiveRecord
432
432
  self
433
433
  end
434
434
 
435
- # ActiveRecord generates these for the values it knows about; this one
435
+ # Active Record generates these for the values it knows about; this one
436
436
  # is ours, and lives in the same place so that it survives a spawn.
437
437
  def distinct_on_values
438
438
  @values.fetch(:distinct_on, ActiveRecord::QueryMethods::FROZEN_EMPTY_ARRAY)
@@ -443,13 +443,32 @@ module ActiveRecord
443
443
  @values[:distinct_on] = columns
444
444
  end
445
445
 
446
+ # Marks the relation for a lateral join, which lets it see the row being
447
+ # joined to -- the top few rows of each group, and the like. In SQL the
448
+ # keyword modifies the subquery, not the join, so it is said on the
449
+ # relation: left_outer_joins(top_post.lateral, as: :top).
450
+ def lateral
451
+ spawn.lateral!
452
+ end
453
+
454
+ def lateral!
455
+ self.lateral_value = true
456
+ self
457
+ end
458
+
459
+ def lateral_value
460
+ @values[:lateral]
461
+ end
462
+
463
+ def lateral_value=(value)
464
+ assert_modifiable!
465
+ @values[:lateral] = value
466
+ end
467
+
446
468
  # `as` names the table within the query, which is what makes a self
447
469
  # join expressible: joins(:employees, as: :managers) { ... }.
448
- #
449
- # `lateral` joins a relation instead of a table, and lets it see the row
450
- # being joined to -- the top few rows of each group, and the like.
451
- def joins(*args, as: nil, lateral: false, &block)
452
- if lateral
470
+ def joins(*args, as: nil, &block)
471
+ if args.first.is_a?(ActiveRecord::Relation)
453
472
  super(build_lateral_join(args.first, Arel::Nodes::InnerJoin, as, &block))
454
473
  elsif block
455
474
  super(build_join_node(args.first, Arel::Nodes::InnerJoin, as, &block))
@@ -459,8 +478,8 @@ module ActiveRecord
459
478
  end
460
479
  end
461
480
 
462
- def left_outer_joins(*args, as: nil, lateral: false, &block)
463
- if lateral
481
+ def left_outer_joins(*args, as: nil, &block)
482
+ if args.first.is_a?(ActiveRecord::Relation)
464
483
  joins(build_lateral_join(args.first, Arel::Nodes::OuterJoin, as, &block))
465
484
  elsif block
466
485
  joins(build_join_node(args.first, Arel::Nodes::OuterJoin, as, &block))
@@ -470,6 +489,36 @@ module ActiveRecord
470
489
  end
471
490
  end
472
491
 
492
+ # The other two outer joins, which Active Record has no method for and
493
+ # Arel has the nodes for. The rules are joins': the block is the ON,
494
+ # `as` names the table within the query, a relation marked `lateral`
495
+ # joins as one. An association name is not among them -- what Active
496
+ # Record reads out of one is an inner or a left join and nothing else.
497
+ def right_outer_joins(*args, as: nil, &block)
498
+ outer_joins(:right_outer_joins, Arel::Nodes::RightOuterJoin,
499
+ args, as, &block)
500
+ end
501
+
502
+ def full_outer_joins(*args, as: nil, &block)
503
+ check_full_outer_support
504
+ outer_joins(:full_outer_joins, Arel::Nodes::FullOuterJoin,
505
+ args, as, &block)
506
+ end
507
+
508
+ # CROSS JOIN: every row of one table against every row of the other, so
509
+ # unlike the joins above there is no condition to give and no block to
510
+ # write it in.
511
+ #
512
+ # Post.cross_joins(:authors)
513
+ # Post.cross_joins(:posts, as: :others)
514
+ def cross_joins(*args, as: nil, &block)
515
+ if block
516
+ raise ArgumentError,
517
+ 'a cross join has no condition; joins is the one that takes a block'
518
+ end
519
+ joins(build_cross_join(args.first, as))
520
+ end
521
+
473
522
  private
474
523
 
475
524
  def build_arel(...)
@@ -521,8 +570,9 @@ module ActiveRecord
521
570
  # which is the usual shape -- what the subquery is allowed to see is
522
571
  # what makes it lateral, and that is said inside it.
523
572
  def build_lateral_join(relation, join_class, alias_name, &block)
524
- unless relation.is_a?(ActiveRecord::Relation)
525
- raise ArgumentError, "a lateral join takes a relation to join against"
573
+ unless relation.lateral_value
574
+ raise ArgumentError,
575
+ "a relation joins laterally; mark it: joins(sub.lateral, as: :top)"
526
576
  end
527
577
  unless alias_name
528
578
  raise ArgumentError, "a lateral join needs a name: joins(..., as: :top)"
@@ -551,6 +601,37 @@ module ActiveRecord
551
601
  raise NotImplementedError, "a lateral join has no equivalent on #{database}"
552
602
  end
553
603
 
604
+ # MySQL has no FULL OUTER JOIN, and neither has MariaDB; SQLite has had
605
+ # one since 3.39 and PostgreSQL always.
606
+ def check_full_outer_support
607
+ return unless AST.adapter_family(klass) == :mysql
608
+ raise NotImplementedError, 'a full outer join has no equivalent on MySQL'
609
+ end
610
+
611
+ def outer_joins(called, join_class, args, alias_name, &block)
612
+ if args.first.is_a?(ActiveRecord::Relation)
613
+ return joins(build_lateral_join(args.first, join_class, alias_name, &block))
614
+ end
615
+ return joins(build_join_node(args.first, join_class, alias_name, &block)) if block
616
+
617
+ raise ArgumentError,
618
+ "#{called} takes a table and the block that joins it; an association " \
619
+ 'is what joins and left_outer_joins read'
620
+ end
621
+
622
+ # Arel has a node for every other join and none for this one, and INNER
623
+ # JOIN with no ON -- which is a cross join on SQLite and MySQL -- is a
624
+ # syntax error on PostgreSQL. So the SQL is written here, the second
625
+ # place in the gem that writes any: the keyword is fixed and the names
626
+ # are quoted by the adapter, so nothing of the caller's is in it.
627
+ def build_cross_join(target_table, alias_name)
628
+ joined = klass.with_connection do |connection|
629
+ name = connection.quote_table_name(target_table.to_s)
630
+ alias_name ? "#{name} #{connection.quote_table_name(alias_name.to_s)}" : name
631
+ end
632
+ Arel::Nodes::StringJoin.new(Arel.sql("CROSS JOIN #{joined}"))
633
+ end
634
+
554
635
  def build_join_node(target_table, join_class, alias_name, &block)
555
636
  ast = evaluate_block(&block)
556
637
  arel_table = Arel::Table.new(target_table)
@@ -1,5 +1,5 @@
1
1
  module Activerecord
2
2
  module Refined
3
- VERSION = '0.6.1'
3
+ VERSION = '0.8.0'
4
4
  end
5
5
  end
@@ -9,6 +9,8 @@ ActiveRecord::QueryMethods.prepend ActiveRecord::Refined::QueryMethods
9
9
  # update_all and its kind are Relation's own rather than QueryMethods'.
10
10
  ActiveRecord::Relation.prepend ActiveRecord::Refined::Writes
11
11
 
12
- # The methods above are ActiveRecord's own, so a model already forwards them
13
- # to its relation. These two are new, and have to be added to that list.
14
- ActiveRecord::Base.singleton_class.delegate :from_cte, :distinct_on, to: :all
12
+ # The methods above are Active Record's own, so a model already forwards them
13
+ # to its relation. These are new, and have to be added to that list.
14
+ ActiveRecord::Base.singleton_class.delegate(
15
+ :from_cte, :distinct_on, :lateral,
16
+ :right_outer_joins, :full_outer_joins, :cross_joins, to: :all)