order_query 0.3.3 → 0.5.1

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.
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OrderQuery
4
+ # Handles nulls :first and :last direction.
5
+ module NullsDirection
6
+ module_function
7
+
8
+ DIRECTIONS = %i[first last].freeze
9
+
10
+ def all
11
+ DIRECTIONS
12
+ end
13
+
14
+ # @param [:first, :last] direction
15
+ # @return [:first, :last]
16
+ def reverse(direction)
17
+ all[(all.index(direction) + 1) % 2].to_sym
18
+ end
19
+
20
+ # @param [:first, :last] direction
21
+ # @raise [ArgumentError]
22
+ # @return [:first, :last]
23
+ def parse!(direction)
24
+ all.include?(direction) && direction or
25
+ fail ArgumentError,
26
+ "`nulls` must be in #{all.map(&:inspect).join(', ')}, "\
27
+ "is #{direction.inspect}"
28
+ end
29
+
30
+ # @param scope [ActiveRecord::Relation]
31
+ # @param dir [:asc, :desc]
32
+ # @return [:first, :last] the default nulls order, based on the given
33
+ # scope's connection adapter name.
34
+ def default(scope, dir)
35
+ case connection_adapter(scope)
36
+ when /mysql|maria|sqlite|sqlserver/i
37
+ (dir == :asc ? :first : :last)
38
+ else
39
+ # Oracle, Postgres
40
+ (dir == :asc ? :last : :first)
41
+ end
42
+ end
43
+
44
+ def connection_adapter(scope)
45
+ if scope.respond_to?(:connection_db_config)
46
+ # Rails >= 6.1.0
47
+ scope.connection_db_config.adapter
48
+ else
49
+ scope.connection_config[:adapter]
50
+ end
51
+ end
52
+ end
53
+ end
@@ -1,5 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'order_query/space'
2
4
  require 'order_query/sql/where'
5
+ require 'order_query/errors'
3
6
 
4
7
  module OrderQuery
5
8
  # Search around a record in an order space
@@ -15,7 +18,8 @@ module OrderQuery
15
18
  @where_sql = SQL::Where.new(self)
16
19
  end
17
20
 
18
- # @params [true, false] loop if true, consider last and first as adjacent (unless they are equal)
21
+ # @param [true, false] loop if true, loops as if the last and the first
22
+ # records were adjacent, unless there is only one record.
19
23
  # @return [ActiveRecord::Base]
20
24
  def next(loop = true)
21
25
  unless_record_eq after.first || (first if loop)
@@ -31,20 +35,26 @@ module OrderQuery
31
35
  space.count - after.count
32
36
  end
33
37
 
38
+ # @param [true, false] strict if false, the given scope will include the
39
+ # record at this point.
34
40
  # @return [ActiveRecord::Relation]
35
- def after
36
- side :after
41
+ def after(strict = true)
42
+ side :after, strict
37
43
  end
38
44
 
45
+ # @param [true, false] strict if false, the given scope will include the
46
+ # record at this point.
39
47
  # @return [ActiveRecord::Relation]
40
- def before
41
- side :before
48
+ def before(strict = true)
49
+ side :before, strict
42
50
  end
43
51
 
44
52
  # @param [:before, :after] side
53
+ # @param [true, false] strict if false, the given scope will include the
54
+ # record at this point.
45
55
  # @return [ActiveRecord::Relation]
46
- def side(side)
47
- query, query_args = @where_sql.build(side)
56
+ def side(side, strict = true)
57
+ query, query_args = @where_sql.build(side, strict)
48
58
  scope = if side == :after
49
59
  space.scope
50
60
  else
@@ -53,8 +63,15 @@ module OrderQuery
53
63
  scope.where(query, *query_args)
54
64
  end
55
65
 
56
- def value(cond)
57
- record.send(cond.name)
66
+ # @param column [Column]
67
+ def value(column)
68
+ v = record.send(column.name)
69
+ if v.nil? && !column.nullable?
70
+ fail Errors::NonNullableColumnIsNullError,
71
+ "Column #{column.inspect} is NULL on record #{@record.inspect}. "\
72
+ 'Set the `nulls` option to :first or :last.'
73
+ end
74
+ v
58
75
  end
59
76
 
60
77
  def inspect
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'order_query/column'
2
4
  require 'order_query/sql/order_by'
3
5
  module OrderQuery
@@ -8,14 +10,20 @@ module OrderQuery
8
10
  delegate :count, :empty?, to: :@base_scope
9
11
 
10
12
  # @param [ActiveRecord::Relation] base_scope
11
- # @param [Array<Array<Symbol,String>>, OrderQuery::Spec] order_spec
13
+ # @param [Array<Array<Symbol,String>>] order_spec
14
+ # @see Column#initialize for the order_spec element format.
12
15
  def initialize(base_scope, order_spec)
13
- @base_scope = base_scope
14
- @columns = order_spec.map { |cond_spec| Column.new(cond_spec, base_scope) }
16
+ @base_scope = base_scope
17
+ @columns = order_spec.map do |cond_spec|
18
+ Column.new(base_scope, *cond_spec)
19
+ end
15
20
  # add primary key if columns are not unique
16
21
  unless @columns.last.unique?
17
- raise ArgumentError.new('Unique column must be last') if @columns.detect(&:unique?)
18
- @columns << Column.new([base_scope.primary_key], base_scope)
22
+ if @columns.detect(&:unique?)
23
+ fail ArgumentError, 'Unique column must be last'
24
+ end
25
+
26
+ @columns << Column.new(base_scope, base_scope.primary_key)
19
27
  end
20
28
  @order_by_sql = SQL::OrderBy.new(@columns)
21
29
  end
@@ -27,12 +35,13 @@ module OrderQuery
27
35
 
28
36
  # @return [ActiveRecord::Relation] scope ordered by columns
29
37
  def scope
30
- @scope ||= @base_scope.order(@order_by_sql.build)
38
+ @scope ||= @base_scope.order(Arel.sql(@order_by_sql.build))
31
39
  end
32
40
 
33
41
  # @return [ActiveRecord::Relation] scope ordered by columns in reverse
34
42
  def scope_reverse
35
- @scope_reverse ||= @base_scope.order(@order_by_sql.build_reverse)
43
+ @scope_reverse ||= @base_scope
44
+ .order(Arel.sql(@order_by_sql.build_reverse))
36
45
  end
37
46
 
38
47
  # @return [ActiveRecord::Base]
@@ -46,7 +55,8 @@ module OrderQuery
46
55
  end
47
56
 
48
57
  def inspect
49
- "#<OrderQuery::Space @columns=#{@columns.inspect} @base_scope=#{@base_scope.inspect}>"
58
+ "#<OrderQuery::Space @columns=#{@columns.inspect} "\
59
+ "@base_scope=#{@base_scope.inspect}>"
50
60
  end
51
61
  end
52
62
  end
@@ -1,20 +1,24 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module OrderQuery
2
4
  module SQL
5
+ # A column in the given scope.
3
6
  class Column
4
- attr_reader :column, :scope
7
+ attr_reader :scope, :column
5
8
 
6
- def initialize(column, scope)
7
- @column = column
9
+ def initialize(scope, column)
8
10
  @scope = scope
11
+ @column = column
9
12
  end
10
13
 
11
14
  def column_name
12
15
  @column_name ||= begin
13
- sql = column.options[:sql]
16
+ sql = column.custom_sql
14
17
  if sql
15
18
  sql.respond_to?(:call) ? sql.call : sql
16
19
  else
17
- connection.quote_table_name(scope.table_name) + '.' + connection.quote_column_name(column.name)
20
+ "#{connection.quote_table_name(scope.table_name)}."\
21
+ "#{connection.quote_column_name(column.name)}"
18
22
  end
19
23
  end
20
24
  end
@@ -1,5 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module OrderQuery
2
4
  module SQL
5
+ # Constructs SQL for ORDER BY.
3
6
  class OrderBy
4
7
  # @param [Array<Column>]
5
8
  def initialize(columns)
@@ -31,23 +34,65 @@ module OrderQuery
31
34
  end
32
35
  end
33
36
 
34
- def column_clause_ray(col, reverse = false)
35
- "#{col.column_name} #{sort_direction_sql(col, reverse)}".freeze
37
+ def column_clause_ray(col, reverse = false,
38
+ with_null_clause = needs_null_sort?(col, reverse))
39
+ clauses = []
40
+ # TODO: use NULLS FIRST/LAST where supported.
41
+ clauses << order_by_nulls_sql(col, reverse) if with_null_clause
42
+ clauses << "#{col.column_name} #{sort_direction_sql(col, reverse)}"
43
+ clauses.join(', ').freeze
36
44
  end
37
45
 
46
+ # rubocop:disable Metrics/AbcSize
47
+
38
48
  def column_clause_enum(col, reverse = false)
39
- enum = col.order_enum
40
- # Collapse boolean enum to `ORDER BY column ASC|DESC`
41
- if enum == [false, true] || enum == [true, false]
42
- return column_clause_ray col, reverse ^ enum.last
49
+ # Collapse booleans enum to `ORDER BY column ASC|DESC`
50
+ return optimize_enum_bools(col, reverse) if optimize_enum_bools?(col)
51
+ if optimize_enum_bools_nil?(col)
52
+ return optimize_enum_bools_nil(col, reverse)
53
+ end
54
+
55
+ clauses = []
56
+ with_nulls = false
57
+ if col.order_enum.include?(nil)
58
+ with_nulls = true
59
+ elsif needs_null_sort?(col, reverse)
60
+ clauses << order_by_nulls_sql(col, reverse)
61
+ end
62
+ clauses.concat(col.order_enum.map do |v|
63
+ "#{order_by_value_sql col, v, with_nulls} " \
64
+ "#{sort_direction_sql(col, reverse)}"
65
+ end)
66
+ clauses.join(', ').freeze
67
+ end
68
+ # rubocop:enable Metrics/AbcSize
69
+
70
+ def needs_null_sort?(col, reverse,
71
+ nulls_direction = col.nulls_direction(reverse))
72
+ return false unless col.nullable?
73
+
74
+ nulls_direction != col.default_nulls_direction(reverse)
75
+ end
76
+
77
+ def order_by_nulls_sql(col, reverse)
78
+ if col.default_nulls_direction !=
79
+ (col.direction == :asc ? :first : :last)
80
+ reverse = !reverse
43
81
  end
44
- enum.map { |v|
45
- "#{order_by_value_sql col, v} #{sort_direction_sql(col, reverse)}"
46
- }.join(', ').freeze
82
+ "#{col.column_name} IS NULL #{sort_direction_sql(col, reverse)}"
47
83
  end
48
84
 
49
- def order_by_value_sql(col, v)
50
- "#{col.column_name}=#{col.quote v}"
85
+ def order_by_value_sql(col, v, with_nulls = false)
86
+ if with_nulls
87
+ if v.nil?
88
+ "#{col.column_name} IS NULL"
89
+ else
90
+ "#{col.column_name} IS NOT NULL AND " \
91
+ "#{col.column_name}=#{col.quote v}"
92
+ end
93
+ else
94
+ "#{col.column_name}=#{col.quote v}"
95
+ end
51
96
  end
52
97
 
53
98
  # @return [String]
@@ -59,6 +104,35 @@ module OrderQuery
59
104
  def join_order_by_clauses(clauses)
60
105
  clauses.join(', ').freeze
61
106
  end
107
+
108
+ private
109
+
110
+ def optimize_enum_bools?(col)
111
+ col.order_enum == [false, true] || col.order_enum == [true, false]
112
+ end
113
+
114
+ def optimize_enum_bools(col, reverse)
115
+ column_clause_ray(col, col.order_enum[-1] ^ reverse)
116
+ end
117
+
118
+ ENUM_SET_TRUE_FALSE_NIL = Set[false, true, nil]
119
+
120
+ def optimize_enum_bools_nil?(col)
121
+ Set.new(col.order_enum) == ENUM_SET_TRUE_FALSE_NIL &&
122
+ !col.order_enum[1].nil?
123
+ end
124
+
125
+ def optimize_enum_bools_nil(col, reverse)
126
+ last_bool_true = if col.order_enum[-1].nil?
127
+ col.order_enum[-2]
128
+ else
129
+ col.order_enum[-1]
130
+ end
131
+ reverse_override = last_bool_true ^ reverse
132
+ with_nulls_sort =
133
+ needs_null_sort?(col, reverse_override, col.nulls_direction(reverse))
134
+ column_clause_ray(col, reverse_override, with_nulls_sort)
135
+ end
62
136
  end
63
137
  end
64
138
  end
@@ -1,7 +1,8 @@
1
- # coding: utf-8
1
+ # frozen_string_literal: true
2
+
2
3
  module OrderQuery
3
4
  module SQL
4
- # Build where clause for searching around a record in an order space
5
+ # Builds where clause for searching around a record in an order space.
5
6
  class Where
6
7
  attr_reader :point
7
8
 
@@ -13,19 +14,24 @@ module OrderQuery
13
14
 
14
15
  # Join column pairs with OR, and nest within each other with AND
15
16
  # @param [:before or :after] side
16
- # @return [query, parameters] WHERE columns matching records strictly before / after this one
17
+ # @return [query, parameters] WHERE columns matching records strictly
18
+ # before / after this one.
19
+ #
17
20
  # sales < 5 OR
18
21
  # sales = 5 AND (
19
22
  # invoice < 3 OR
20
23
  # invoices = 3 AND (
21
24
  # ... ))
22
- def build(side)
25
+ def build(side, strict = true)
23
26
  # generate pairs of terms such as sales < 5, sales = 5
24
- terms = @columns.map { |col|
25
- [where_side(col, side, true), where_tie(col)].reject { |x| x == WHERE_IDENTITY }
26
- }
27
+ terms = @columns.map.with_index do |col, i|
28
+ be_strict = i != @columns.size - 1 ? true : strict
29
+ [where_side(col, side, be_strict), where_tie(col)].reject do |x|
30
+ x == WHERE_IDENTITY
31
+ end
32
+ end
27
33
  # group pairwise with OR, and nest with AND
28
- query = foldr_terms terms.map { |pair| join_terms 'OR'.freeze, *pair }, 'AND'.freeze
34
+ query = foldr_terms terms.map { |pair| join_terms 'OR', *pair }, 'AND'
29
35
  if ::OrderQuery.wrap_top_level_or
30
36
  # wrap in a redundant AND clause for performance
31
37
  query = wrap_top_level_or query, terms, side
@@ -47,7 +53,8 @@ module OrderQuery
47
53
  # joins terms with an operator, empty terms are skipped
48
54
  # @return [query, parameters]
49
55
  def join_terms(op, *terms)
50
- [terms.map(&:first).reject(&:empty?).join(" #{op} "), terms.map(&:second).reduce([], :+)]
56
+ [terms.map(&:first).reject(&:empty?).join(" #{op} "),
57
+ terms.map(&:second).reduce([], :+)]
51
58
  end
52
59
 
53
60
  def wrap_term_with_parens(t)
@@ -65,8 +72,11 @@ module OrderQuery
65
72
  # Read more at https://github.com/glebm/order_query/issues/3
66
73
  def wrap_top_level_or(query, terms, side)
67
74
  top_term_i = terms.index(&:present?)
68
- if top_term_i && terms[top_term_i].length == 2 && !(col = @columns[top_term_i]).order_enum
69
- join_terms 'AND'.freeze, where_side(col, side, false), wrap_term_with_parens(query)
75
+ if top_term_i && terms[top_term_i].length == 2 &&
76
+ !(col = @columns[top_term_i]).order_enum
77
+ join_terms 'AND',
78
+ where_side(col, side, false),
79
+ wrap_term_with_parens(query)
70
80
  else
71
81
  query
72
82
  end
@@ -82,36 +92,61 @@ module OrderQuery
82
92
  end
83
93
 
84
94
  # @param [:before or :after] side
85
- # @return [query, params] return query fragment for column values before / after the current one
86
- def where_side(col, side, strict = true, value = point.value(col))
95
+ # @return [query, params] return query fragment for column values
96
+ # before / after the current one.
97
+ def where_side(col, side, strict, value = point.value(col))
87
98
  if col.order_enum
88
99
  where_in col, col.enum_side(value, side, strict)
100
+ elsif value.nil?
101
+ where_null col, side, strict
89
102
  else
90
103
  where_ray col, value, side, strict
91
104
  end
92
105
  end
93
106
 
94
107
  def where_in(col, values)
95
- case values.length
96
- when 0
97
- WHERE_IDENTITY
98
- when 1
99
- where_eq col, values[0]
100
- else
101
- ["#{col.column_name} IN (?)".freeze, [values]]
102
- end
108
+ join_terms 'OR',
109
+ (values.include?(nil) ? where_eq(col, nil) : WHERE_IDENTITY),
110
+ case (non_nil_values = values - [nil]).length
111
+ when 0
112
+ WHERE_IDENTITY
113
+ when 1
114
+ where_eq col, non_nil_values
115
+ else
116
+ ["#{col.column_name} IN (?)", [non_nil_values]]
117
+ end
103
118
  end
104
119
 
105
120
  def where_eq(col, value = point.value(col))
106
- [%Q(#{col.column_name} = ?).freeze, [value]]
121
+ if value.nil?
122
+ ["#{col.column_name} IS NULL", []]
123
+ else
124
+ ["#{col.column_name} = ?", [value]]
125
+ end
126
+ end
127
+
128
+ RAY_OP = { asc: '>', desc: '<' }.freeze
129
+ NULLS_ORD = { first: 'IS NOT NULL', last: 'IS NULL' }.freeze
130
+
131
+ def where_null(col, mode, strict)
132
+ if strict && col.nulls_direction(mode == :before) != :last
133
+ ["#{col.column_name} IS NOT NULL", []]
134
+ else
135
+ WHERE_IDENTITY
136
+ end
107
137
  end
108
138
 
109
- RAY_OP = {asc: '>'.freeze, desc: '<'.freeze}.freeze
110
- def where_ray(col, from, mode, strict = true)
111
- ["#{col.column_name} #{RAY_OP[col.direction(mode == :before)]}#{'=' unless strict} ?".freeze, [from]]
139
+ def where_ray(col, from, mode, strict)
140
+ ["#{col.column_name} "\
141
+ "#{RAY_OP[col.direction(mode == :before)]}#{'=' unless strict} ?",
142
+ [from]].tap do |ray|
143
+ if col.nullable? && col.nulls_direction(mode == :before) == :last
144
+ ray[0] = "(#{ray[0]} OR #{col.column_name} IS NULL)"
145
+ end
146
+ end
112
147
  end
113
148
 
114
- WHERE_IDENTITY = [''.freeze, [].freeze].freeze
149
+ WHERE_IDENTITY = ['', [].freeze].freeze
115
150
 
116
151
  private
117
152
 
@@ -120,8 +155,8 @@ module OrderQuery
120
155
  # Read more about folds:
121
156
  # * http://www.haskell.org/haskellwiki/Fold
122
157
  # * http://en.wikipedia.org/wiki/Fold_(higher-order_function)
123
- def foldr_i(z, xs, &f)
124
- xs.reverse_each.each_with_index.inject(z) { |b, (a, i)| f.call a, b, i }
158
+ def foldr_i(z, xs)
159
+ xs.reverse_each.each_with_index.inject(z) { |b, (a, i)| yield a, b, i }
125
160
  end
126
161
  end
127
162
  end