order_query 0.3.3 → 0.5.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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