Skip to content

Commit

Permalink
Created an alphabetical pager
Browse files Browse the repository at this point in the history
This pager exposes a an application helper(which renders a UI
partial) along with an active record extension that makes it easy to
filter a textual field based on lexiographical categories. Note: it
technically is not a pager because there are no fixed size pages of results
(and commonly there will be no items on some pages).

Its logic is limited for now because it currently fulfills all
functionality required. However I see it being useful in the REST API as
a filter function. I also expect it could be further expanded to get
totals and to also dynamically remove irrelevant pages.

This was done for #322
  • Loading branch information
atruskie committed Mar 16, 2017
1 parent 52f17c8 commit cdcdd3d
Show file tree
Hide file tree
Showing 5 changed files with 234 additions and 0 deletions.
69 changes: 69 additions & 0 deletions app/helpers/alphabetical_paginator_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
module AlphabeticalPaginatorHelper

# make our custom url generation methods available to views
include Api::CustomUrlHelpers

def alphabetical_paginator(current_page = 'a', window_size = 1, index_size = 1)
fail if window_size < 1
fail if index_size < 1

other = AlphabeticalPaginatorQuery::OTHER #"\u{1F30F}"
numbers = AlphabeticalPaginatorQuery::NUMBERS
start_char = 'a'
end_char = 'z'

pages = [
make_page(other, other, current_page),
make_page(numbers, numbers, current_page)
]


current = 0
min = 0
max = ((end_char.ord + 1 - start_char.ord) ** index_size) - 1
while current <= max do
next_number = current + window_size

left = get_chars(current, 26, index_size, start_char.ord)
right = get_chars([max, next_number - 1].min, 26, index_size, start_char.ord)

page = left + '-' + right
title = left
if window_size != 1 || index_size != 1
title = page
end

pages << make_page(title, page, current_page)

current = next_number
end


render partial: 'shared/alphabetical_paginator', locals: {
paginator: {
pages: pages
}
}
end

private

def get_chars(index, delta, index_size, offset)
result = ''
(1..index_size).each do |i|

char = (index / (delta**(index_size - i))) % delta
result += (offset + char).to_i.chr
end

result
end

def make_page(title, page, current_page)
{
title: title,
page: page,
current?: page == current_page
}
end
end
11 changes: 11 additions & 0 deletions app/views/shared/_alphabetical_paginator.haml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-# locals paginator
%ul.pagination
- paginator[:pages].each do |page|
- if page[:current?]
%li.active
%a
= page[:title]
- else
%li
= link_to page[:title], page: page[:page]

1 change: 1 addition & 0 deletions config/initializers/active_record_extensions.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
require 'alphabetical_paginator_query'
54 changes: 54 additions & 0 deletions lib/modules/alphabetical_paginator_query.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
module AlphabeticalPaginatorQuery
OTHER = "\u{1F30F}"
NUMBERS = '0-9'

extend ActiveSupport::Concern

class_methods do

def alphabetical_page(field, range)
range = 'a-b' if range.blank?
left, right = range.split('-')
query_type = validate_range(left, right)

if query_type == :normal
if left == right then
q = where(["LOWER(LEFT(\"#{field.to_s}\", ?)) = ?",
left.length,
left
])
else
q = where(["LOWER(LEFT(\"#{field.to_s}\", ?)) >= ? AND LOWER(LEFT(\"#{field.to_s}\", ?)) <= ?",
left.length,
left,
right.length,
right
])
end
elsif query_type == :numbers
q = where(["\"#{field.to_s}\" ~ '^\\d+'", field])
else
q = where(["\"#{field.to_s}\" !~ '^[a-zA-Z0-9]'", field])
end


q.order(field)
end

private

def validate_range(left, right)
return :other if OTHER == left && right.blank?
return :numbers if left == '0' && right == '9'

return :normal if /^[a-z]+$/ =~ left && /^[a-z]+$/ =~ right

fail ArgumentError, 'Alphabetical paginator range invalid'
end

end

end


ActiveRecord::Base.send(:include, AlphabeticalPaginatorQuery)
99 changes: 99 additions & 0 deletions spec/lib/modules/alphabetical_paginator_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
require 'rails_helper'

describe 'alphabetical paginator activerecord extension' do
before :each do
FactoryGirl.build(:user, user_name: '汉字 user').save(validate: false)
FactoryGirl.create(:user, user_name: 'aauser')
FactoryGirl.create(:user, user_name: 'anuser')
FactoryGirl.create(:user, user_name: 'amuser')
FactoryGirl.create(:user, user_name: 'azuser')
FactoryGirl.create(:user, user_name: 'buser')
FactoryGirl.create(:user, user_name: 'zzzzzuser')
FactoryGirl.create(:user, user_name: '_user')
FactoryGirl.create(:user, user_name: '123user')
end

it 'returns users in the other range' do
users = User.alphabetical_page(:user_name, "\u{1F30F}")
expect(users.count).to eq(2)
expect(users[0].user_name).to eq('_user')
expect(users[1].user_name).to eq('汉字 user')
end

it 'returns users in the number range' do
users = User.alphabetical_page(:user_name, '0-9')
expect(users.count).to eq(1)
expect(users[0].user_name).to eq('123user')
end

it 'returns users in the a-a range' do
users = User.alphabetical_page(:user_name, 'a-a')
expect(users.count).to eq(5)
expect(users[0].user_name).to eq('aauser')
expect(users[1].user_name).to eq('Admin')
expect(users[2].user_name).to eq('amuser')
expect(users[3].user_name).to eq('anuser')
expect(users[4].user_name).to eq('azuser')
end

it 'returns users in the a-b range' do
users = User.alphabetical_page(:user_name, 'a-b')
expect(users.count).to eq(6)
expect(users[0].user_name).to eq('aauser')
expect(users[1].user_name).to eq('Admin')
expect(users[2].user_name).to eq('amuser')
expect(users[3].user_name).to eq('anuser')
expect(users[4].user_name).to eq('azuser')
expect(users[5].user_name).to eq('buser')
end

it 'returns users in the z-z range' do
users = User.alphabetical_page(:user_name, 'z-z')
expect(users.count).to eq(1)
expect(users[0].user_name).to eq('zzzzzuser')
end

it 'returns users in the yzz-zzz range' do
users = User.alphabetical_page(:user_name, 'yzz-zzz')
expect(users.count).to eq(1)
expect(users[0].user_name).to eq('zzzzzuser')
end

it 'returns users in the an-am range' do
users = User.alphabetical_page(:user_name, 'am-an')
expect(users.count).to eq(2)
expect(users[0].user_name).to eq('amuser')
expect(users[1].user_name).to eq('anuser')
end

context 'optimization for matching arguments' do
it 'uses a simpler query format for ranges with indentical left and rights' do
query = User.alphabetical_page(:user_name, 'aaa-aaa').to_sql
expect(query).to include('LOWER(LEFT("user_name", 3)) = \'aaa\'')
expect(query).to match(/WHERE..LOWER(?!.*LOWER)/)
end
end

context 'validating arguments' do
cases = [
'1-2',
"\u{1F30D}",
"\u{1F30F}-\u{1F30F}",
'汉-字',
'a1-a2',
'---',
'\';-- SELECT * FROM users',
'A-Z',
'aA-ab',
'aa-zZ'
]

cases.each do |bad_case|
it "fails to process an invalid case ('#{bad_case}')" do
expect {
User.alphabetical_page(:user_name, bad_case)
}.to raise_error(ArgumentError, 'Alphabetical paginator range invalid')
end
end
end
end

0 comments on commit cdcdd3d

Please sign in to comment.