rotulus 0.2.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +3 -0
- data/Gemfile +46 -0
- data/LICENSE +21 -0
- data/README.md +475 -0
- data/lib/rotulus/column.rb +157 -0
- data/lib/rotulus/column_condition_builder.rb +115 -0
- data/lib/rotulus/configuration.rb +74 -0
- data/lib/rotulus/cursor.rb +152 -0
- data/lib/rotulus/db/database.rb +66 -0
- data/lib/rotulus/db/mysql.rb +31 -0
- data/lib/rotulus/db/postgresql.rb +13 -0
- data/lib/rotulus/db/sqlite.rb +6 -0
- data/lib/rotulus/order.rb +171 -0
- data/lib/rotulus/page.rb +278 -0
- data/lib/rotulus/page_tableizer.rb +143 -0
- data/lib/rotulus/record.rb +66 -0
- data/lib/rotulus/version.rb +3 -0
- data/lib/rotulus.rb +42 -0
- metadata +122 -0
@@ -0,0 +1,157 @@
|
|
1
|
+
module Rotulus
|
2
|
+
class Column
|
3
|
+
attr_reader :model, :name, :direction, :nulls
|
4
|
+
|
5
|
+
# Creates a Column object representing a table column in the "ORDER BY" expression.
|
6
|
+
#
|
7
|
+
# @param model [Class] the ActiveRecord model class name where this column belongs
|
8
|
+
# @param name [String] the column name. Columns from joined tables are
|
9
|
+
# prefixed with the joined table's name/alias (e.g. +some_table.column+).
|
10
|
+
# @param direction [Symbol] the sort direction, +:asc+ or +:desc+. Default: +:asc+.
|
11
|
+
# @param nullable [Boolean] whether a null value is expected for this column in the result.
|
12
|
+
# Note that for queries with table JOINs, a column could have a null value even
|
13
|
+
# if the column doesn't allow nulls in its table so :nullable might need to be set
|
14
|
+
# to +true+ for such cases.
|
15
|
+
# Default: +true+ if :nullable option value is nil and the column is defined as
|
16
|
+
# nullable in its table otherwise, false.
|
17
|
+
# @param nulls [Symbol] null values sorting, +:first+ for +NULLS FIRST+ and
|
18
|
+
# +:last+ for +NULLS LAST+. Applicable only if column is nullable.
|
19
|
+
# @param distinct [Boolean] whether the column value is expected to be unique in the result.
|
20
|
+
# Note that for queries with table JOINs, multiple rows could have the same column
|
21
|
+
# value even if the column has a unique index defined in its table so :distinct might
|
22
|
+
# need to be set to +false+ for such cases.
|
23
|
+
# Default: true if :distinct option value is nil and the column is the PK of its
|
24
|
+
# table otherwise, false.
|
25
|
+
#
|
26
|
+
def initialize(model, name, direction: :asc, nullable: nil, nulls: nil, distinct: nil)
|
27
|
+
@model = model
|
28
|
+
@name = name.to_s
|
29
|
+
unless name_valid?
|
30
|
+
raise Rotulus::InvalidColumnError.new("Column/table name must contain letters, digits (0-9), or \
|
31
|
+
underscores and must begin with a letter or underscore.".squish)
|
32
|
+
end
|
33
|
+
|
34
|
+
@direction = direction.to_s.downcase == 'desc' ? :desc : :asc
|
35
|
+
@distinct = (distinct.nil? ? primary_key? : distinct).presence || false
|
36
|
+
@nullable = (nullable.nil? ? metadata&.null : nullable).presence || false
|
37
|
+
@nulls = nulls_order(nulls)
|
38
|
+
end
|
39
|
+
|
40
|
+
def self.select_alias_to_name(select_alias)
|
41
|
+
select_alias.gsub('cursor___', '').gsub('__', '.')
|
42
|
+
end
|
43
|
+
|
44
|
+
def self.select_alias(name)
|
45
|
+
"cursor___#{name.to_s.gsub('.', '__')}"
|
46
|
+
end
|
47
|
+
|
48
|
+
# Mark the column as the 'leftmost' column in the 'ORDER BY' SQL (column with highest sort priority)
|
49
|
+
def as_leftmost!
|
50
|
+
@leftmost = true
|
51
|
+
|
52
|
+
self
|
53
|
+
end
|
54
|
+
|
55
|
+
def leftmost?
|
56
|
+
@leftmost
|
57
|
+
end
|
58
|
+
|
59
|
+
def asc?
|
60
|
+
direction == :asc
|
61
|
+
end
|
62
|
+
|
63
|
+
def desc?
|
64
|
+
!asc?
|
65
|
+
end
|
66
|
+
|
67
|
+
def distinct?
|
68
|
+
@distinct
|
69
|
+
end
|
70
|
+
|
71
|
+
def nullable?
|
72
|
+
@nullable
|
73
|
+
end
|
74
|
+
|
75
|
+
def nulls_first?
|
76
|
+
nulls == :first
|
77
|
+
end
|
78
|
+
|
79
|
+
def nulls_last?
|
80
|
+
nulls == :last
|
81
|
+
end
|
82
|
+
|
83
|
+
def unprefixed_name
|
84
|
+
@unprefixed_name ||= name.split('.').last
|
85
|
+
end
|
86
|
+
|
87
|
+
def prefixed_name
|
88
|
+
@prefixed_name ||= if !name_has_prefix?
|
89
|
+
"#{model.table_name}.#{name}"
|
90
|
+
else
|
91
|
+
name
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
def select_alias
|
96
|
+
self.class.select_alias(prefixed_name)
|
97
|
+
end
|
98
|
+
|
99
|
+
def to_h
|
100
|
+
h = {
|
101
|
+
direction: direction,
|
102
|
+
nullable: nullable?,
|
103
|
+
distinct: distinct?
|
104
|
+
}
|
105
|
+
h[:nulls] = nulls if nullable?
|
106
|
+
|
107
|
+
{ prefixed_name => h }
|
108
|
+
end
|
109
|
+
|
110
|
+
def reversed_order_sql
|
111
|
+
return Rotulus.db.reversed_order_sql(prefixed_name, direction) unless nullable?
|
112
|
+
|
113
|
+
Rotulus.db.reversed_nullable_order_sql(prefixed_name, direction, nulls)
|
114
|
+
end
|
115
|
+
|
116
|
+
def order_sql
|
117
|
+
return Rotulus.db.order_sql(prefixed_name, direction) unless nullable?
|
118
|
+
|
119
|
+
Rotulus.db.nullable_order_sql(prefixed_name, direction, nulls)
|
120
|
+
end
|
121
|
+
|
122
|
+
def select_sql
|
123
|
+
"#{prefixed_name} as #{select_alias}"
|
124
|
+
end
|
125
|
+
|
126
|
+
private
|
127
|
+
|
128
|
+
def metadata
|
129
|
+
model.columns_hash[unprefixed_name]
|
130
|
+
end
|
131
|
+
|
132
|
+
def name_has_prefix?
|
133
|
+
return @name_has_prefix if instance_variable_defined?(:@name_has_prefix)
|
134
|
+
|
135
|
+
@name_has_prefix = name.include?('.')
|
136
|
+
end
|
137
|
+
|
138
|
+
# Only alphanumeric columns and with or without underscores and table/alias prefix are allowed.
|
139
|
+
def name_valid?
|
140
|
+
return false if name.blank?
|
141
|
+
|
142
|
+
!!(name =~ /^([[:alpha:]_][[:alnum:]_]*)(\.([[:alpha:]_][[:alnum:]_]*))*$/)
|
143
|
+
end
|
144
|
+
|
145
|
+
def nulls_order(nulls)
|
146
|
+
return nil unless nullable?
|
147
|
+
return :last if nulls.to_s.downcase == 'last'
|
148
|
+
return :first if nulls.to_s.downcase == 'first'
|
149
|
+
|
150
|
+
Rotulus.db.default_nulls_order(direction)
|
151
|
+
end
|
152
|
+
|
153
|
+
def primary_key?
|
154
|
+
unprefixed_name == model.primary_key
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
@@ -0,0 +1,115 @@
|
|
1
|
+
module Rotulus
|
2
|
+
class ColumnConditionBuilder
|
3
|
+
# Generates a condition builder instance that builds an SQL condition
|
4
|
+
# to filter the preceding or succeeding records given a Column instance and its
|
5
|
+
# value.
|
6
|
+
#
|
7
|
+
# @param column [Rotulus::Column] ordered column
|
8
|
+
# @param value [Object] the column value for a specific reference record
|
9
|
+
# @param direction [Symbol] the seek direction, `:next` or `:prev`
|
10
|
+
# @param tie_breaker_sql [Symbol] in case :column is not distinct, a 'tie-breaker' SQL
|
11
|
+
# condition is needed to ensure stable pagination. This condition is generated from the (distinct)
|
12
|
+
# columns with lower precedence in the ORDER BY column list. For example, in
|
13
|
+
# "ORDER BY first_name asc, ssn desc", multiple records may exist with the same 'first_name' value.
|
14
|
+
# The distinct column 'ssn' in the order definition will be the tie-breaker. If no
|
15
|
+
# distinct column is defined in the order definition, the PK will be the tie-breaker.
|
16
|
+
def initialize(column, value, direction, tie_breaker_sql = nil)
|
17
|
+
@column = column
|
18
|
+
@value = value
|
19
|
+
@direction = direction
|
20
|
+
@tie_breaker_sql = tie_breaker_sql
|
21
|
+
end
|
22
|
+
|
23
|
+
def build
|
24
|
+
return filter_condition unless column.nullable?
|
25
|
+
|
26
|
+
nullable_filter_condition
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
attr_reader :column, :value, :direction, :tie_breaker_sql
|
32
|
+
delegate :nulls_first?, :nulls_last?, to: :column, prefix: false
|
33
|
+
|
34
|
+
def filter_condition
|
35
|
+
return seek_condition if column.distinct?
|
36
|
+
|
37
|
+
prefilter("#{seek_condition} OR (#{tie_break(identity)})")
|
38
|
+
end
|
39
|
+
|
40
|
+
def nullable_filter_condition
|
41
|
+
if seek_to_null_direction?
|
42
|
+
return tie_break null_condition if value.nil?
|
43
|
+
|
44
|
+
condition = "#{seek_condition} OR #{null_condition}"
|
45
|
+
return condition if column.distinct?
|
46
|
+
|
47
|
+
prefilter("(#{condition}) OR (#{tie_break(identity)})")
|
48
|
+
else
|
49
|
+
return filter_condition unless value.nil?
|
50
|
+
|
51
|
+
"#{not_null_condition} OR (#{tie_break(null_condition)})"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def identity
|
56
|
+
"#{column.prefixed_name} = #{quoted_value}"
|
57
|
+
end
|
58
|
+
|
59
|
+
# Pre-filter leftmost ordered column for perfomance if column is non-distinct
|
60
|
+
# https://use-the-index-luke.com/sql/partial-results/fetch-next-page#sb-equivalent-logic
|
61
|
+
def prefilter(condition)
|
62
|
+
return condition unless column.leftmost?
|
63
|
+
|
64
|
+
if column.nullable? && seek_to_null_direction?
|
65
|
+
return "(#{seek_condition(:inclusive)} OR #{null_condition}) AND (#{condition})"
|
66
|
+
end
|
67
|
+
|
68
|
+
"#{seek_condition(:inclusive)} AND (#{condition})"
|
69
|
+
end
|
70
|
+
|
71
|
+
def tie_break(condition)
|
72
|
+
return condition if tie_breaker_sql.blank?
|
73
|
+
|
74
|
+
"#{condition} AND #{tie_breaker_sql}"
|
75
|
+
end
|
76
|
+
|
77
|
+
def null_condition
|
78
|
+
"#{column.prefixed_name} IS NULL"
|
79
|
+
end
|
80
|
+
|
81
|
+
def not_null_condition
|
82
|
+
"#{column.prefixed_name} IS NOT NULL"
|
83
|
+
end
|
84
|
+
|
85
|
+
def seek_condition(inclusivity = :exclusive)
|
86
|
+
operator = seek_operators[direction][column.direction]
|
87
|
+
operator = "#{operator}=" if inclusivity == :inclusive
|
88
|
+
|
89
|
+
"#{column.prefixed_name} #{operator} #{quoted_value}"
|
90
|
+
end
|
91
|
+
|
92
|
+
def seek_operators
|
93
|
+
@seek_operators ||= { next: { asc: '>', desc: '<' },
|
94
|
+
prev: { asc: '<', desc: '>' } }
|
95
|
+
end
|
96
|
+
|
97
|
+
def seek_next?
|
98
|
+
direction == :next
|
99
|
+
end
|
100
|
+
|
101
|
+
def seek_prev?
|
102
|
+
!seek_next?
|
103
|
+
end
|
104
|
+
|
105
|
+
def seek_to_null_direction?
|
106
|
+
(nulls_first? && seek_prev?) || (nulls_last? && seek_next?)
|
107
|
+
end
|
108
|
+
|
109
|
+
def quoted_value
|
110
|
+
return value unless value.is_a?(String)
|
111
|
+
|
112
|
+
ActiveRecord::Base.connection.quote(value)
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
module Rotulus
|
2
|
+
class Configuration
|
3
|
+
attr_accessor :secret
|
4
|
+
|
5
|
+
def initialize
|
6
|
+
@page_default_limit = default_limit
|
7
|
+
@page_max_limit = default_max_limit
|
8
|
+
@secret = ENV['ROTULUS_SECRET']
|
9
|
+
@token_expires_in = 259200
|
10
|
+
@cursor_class = default_cursor_class
|
11
|
+
end
|
12
|
+
|
13
|
+
def page_default_limit=(limit)
|
14
|
+
@page_default_limit = limit.to_i
|
15
|
+
end
|
16
|
+
|
17
|
+
def page_default_limit
|
18
|
+
limit = @page_default_limit
|
19
|
+
limit = default_limit unless limit.positive?
|
20
|
+
limit = page_max_limit if limit > page_max_limit
|
21
|
+
limit
|
22
|
+
end
|
23
|
+
|
24
|
+
def page_max_limit=(limit)
|
25
|
+
@page_max_limit = limit.to_i
|
26
|
+
end
|
27
|
+
|
28
|
+
def page_max_limit
|
29
|
+
return @page_max_limit if @page_max_limit.positive?
|
30
|
+
|
31
|
+
default_max_limit
|
32
|
+
end
|
33
|
+
|
34
|
+
def token_expires_in=(expire_in_seconds)
|
35
|
+
@token_expires_in = expire_in_seconds.to_i
|
36
|
+
end
|
37
|
+
|
38
|
+
def token_expires_in
|
39
|
+
return @token_expires_in if @token_expires_in.positive?
|
40
|
+
|
41
|
+
nil
|
42
|
+
end
|
43
|
+
|
44
|
+
def cursor_class=(cursor_class)
|
45
|
+
@cursor_class = cursor_class.is_a?(String) ? cursor_class.constantize : cursor_class
|
46
|
+
end
|
47
|
+
|
48
|
+
def cursor_class
|
49
|
+
@cursor_class || default_cursor_class
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
def default_cursor_class
|
55
|
+
Rotulus::Cursor
|
56
|
+
end
|
57
|
+
|
58
|
+
def default_limit
|
59
|
+
5
|
60
|
+
end
|
61
|
+
|
62
|
+
def default_max_limit
|
63
|
+
50
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def self.configuration
|
68
|
+
@configuration ||= Configuration.new
|
69
|
+
end
|
70
|
+
|
71
|
+
def self.configure
|
72
|
+
yield configuration if block_given?
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,152 @@
|
|
1
|
+
require 'base64'
|
2
|
+
|
3
|
+
module Rotulus
|
4
|
+
class Cursor
|
5
|
+
class << self
|
6
|
+
# Initialize a Cursor instance for the given page instance and encoded token.
|
7
|
+
#
|
8
|
+
# @param page [Page] Page instance
|
9
|
+
# @param token [String] Base64-encoded string data
|
10
|
+
# @return [Cursor] Cursor
|
11
|
+
#
|
12
|
+
# @raise [InvalidCursor] if the cursor is no longer consistent to the page's ActiveRecord
|
13
|
+
# relation filters, sorting, limit, or if the encoded cursor data was tampered.
|
14
|
+
def for_page_and_token!(page, token)
|
15
|
+
data = decode(token)
|
16
|
+
reference_record = Record.new(page, data[:f])
|
17
|
+
direction = data[:d]
|
18
|
+
created_at = Time.at(data[:c]).utc
|
19
|
+
state = data[:s].presence
|
20
|
+
|
21
|
+
cursor = new(reference_record, direction, created_at: created_at)
|
22
|
+
|
23
|
+
if cursor.state != state
|
24
|
+
raise InvalidCursor.new('Invalid cursor possibly due to filter, order, or limit changed')
|
25
|
+
end
|
26
|
+
|
27
|
+
cursor
|
28
|
+
end
|
29
|
+
|
30
|
+
# Decode the given encoded cursor token
|
31
|
+
#
|
32
|
+
# @param token [String] Encoded cursor token
|
33
|
+
# @return [Hash] Cursor data hash containing the cursor direction(:next, :prev),
|
34
|
+
# cursor's state, and the ordered column values of the reference record: last record
|
35
|
+
# of the previous page if page direction is `:next` or the first record of the next
|
36
|
+
# page if page direction is `:prev`.
|
37
|
+
def decode(token)
|
38
|
+
Oj.load(Base64.urlsafe_decode64(token))
|
39
|
+
rescue ArgumentError => e
|
40
|
+
raise InvalidCursor.new("Invalid Cursor: #{e.message}")
|
41
|
+
end
|
42
|
+
|
43
|
+
# Encode cursor data hash
|
44
|
+
#
|
45
|
+
# @param token_data [Hash] Cursor token data hash
|
46
|
+
# @return token [String] String token for this cursor that can be used as param to Page#at.
|
47
|
+
def encode(token_data)
|
48
|
+
Base64.urlsafe_encode64(Oj.dump(token_data, symbol_keys: true))
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
attr_reader :record, :direction, :created_at
|
53
|
+
|
54
|
+
delegate :page, to: :record, prefix: false
|
55
|
+
|
56
|
+
# @param record [Record] the last(first if direction is `:prev`) record of page containing
|
57
|
+
# the ordered column's values that will be used to generate the next/prev page query.
|
58
|
+
# @param direction [Symbol] the cursor direction, `:next` for next page or `:prev` for
|
59
|
+
# previous page
|
60
|
+
# @param created_at [Time] only needed when deserializing a Cursor from a token. The time
|
61
|
+
# when the cursor was last initialized. see Cursor.from_page_and_token!
|
62
|
+
def initialize(record, direction, created_at: nil)
|
63
|
+
@record = record
|
64
|
+
@direction = direction.to_sym
|
65
|
+
@created_at = created_at.presence || Time.current
|
66
|
+
|
67
|
+
validate!
|
68
|
+
end
|
69
|
+
|
70
|
+
# @return [Boolean] returns true if the cursor should retrieve the 'next' records from the last
|
71
|
+
# record of the previous page. Otherwise, returns false.
|
72
|
+
def next?
|
73
|
+
direction == :next
|
74
|
+
end
|
75
|
+
|
76
|
+
# @return [Boolean] returns true if the cursor should retrieve the 'previous' records from the
|
77
|
+
# first record of a page. Otherwise, returns false.
|
78
|
+
def prev?
|
79
|
+
!next?
|
80
|
+
end
|
81
|
+
|
82
|
+
# Generate the SQL condition to filter the records of the next/previous page. The condition is
|
83
|
+
# generated based on the order definition and the referenced record's values.
|
84
|
+
#
|
85
|
+
# @return [String] the SQL 'where' condition to get the next or previous page's records.
|
86
|
+
def sql
|
87
|
+
@sql ||= Arel.sql(record.sql_seek_condition(direction))
|
88
|
+
end
|
89
|
+
|
90
|
+
# Generate the token: a Base64-encoded string representation of this cursor
|
91
|
+
#
|
92
|
+
# @return [String] the token encoded in Base64.
|
93
|
+
def to_token
|
94
|
+
@token ||= self.class.encode(f: record.values,
|
95
|
+
d: direction,
|
96
|
+
s: state,
|
97
|
+
c: created_at.to_i)
|
98
|
+
end
|
99
|
+
alias to_s to_token
|
100
|
+
|
101
|
+
# Generate a 'state' string so we can detect whether the cursor data is no longer consistent to
|
102
|
+
# the AR filter, limit, or order definition. This also provides a mechanism to detect if
|
103
|
+
# any token data was tampered.
|
104
|
+
#
|
105
|
+
# @return [String] the hashed state
|
106
|
+
def state
|
107
|
+
state_data = "#{page.state}#{record.state}"
|
108
|
+
state_data << "#{direction}#{created_at.to_i}#{secret}"
|
109
|
+
|
110
|
+
Digest::MD5.hexdigest(state_data)
|
111
|
+
end
|
112
|
+
|
113
|
+
# Checks if the cursor is expired
|
114
|
+
#
|
115
|
+
# @return [Boolean] returns true if cursor is expired
|
116
|
+
def expired?
|
117
|
+
return false if config.token_expires_in.nil? || created_at.nil?
|
118
|
+
|
119
|
+
(created_at + config.token_expires_in) < Time.current
|
120
|
+
end
|
121
|
+
|
122
|
+
private
|
123
|
+
|
124
|
+
# Checks whether the cursor is valid
|
125
|
+
#
|
126
|
+
# @return [Boolean]
|
127
|
+
#
|
128
|
+
# @raise [InvalidCursorDirection] if the cursor direction is not valid.
|
129
|
+
# @raise [ExpiredCursor] if the cursor is expired.
|
130
|
+
def validate!
|
131
|
+
raise ExpiredCursor.new('Cursor token expired') if expired?
|
132
|
+
|
133
|
+
return if direction_valid?
|
134
|
+
|
135
|
+
raise InvalidCursorDirection.new('Cursor direction should either be :prev or :next.')
|
136
|
+
end
|
137
|
+
|
138
|
+
def direction_valid?
|
139
|
+
%i[prev next].include?(direction)
|
140
|
+
end
|
141
|
+
|
142
|
+
def secret
|
143
|
+
return config.secret if config.secret.present?
|
144
|
+
|
145
|
+
raise ConfigurationError.new('missing :secret configuration.')
|
146
|
+
end
|
147
|
+
|
148
|
+
def config
|
149
|
+
@config ||= Rotulus.configuration
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
module Rotulus
|
2
|
+
module DB
|
3
|
+
class Database
|
4
|
+
def name
|
5
|
+
@name ||= self.class.name.split('::').last.downcase
|
6
|
+
end
|
7
|
+
|
8
|
+
def select_all_sql(table_name)
|
9
|
+
"\"#{table_name}\".*"
|
10
|
+
end
|
11
|
+
|
12
|
+
def nulls_order_sql(nulls)
|
13
|
+
return 'nulls first' if nulls == :first
|
14
|
+
|
15
|
+
'nulls last'
|
16
|
+
end
|
17
|
+
|
18
|
+
def order_sql(column_name, sort_direction)
|
19
|
+
"#{column_name} #{sort_direction}"
|
20
|
+
end
|
21
|
+
|
22
|
+
def reversed_order_sql(column_name, sort_direction)
|
23
|
+
"#{column_name} #{reverse_sort_direction(sort_direction)}"
|
24
|
+
end
|
25
|
+
|
26
|
+
def nullable_order_sql(column_name, sort_direction, nulls)
|
27
|
+
sql = order_sql(column_name, sort_direction)
|
28
|
+
return sql if nulls_in_default_order?(sort_direction, nulls)
|
29
|
+
|
30
|
+
"#{sql} #{nulls_order_sql(nulls)}"
|
31
|
+
end
|
32
|
+
|
33
|
+
def reversed_nullable_order_sql(column_name, sort_direction, nulls)
|
34
|
+
sql = reversed_order_sql(column_name, sort_direction)
|
35
|
+
|
36
|
+
nulls = reverse_nulls(nulls)
|
37
|
+
return sql if nulls_in_default_order?(reverse_sort_direction(sort_direction), nulls)
|
38
|
+
|
39
|
+
"#{sql} #{nulls_order_sql(nulls)}"
|
40
|
+
end
|
41
|
+
|
42
|
+
def nulls_in_default_order?(sort_direction, nulls)
|
43
|
+
nulls == default_nulls_order(sort_direction)
|
44
|
+
end
|
45
|
+
|
46
|
+
# SQLite and MySQL considers NULL values to be smaller than any other values.
|
47
|
+
# https://www.sqlite.org/lang_select.html#orderby
|
48
|
+
# https://dev.mysql.com/doc/refman/8.0/en/working-with-null.html
|
49
|
+
def default_nulls_order(sort_direction)
|
50
|
+
return :first if sort_direction == :asc
|
51
|
+
|
52
|
+
:last
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def reverse_sort_direction(sort_direction)
|
58
|
+
sort_direction == :asc ? :desc : :asc
|
59
|
+
end
|
60
|
+
|
61
|
+
def reverse_nulls(nulls)
|
62
|
+
nulls == :first ? :last : :first
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module Rotulus
|
2
|
+
module DB
|
3
|
+
class MySQL < Database
|
4
|
+
def select_all_sql(table_name)
|
5
|
+
"`#{table_name}`.*"
|
6
|
+
end
|
7
|
+
|
8
|
+
def nulls_order_sql(nulls)
|
9
|
+
return 'is not null' if nulls == :first
|
10
|
+
|
11
|
+
'is null'
|
12
|
+
end
|
13
|
+
|
14
|
+
def nullable_order_sql(column_name, sort_direction, nulls)
|
15
|
+
sql = order_sql(column_name, sort_direction)
|
16
|
+
return sql if nulls_in_default_order?(sort_direction, nulls)
|
17
|
+
|
18
|
+
"#{column_name} #{nulls_order_sql(nulls)}, #{sql}"
|
19
|
+
end
|
20
|
+
|
21
|
+
def reversed_nullable_order_sql(column_name, sort_direction, nulls)
|
22
|
+
sql = reversed_order_sql(column_name, sort_direction)
|
23
|
+
|
24
|
+
nulls = reverse_nulls(nulls)
|
25
|
+
return sql if nulls_in_default_order?(reverse_sort_direction(sort_direction), nulls)
|
26
|
+
|
27
|
+
"#{column_name} #{nulls_order_sql(nulls)}, #{sql}"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
module Rotulus
|
2
|
+
module DB
|
3
|
+
class PostgreSQL < Database
|
4
|
+
# PG considers NULL values to be larger than any other values.
|
5
|
+
# https://www.postgresql.org/docs/current/queries-order.html
|
6
|
+
def default_nulls_order(sort_direction)
|
7
|
+
return :last if sort_direction == :asc
|
8
|
+
|
9
|
+
:first
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|