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.
@@ -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
@@ -0,0 +1,6 @@
1
+ module Rotulus
2
+ module DB
3
+ class SQLite < Database
4
+ end
5
+ end
6
+ end