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,171 @@
|
|
1
|
+
module Rotulus
|
2
|
+
class Order
|
3
|
+
# Creates an Order object that builds the column objects in the "ORDER BY" expression
|
4
|
+
#
|
5
|
+
# @param ar_model [Class] the ActiveRecord model class name of Page#ar_relation
|
6
|
+
# @param raw_hash [Hash<Symbol, Hash>, Hash<Symbol, Symbol>, nil] the order definition of columns
|
7
|
+
#
|
8
|
+
def initialize(ar_model, raw_hash = {})
|
9
|
+
@ar_model = ar_model
|
10
|
+
@raw_hash = raw_hash&.with_indifferent_access || {}
|
11
|
+
@definition = {}
|
12
|
+
|
13
|
+
build_column_definitions
|
14
|
+
end
|
15
|
+
|
16
|
+
# Returns an array of the ordered columns
|
17
|
+
#
|
18
|
+
# @return [Array<Rotulus::Column>] ordered columns
|
19
|
+
def columns
|
20
|
+
@columns ||= definition.values
|
21
|
+
end
|
22
|
+
|
23
|
+
# Returns an array of the ordered columns' names
|
24
|
+
#
|
25
|
+
# @return [Array<String>] ordered column names
|
26
|
+
def column_names
|
27
|
+
@column_names ||= columns.map(&:name)
|
28
|
+
end
|
29
|
+
|
30
|
+
# Returns an array of column names prefixed with table name
|
31
|
+
#
|
32
|
+
# @return [Array<String>] column names prefixed with the table name
|
33
|
+
def prefixed_column_names
|
34
|
+
@prefixed_column_names ||= definition.keys.map(&:to_s)
|
35
|
+
end
|
36
|
+
|
37
|
+
# Returns the SELECT expressions to include the ordered columns in the selected columns
|
38
|
+
# of a query.
|
39
|
+
#
|
40
|
+
# @return [String] the SELECT expressions
|
41
|
+
def select_sql
|
42
|
+
columns.map(&:select_sql).join(', ')
|
43
|
+
end
|
44
|
+
|
45
|
+
# Returns a hash containing the ordered column values of a given ActiveRecord::Base record
|
46
|
+
# instance. These values will be used to generate the query to fetch the preceding/succeeding
|
47
|
+
# records of a given :record.
|
48
|
+
#
|
49
|
+
# @param record [ActiveRecord::Base] a record/row returned from Page#records
|
50
|
+
# @return [Hash] the hash containing the column values with the column name as key
|
51
|
+
def selected_values(record)
|
52
|
+
return {} if record.blank?
|
53
|
+
|
54
|
+
record.slice(*select_aliases)
|
55
|
+
.transform_keys do |a|
|
56
|
+
Column.select_alias_to_name(a)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# Returns the reversed `ORDER BY` expression(s) for the current page when the page was accessed
|
61
|
+
# via a 'previous' cursor(i.e. navigating back/ page#paged_back?)
|
62
|
+
#
|
63
|
+
# @return [String] the ORDER BY clause
|
64
|
+
def reversed_sql
|
65
|
+
Arel.sql(columns.map(&:reversed_order_sql).join(', '))
|
66
|
+
end
|
67
|
+
|
68
|
+
# Returns the ORDER BY sort expression(s) to sort the records
|
69
|
+
#
|
70
|
+
# @return [String] the ORDER BY clause
|
71
|
+
def sql
|
72
|
+
Arel.sql(columns.map(&:order_sql).join(', '))
|
73
|
+
end
|
74
|
+
|
75
|
+
# Generate a 'state' so we can detect whether the order definition has changed.
|
76
|
+
#
|
77
|
+
# @return [String] the hashed state
|
78
|
+
def state
|
79
|
+
Digest::MD5.hexdigest(Oj.dump(to_h, mode: :rails))
|
80
|
+
end
|
81
|
+
|
82
|
+
# Returns a hash containing the hash representation of the ordered columns.
|
83
|
+
#
|
84
|
+
# @return [Hash] the hash representation of the ordered columns.
|
85
|
+
def to_h
|
86
|
+
definition.each_with_object({}) do |(name, column), h|
|
87
|
+
h.merge!(column.to_h)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
private
|
92
|
+
|
93
|
+
attr_reader :ar_model, :definition, :raw_hash
|
94
|
+
|
95
|
+
def ar_model_primary_key
|
96
|
+
ar_model.primary_key
|
97
|
+
end
|
98
|
+
|
99
|
+
def ar_table
|
100
|
+
ar_model.table_name
|
101
|
+
end
|
102
|
+
|
103
|
+
def column_model(model_override, name)
|
104
|
+
prefix = name.match(/^.*?(?=\.)/).to_s
|
105
|
+
unprefixed_name = name.split('.').last
|
106
|
+
|
107
|
+
unless model_override.nil?
|
108
|
+
return model_override unless model_override.columns_hash[unprefixed_name].nil?
|
109
|
+
|
110
|
+
raise Rotulus::InvalidColumnError.new(
|
111
|
+
"Model '#{model_override}' doesnt have a '#{name}' column. \
|
112
|
+
Tip: check the :model option value in the column's order configuration.".squish
|
113
|
+
)
|
114
|
+
end
|
115
|
+
|
116
|
+
if (prefix.blank? && !ar_model.columns_hash[name].nil?) ||
|
117
|
+
(prefix == ar_table && !ar_model.columns_hash[unprefixed_name].nil?)
|
118
|
+
return ar_model
|
119
|
+
end
|
120
|
+
|
121
|
+
raise Rotulus::InvalidColumnError.new(
|
122
|
+
"Unable determine which model the column '#{name}' belongs to. \
|
123
|
+
Tip: set/check the :model option value in the column's order configuration.".squish
|
124
|
+
)
|
125
|
+
end
|
126
|
+
|
127
|
+
def primary_key_ordered?
|
128
|
+
!definition["#{ar_table}.#{ar_model_primary_key}"].nil?
|
129
|
+
end
|
130
|
+
|
131
|
+
def build_column_definitions
|
132
|
+
raw_hash.each do |column_name, options|
|
133
|
+
column_name = column_name.to_s
|
134
|
+
|
135
|
+
unless options.is_a?(Hash)
|
136
|
+
options = if options.to_s.downcase == 'desc'
|
137
|
+
{ direction: :desc }
|
138
|
+
else
|
139
|
+
{ direction: :asc }
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
model = column_model(options[:model].presence, column_name)
|
144
|
+
column = Column.new(model,
|
145
|
+
column_name,
|
146
|
+
direction: options[:direction],
|
147
|
+
nulls: options[:nulls],
|
148
|
+
nullable: options[:nullable],
|
149
|
+
distinct: options[:distinct])
|
150
|
+
next unless definition[column.prefixed_name].nil?
|
151
|
+
|
152
|
+
definition[column.prefixed_name] = column
|
153
|
+
end
|
154
|
+
|
155
|
+
# Add tie-breaker using the PK
|
156
|
+
unless primary_key_ordered?
|
157
|
+
pk_column = Column.new(ar_model, ar_model_primary_key, direction: :asc)
|
158
|
+
definition[pk_column.prefixed_name] = pk_column
|
159
|
+
end
|
160
|
+
|
161
|
+
columns.first.as_leftmost!
|
162
|
+
end
|
163
|
+
|
164
|
+
# Returns an array of SELECT statement alias of the ordered columns
|
165
|
+
#
|
166
|
+
# @return [Array<String>] column SELECT aliases
|
167
|
+
def select_aliases
|
168
|
+
@select_aliases ||= columns.map(&:select_alias)
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|
data/lib/rotulus/page.rb
ADDED
@@ -0,0 +1,278 @@
|
|
1
|
+
module Rotulus
|
2
|
+
class Page
|
3
|
+
attr_reader :ar_relation, :order, :limit, :cursor
|
4
|
+
|
5
|
+
delegate :columns, to: :order, prefix: true
|
6
|
+
|
7
|
+
# Creates a new Page instance representing a subset of the given ActiveRecord::Relation
|
8
|
+
# records sorted using the given 'order' definition param.
|
9
|
+
#
|
10
|
+
# @param ar_relation [ActiveRecord::Relation] the base relation instance to be paginated
|
11
|
+
# @param order [Hash<Symbol, Hash>, Hash<Symbol, Symbol>, nil] the order definition of columns.
|
12
|
+
# Same with SQL 'ORDER BY', columns listed first takes precedence in the sorting of records.
|
13
|
+
# The order param allows 2 formats: expanded and compact. Expanded format exposes some config
|
14
|
+
# which allows more control in generating the optimal SQL queries to filter page records.
|
15
|
+
#
|
16
|
+
# Available options for each column in expanded order definition:
|
17
|
+
# * direction (Symbol) the sort direction, +:asc+ or +:desc+. Default: +:asc+.
|
18
|
+
# * nullable (Boolean) whether a null value is expected for this column in the query result.
|
19
|
+
# Note that for queries with table JOINs, a column could have a null value
|
20
|
+
# even if the column doesn't allow nulls in its table so :nullable might need to be set to
|
21
|
+
# +true+ for such cases.
|
22
|
+
# Default: +true+ if :nullable option value is nil and the
|
23
|
+
# column is defined as nullable in its table otherwise, false.
|
24
|
+
# * nulls (Symbol) null values sorting, +:first+ for +NULLS FIRST+ and
|
25
|
+
# +:last+ for +NULLS LAST+. Applicable only if column is :nullable.
|
26
|
+
# * distinct (Boolean) whether the column value is expected to be unique in the result.
|
27
|
+
# Note that for queries with table JOINs, multiple rows could have the same column
|
28
|
+
# value even if the column has a unique index defined in its table so :distinct might
|
29
|
+
# need to be set to +false+ for such cases.
|
30
|
+
# Default: true if :distinct option value is nil and the column is the PK of its
|
31
|
+
# table otherwise, false.
|
32
|
+
# * model (Class) Model where the column belongs to.
|
33
|
+
#
|
34
|
+
# @param limit [Integer] the number of records per page. Defaults to the +config.page_default_limit+.
|
35
|
+
#
|
36
|
+
# @example Using expanded order definition (Recommended)
|
37
|
+
# Rotulus::Page.new(User.all, order: { last_name: { direction: :asc },
|
38
|
+
# first_name: { direction: :desc, nulls: :last },
|
39
|
+
# ssn: { direction: :asc, distinct: true } }, limit: 3)
|
40
|
+
#
|
41
|
+
# @example Using compact order definition
|
42
|
+
# Rotulus::Page.new(User.all, order: { last_name: :asc, first_name: :desc, ssn: :asc }, limit: 3)
|
43
|
+
#
|
44
|
+
# @raise [InvalidLimit] if the :limit exceeds the configured :page_max_limit or if the
|
45
|
+
# :limit is not a positive number.
|
46
|
+
def initialize(ar_relation, order: { id: :asc }, limit: nil)
|
47
|
+
unless limit_valid?(limit)
|
48
|
+
raise InvalidLimit.new("Allowed page limit is 1 up to #{config.page_max_limit}")
|
49
|
+
end
|
50
|
+
|
51
|
+
@ar_relation = ar_relation || model.none
|
52
|
+
@order = Order.new(model, order)
|
53
|
+
@limit = (limit.presence || config.page_default_limit).to_i
|
54
|
+
end
|
55
|
+
|
56
|
+
# Return a new page pointed to the given cursor(in encoded token format)
|
57
|
+
#
|
58
|
+
# @param token [String] Base64-encoded representation of cursor.
|
59
|
+
#
|
60
|
+
# @example
|
61
|
+
# page = Rotulus::Page.new(User.where(last_name: 'Doe'), order: { first_name: :desc }, limit: 2)
|
62
|
+
# page.at('eyI6ZiI6eyJebyI6IkFjdGl2ZVN1cHBvcnQ6Okhhc2hXaXRoSW5kaWZm...')
|
63
|
+
#
|
64
|
+
# @return [Page] page instance
|
65
|
+
def at(token)
|
66
|
+
page_copy = dup
|
67
|
+
page_copy.at!(token)
|
68
|
+
page_copy
|
69
|
+
end
|
70
|
+
|
71
|
+
# Point the same page instance to the given cursor(in encoded token format)
|
72
|
+
#
|
73
|
+
# @example
|
74
|
+
# page = Rotulus::Page.new(User.where(last_name: 'Doe'), order: { first_name: :desc }, limit: 2)
|
75
|
+
# page.at!('eyI6ZiI6eyJebyI6IkFjdGl2ZVN1cHBvcnQ6Okhhc2hXaXRoSW5kaWZm...')
|
76
|
+
#
|
77
|
+
# @param token [String] Base64-encoded representation of cursor
|
78
|
+
# @return [self] page instance
|
79
|
+
def at!(token)
|
80
|
+
@cursor = token.present? ? cursor_clazz.for_page_and_token!(self, token) : nil
|
81
|
+
|
82
|
+
reload
|
83
|
+
end
|
84
|
+
|
85
|
+
# Get the records for this page. Note an extra record is fetched(limit + 1)
|
86
|
+
# to make it easier to check whether a next or previous page exists.
|
87
|
+
#
|
88
|
+
# @return [Array<ActiveRecord::Base>] array of records for this page.
|
89
|
+
def records
|
90
|
+
return loaded_records[1..limit] if paged_back? && extra_row_returned?
|
91
|
+
|
92
|
+
loaded_records[0...limit]
|
93
|
+
end
|
94
|
+
|
95
|
+
# Clear memoized records to lazily force to initiate the query again.
|
96
|
+
#
|
97
|
+
# @return [self] page instance
|
98
|
+
def reload
|
99
|
+
@loaded_records = nil
|
100
|
+
|
101
|
+
self
|
102
|
+
end
|
103
|
+
|
104
|
+
# Check if a next page exists
|
105
|
+
#
|
106
|
+
# @return [Boolean] returns true if a next page exists, otherwise returns false.
|
107
|
+
def next?
|
108
|
+
((cursor.nil? || paged_forward?) && extra_row_returned?) || paged_back?
|
109
|
+
end
|
110
|
+
|
111
|
+
# Check if a preceding page exists
|
112
|
+
#
|
113
|
+
# @return [Boolean] returns true if a previous page exists, otherwise returns false.
|
114
|
+
def prev?
|
115
|
+
(paged_back? && extra_row_returned?) || !cursor.nil? && paged_forward?
|
116
|
+
end
|
117
|
+
|
118
|
+
# Check if the page is the 'root' page; meaning, there are no preceding pages.
|
119
|
+
#
|
120
|
+
# @return [Boolean] returns true if the page is the root page, otherwise false.
|
121
|
+
def root?
|
122
|
+
cursor.nil? || !prev?
|
123
|
+
end
|
124
|
+
|
125
|
+
# Generate the cursor token to access the next page if one exists
|
126
|
+
#
|
127
|
+
# @return [String] Base64-encoded representation of cursor
|
128
|
+
def next_token
|
129
|
+
return unless next?
|
130
|
+
|
131
|
+
record = cursor_reference_record(:next)
|
132
|
+
return if record.nil?
|
133
|
+
|
134
|
+
cursor_clazz.new(record, :next).to_token
|
135
|
+
end
|
136
|
+
|
137
|
+
# Generate the cursor token to access the previous page if one exists
|
138
|
+
#
|
139
|
+
# @return [Cursor] Base64-encoded representation of cursor
|
140
|
+
def prev_token
|
141
|
+
return unless prev?
|
142
|
+
|
143
|
+
record = cursor_reference_record(:prev)
|
144
|
+
return if record.nil?
|
145
|
+
|
146
|
+
cursor_clazz.new(record, :prev).to_token
|
147
|
+
end
|
148
|
+
|
149
|
+
# Next page instance
|
150
|
+
#
|
151
|
+
# @return [Page] the next page with records after the last record of this page.
|
152
|
+
def next
|
153
|
+
return unless next?
|
154
|
+
|
155
|
+
at next_token
|
156
|
+
end
|
157
|
+
|
158
|
+
# Previous page instance
|
159
|
+
#
|
160
|
+
# @return [Page] the previous page with records preceding the first record of this page.
|
161
|
+
def prev
|
162
|
+
return unless prev?
|
163
|
+
|
164
|
+
at prev_token
|
165
|
+
end
|
166
|
+
|
167
|
+
# Generate a hash containing the previous and next page cursor tokens
|
168
|
+
#
|
169
|
+
# @return [Hash] the hash containing the cursor tokens
|
170
|
+
def links
|
171
|
+
return {} if records.empty?
|
172
|
+
|
173
|
+
{
|
174
|
+
previous: prev_token,
|
175
|
+
next: next_token
|
176
|
+
}.delete_if { |_, token| token.nil? }
|
177
|
+
end
|
178
|
+
|
179
|
+
# Return Hashed value of this page's state so we can check whether the ar_relation's filter,
|
180
|
+
# order definition, and limit are still consistent to the cursor. see Cursor.state_valid?.
|
181
|
+
#
|
182
|
+
# @return [String] the hashed state
|
183
|
+
def state
|
184
|
+
Digest::MD5.hexdigest("#{ar_relation.to_sql}~#{order.state}~#{limit}")
|
185
|
+
end
|
186
|
+
|
187
|
+
# Returns a string showing the page's records in table form with the ordered columns
|
188
|
+
# as the columns. This method is primarily used to test/debug the pagination behavior.
|
189
|
+
#
|
190
|
+
# @return [String] table
|
191
|
+
def as_table
|
192
|
+
Rotulus::PageTableizer.new(self).tableize
|
193
|
+
end
|
194
|
+
|
195
|
+
def inspect
|
196
|
+
cursor_info = cursor.nil? ? '' : " cursor='#{cursor}'"
|
197
|
+
|
198
|
+
"#<#{self.class.name} ar_relation=#{ar_relation} order=#{order} limit=#{limit}#{cursor_info}>"
|
199
|
+
end
|
200
|
+
|
201
|
+
private
|
202
|
+
|
203
|
+
# If this is the root page or when paginating forward(#paged_forward), limit+1
|
204
|
+
# includes the first record of the next page. This lets us know whether there is a page
|
205
|
+
# succeeding the current page. When paginating backwards(#paged_back?), the limit+1 includes the
|
206
|
+
# record prior to the current page's first record(last record of the previous page, if it exists)
|
207
|
+
# -letting us know that the current page has a previous/preceding page.
|
208
|
+
def loaded_records
|
209
|
+
return @loaded_records unless @loaded_records.nil?
|
210
|
+
|
211
|
+
@loaded_records = ar_relation.where(cursor&.sql)
|
212
|
+
.order(order_by_sql)
|
213
|
+
.limit(limit + 1)
|
214
|
+
.select(*select_columns)
|
215
|
+
return @loaded_records.to_a unless paged_back?
|
216
|
+
|
217
|
+
# Reverse the returned records in case #paged_back? as the sorting is also reversed.
|
218
|
+
@loaded_records = @loaded_records.reverse
|
219
|
+
end
|
220
|
+
|
221
|
+
# Query in #loaded_records uses limit + 1. Returns true if an extra row was retrieved.
|
222
|
+
def extra_row_returned?
|
223
|
+
loaded_records.size > limit
|
224
|
+
end
|
225
|
+
|
226
|
+
def paged_back?
|
227
|
+
!!cursor&.prev?
|
228
|
+
end
|
229
|
+
|
230
|
+
def paged_forward?
|
231
|
+
!!cursor&.next?
|
232
|
+
end
|
233
|
+
|
234
|
+
def cursor_reference_record(direction)
|
235
|
+
record = direction == :next ? records.last : records.first
|
236
|
+
|
237
|
+
Record.new(self, order.selected_values(record))
|
238
|
+
end
|
239
|
+
|
240
|
+
# SELECT the ordered columns so we can use the values to generate the 'where' condition
|
241
|
+
# to filter next/prev page's records. Alias and normalize those columns so we can access
|
242
|
+
# the values using record#slice.
|
243
|
+
def select_columns
|
244
|
+
base_select_values = ar_relation.select_values.presence || [select_all_sql]
|
245
|
+
base_select_values << order.select_sql
|
246
|
+
base_select_values
|
247
|
+
end
|
248
|
+
|
249
|
+
def select_all_sql
|
250
|
+
Rotulus.db.select_all_sql(model.table_name)
|
251
|
+
end
|
252
|
+
|
253
|
+
def order_by_sql
|
254
|
+
return order.reversed_sql if paged_back?
|
255
|
+
|
256
|
+
order.sql
|
257
|
+
end
|
258
|
+
|
259
|
+
def limit_valid?(limit)
|
260
|
+
return true if limit.blank?
|
261
|
+
|
262
|
+
limit = limit.to_i
|
263
|
+
limit >= 1 && limit <= config.page_max_limit
|
264
|
+
end
|
265
|
+
|
266
|
+
def model
|
267
|
+
ar_relation.model
|
268
|
+
end
|
269
|
+
|
270
|
+
def config
|
271
|
+
@config ||= Rotulus.configuration
|
272
|
+
end
|
273
|
+
|
274
|
+
def cursor_clazz
|
275
|
+
@cursor_clazz ||= config.cursor_class
|
276
|
+
end
|
277
|
+
end
|
278
|
+
end
|
@@ -0,0 +1,143 @@
|
|
1
|
+
module Rotulus
|
2
|
+
class PageTableizer
|
3
|
+
def initialize(page)
|
4
|
+
@page = page
|
5
|
+
end
|
6
|
+
|
7
|
+
# Returns a string showing the page's records in table form with the ordered columns
|
8
|
+
# as the columns. Some data types are formatted so output is consistent regardless of the
|
9
|
+
# DB engine in use:
|
10
|
+
# 1. Datetime - iso8601(3) formatted
|
11
|
+
# 2. Float - converted to BigDecimal before converting to string
|
12
|
+
# 3. Float/BigDecimal - '.0' fractional portion is dropped
|
13
|
+
# 4. Nil - <NULL>
|
14
|
+
#
|
15
|
+
# @return [String] table
|
16
|
+
#
|
17
|
+
# example:
|
18
|
+
# +-----------------------------------------------------------------------------------------+
|
19
|
+
# | users.first_name | users.last_name | users.email | users.id |
|
20
|
+
# +-----------------------------------------------------------------------------------------+
|
21
|
+
# | George | <NULL> | george@domain.com | 1 |
|
22
|
+
# | Jane | Smith | jane.c.smith@email.com | 2 |
|
23
|
+
# | Jane | Doe | jane.doe@email.com | 3 |
|
24
|
+
# +-----------------------------------------------------------------------------------------+
|
25
|
+
#
|
26
|
+
def tableize
|
27
|
+
return '' if records.blank?
|
28
|
+
|
29
|
+
s = ''
|
30
|
+
s << divider
|
31
|
+
s << header
|
32
|
+
s << divider
|
33
|
+
s << records.map { |record| record_to_string(record) }.join("\n")
|
34
|
+
s << "\n"
|
35
|
+
s << divider
|
36
|
+
s
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
attr_reader :page
|
42
|
+
|
43
|
+
def order
|
44
|
+
@order ||= page.order
|
45
|
+
end
|
46
|
+
|
47
|
+
def records
|
48
|
+
@records ||= page.records
|
49
|
+
end
|
50
|
+
|
51
|
+
def columns
|
52
|
+
@columns ||= order.prefixed_column_names
|
53
|
+
end
|
54
|
+
|
55
|
+
def col_padding
|
56
|
+
1
|
57
|
+
end
|
58
|
+
|
59
|
+
def col_widths
|
60
|
+
return @col_widths if instance_variable_defined?(:@col_widths)
|
61
|
+
|
62
|
+
@col_widths = columns.each_with_object({}) { |name, h| h[name] = name.size + (col_padding * 2) }
|
63
|
+
records.each do |record|
|
64
|
+
values = order.selected_values(record)
|
65
|
+
|
66
|
+
values.each do |col_name, value|
|
67
|
+
width = normalize_value(value).size + (col_padding * 2)
|
68
|
+
|
69
|
+
@col_widths[col_name] = width if @col_widths[col_name] <= width
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
@col_widths
|
74
|
+
end
|
75
|
+
|
76
|
+
def header
|
77
|
+
@header ||= columns.reduce('') do |names, name|
|
78
|
+
"#{names}|#{name.center(col_widths[name])}"
|
79
|
+
end + " |\n"
|
80
|
+
end
|
81
|
+
|
82
|
+
def divider
|
83
|
+
@divider ||= '+' + ('-' * (header.size - 3)) + "+\n"
|
84
|
+
end
|
85
|
+
|
86
|
+
def record_to_string(record)
|
87
|
+
values = order.selected_values(record)
|
88
|
+
s = values.each_with_object('') do |(k, v), s|
|
89
|
+
v = normalize_value(v)
|
90
|
+
s << "|#{v.center(col_widths[k])}"
|
91
|
+
end
|
92
|
+
|
93
|
+
s << ' |'
|
94
|
+
end
|
95
|
+
|
96
|
+
def normalize_value(value)
|
97
|
+
return '<NULL>' if value.nil?
|
98
|
+
return "'#{value}'" if value.blank? && value.is_a?(String)
|
99
|
+
|
100
|
+
value = if Rotulus.db.name == 'sqlite'
|
101
|
+
format_sqlite_value(value)
|
102
|
+
else
|
103
|
+
format_value(value)
|
104
|
+
end
|
105
|
+
|
106
|
+
value = value.to_s if value.is_a?(BigDecimal)
|
107
|
+
value = BigDecimal(value.to_s).to_s if value.is_a?(Float)
|
108
|
+
value = value.split('.').first if value.is_a?(String) && value =~ /^\-?\d+\.0$/ # drop decimal if it's 0
|
109
|
+
|
110
|
+
value.to_s
|
111
|
+
end
|
112
|
+
|
113
|
+
def format_sqlite_value(value)
|
114
|
+
return value unless value.is_a?(String)
|
115
|
+
|
116
|
+
date_pattern1 = /^\d{4}\-\d{2}\-\d{2} \d{2}:\d{2}:\d{2}\.\d{6}$/
|
117
|
+
date_pattern2 = /^\d{4}\-\d{2}\-\d{2} \d{2}:\d{2}:\d{2}$/
|
118
|
+
|
119
|
+
if value =~ date_pattern1
|
120
|
+
return DateTime.strptime(value, '%Y-%m-%d %H:%M:%S.%L')
|
121
|
+
.utc
|
122
|
+
.iso8601(3)
|
123
|
+
.gsub('+00:00', 'Z')
|
124
|
+
elsif value =~ date_pattern2
|
125
|
+
return DateTime.strptime(value, '%Y-%m-%d %H:%M:%S')
|
126
|
+
.utc
|
127
|
+
.iso8601(3)
|
128
|
+
.gsub('+00:00', 'Z')
|
129
|
+
end
|
130
|
+
|
131
|
+
value
|
132
|
+
end
|
133
|
+
|
134
|
+
def format_value(value)
|
135
|
+
value = value.utc if value.respond_to?(:utc)
|
136
|
+
if value.respond_to?(:iso8601)
|
137
|
+
value = value.method(:iso8601).arity.zero? ? value.iso8601 : value.iso8601(3)
|
138
|
+
end
|
139
|
+
|
140
|
+
value
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
module Rotulus
|
2
|
+
class Record
|
3
|
+
attr_reader :page, :values
|
4
|
+
|
5
|
+
# Creates a new Record instance representing the first or last record of a :page
|
6
|
+
# wherein the :values include the ordered column values of the AR record. This
|
7
|
+
# instance serves as the reference point in generating the SQL query to fetch the the page's
|
8
|
+
# previous or next page's records. That is, the first record's values of a page are used
|
9
|
+
# in the WHERE condition to fetch the previous page's records and the last record's values
|
10
|
+
# are used to fetch next page's records.
|
11
|
+
#
|
12
|
+
# @param page [Rotulus::Page] the Page instance
|
13
|
+
# @param values [Hash] the ordered column values of an AR record
|
14
|
+
def initialize(page, values = {})
|
15
|
+
@page = page
|
16
|
+
@values = normalize_values(values || {})
|
17
|
+
end
|
18
|
+
|
19
|
+
# Get the records preceding or succeeding this record in respect to the sort direction of
|
20
|
+
# the ordered columns.
|
21
|
+
#
|
22
|
+
# @param direction [Symbol] `:next` to fetch succeeding records otherwise, `:prev`.
|
23
|
+
#
|
24
|
+
# @return [String] SQL 'where' condition
|
25
|
+
def sql_seek_condition(direction)
|
26
|
+
page.order_columns.reverse.reduce(nil) do |sql, column|
|
27
|
+
column_seek_sql = ColumnConditionBuilder.new(
|
28
|
+
column,
|
29
|
+
values[column.prefixed_name],
|
30
|
+
direction,
|
31
|
+
sql
|
32
|
+
).build
|
33
|
+
|
34
|
+
parenthesize = !sql.nil? && !column.leftmost?
|
35
|
+
parenthesize ? "(#{column_seek_sql})" : column_seek_sql
|
36
|
+
end.squish
|
37
|
+
end
|
38
|
+
|
39
|
+
# Generate a 'state' so we can detect whether the record values changed/
|
40
|
+
# for integrity check. e.g. Record values prior to encoding in the cursor
|
41
|
+
# vs. the decoded values from an encoded cursor token.
|
42
|
+
#
|
43
|
+
# @return [String] the hashed state
|
44
|
+
def state
|
45
|
+
Digest::MD5.hexdigest(values.map { |k, v| "#{k}:#{v}" }.join('~'))
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
# Normalize values so that serialization-deserialization behaviors(e.g. values from/to encoded
|
51
|
+
# cursor data, SQL query generation) are predictable:
|
52
|
+
# 1. Date, Time, Datetime: iso8601 formatted
|
53
|
+
# 2. Float: converted to BigDecimal
|
54
|
+
def normalize_values(values)
|
55
|
+
values.transform_values! do |v|
|
56
|
+
v = v.utc if v.respond_to?(:utc)
|
57
|
+
if v.respond_to?(:iso8601)
|
58
|
+
v = v.method(:iso8601).arity.zero? ? v.iso8601 : v.iso8601(6)
|
59
|
+
end
|
60
|
+
|
61
|
+
v = BigDecimal(v.to_s) if v.is_a?(Float)
|
62
|
+
v
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
data/lib/rotulus.rb
ADDED
@@ -0,0 +1,42 @@
|
|
1
|
+
require 'active_record'
|
2
|
+
require 'active_support'
|
3
|
+
require 'active_support/core_ext/string/inquiry'
|
4
|
+
require 'oj'
|
5
|
+
require 'rotulus/version'
|
6
|
+
require 'rotulus/configuration'
|
7
|
+
require 'rotulus/db/database'
|
8
|
+
require 'rotulus/record'
|
9
|
+
require 'rotulus/column'
|
10
|
+
require 'rotulus/column_condition_builder'
|
11
|
+
require 'rotulus/order'
|
12
|
+
require 'rotulus/cursor'
|
13
|
+
require 'rotulus/page_tableizer'
|
14
|
+
require 'rotulus/page'
|
15
|
+
|
16
|
+
module Rotulus
|
17
|
+
class BaseError < StandardError; end
|
18
|
+
class CursorError < BaseError; end
|
19
|
+
class InvalidCursor < CursorError; end
|
20
|
+
class ExpiredCursor < CursorError; end
|
21
|
+
class InvalidCursorDirection < CursorError; end
|
22
|
+
class InvalidLimit < BaseError; end
|
23
|
+
class ConfigurationError < BaseError; end
|
24
|
+
class InvalidColumnError < BaseError; end
|
25
|
+
|
26
|
+
def self.db
|
27
|
+
@db ||= case ActiveRecord::Base.connection.adapter_name.downcase
|
28
|
+
when /(mysql).*/
|
29
|
+
require 'rotulus/db/mysql'
|
30
|
+
|
31
|
+
Rotulus::DB::MySQL.new
|
32
|
+
when /(postgres).*/
|
33
|
+
require 'rotulus/db/postgresql'
|
34
|
+
|
35
|
+
Rotulus::DB::PostgreSQL.new
|
36
|
+
else
|
37
|
+
require 'rotulus/db/sqlite'
|
38
|
+
|
39
|
+
Rotulus::DB::SQLite.new
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|