lexoranker 0.3.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 +23 -0
- data/LICENSE.txt +20 -0
- data/README.md +175 -0
- data/lib/lexoranker/rankable.rb +67 -0
- data/lib/lexoranker/rankable_methods/adapters/active_record.rb +61 -0
- data/lib/lexoranker/rankable_methods/adapters/sequel.rb +64 -0
- data/lib/lexoranker/rankable_methods/base.rb +162 -0
- data/lib/lexoranker/ranker.rb +277 -0
- data/lib/lexoranker/version.rb +5 -0
- data/lib/lexoranker.rb +18 -0
- metadata +65 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 2d36bdd491546880243ecc90b0d2a22680bfee8060b153019fdc6f9b064f26a1
|
4
|
+
data.tar.gz: bb63791d2832294a41364c8535b2e9b19002c90eb1be08b6f016abbe7569277e
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 3773b7fa6b56a3d0345491c1cca8b967335497ef461ab91d1017e4ed6bd7ea6d8ef9ba8553d87844f8cf14e2a272a450e5c1fbe721220c6890f56375b6b73b35
|
7
|
+
data.tar.gz: 5df07a814a78be85e6927e2ade38bf01b35f73f26cea061b75823766847ab73bb013da837bd257cf5fa927bb391d618a10c73f79eb8813050860ad4fc692b663
|
data/CHANGELOG.md
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
## 2025-06-10
|
2
|
+
* Release 0.3.0
|
3
|
+
* Create initial RubyGems Release
|
4
|
+
## 2025-06-09
|
5
|
+
* Bump Gems to current versions
|
6
|
+
* Replace StandardRB with Rubocop for running linter (still uses the
|
7
|
+
StandardRB rules under the hood, but lets me configure the RSpec linter)
|
8
|
+
* Another round of autocorrected lint fixes.
|
9
|
+
## 2023-06-20
|
10
|
+
* Update `.ranks_around_position` to respect scope_by
|
11
|
+
* Version 0.2.0
|
12
|
+
## 2023-05-09
|
13
|
+
* `#init_from_array` now uses the `#balanced_ranks` method to generate the
|
14
|
+
initial set of ranks. This is a more balanced way of generating the ranks
|
15
|
+
that should be relatively uniform across the character space.
|
16
|
+
## 2023-04-28
|
17
|
+
* Allow custom character sets in Ranker
|
18
|
+
* Add documentation and update README
|
19
|
+
## 2023-04-13
|
20
|
+
* Initial Commit
|
21
|
+
* Initial Ranker implementation
|
22
|
+
* Initial Rankable implementation for Active Record
|
23
|
+
* Initial Rankable implementation for Sequel
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2025 PJ Davis
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
6
|
+
this software and associated documentation files (the “Software”), to deal in
|
7
|
+
the Software without restriction, including without limitation the rights to
|
8
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
9
|
+
the Software, and to permit persons to whom the Software is furnished to do so,
|
10
|
+
subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
17
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
18
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
19
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
20
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,175 @@
|
|
1
|
+
# LexoRanker
|
2
|
+
|
3
|
+
LexoRanker is a library for sorting a set of elements based on their
|
4
|
+
lexicographic order and the average distance between them. This method allows
|
5
|
+
for user-defined ordering of elements without having to update existing rows,
|
6
|
+
and with longer intervals between the need for rebalancing. LexoRanker is
|
7
|
+
stateless, meaning that you only need to know the rank of the elements before
|
8
|
+
and after the element you want to sort.
|
9
|
+
|
10
|
+
Inspired by Atlassian's LexoRanker.
|
11
|
+
|
12
|
+
## Installation
|
13
|
+
|
14
|
+
Add this to your application's `Gemfile`:
|
15
|
+
|
16
|
+
`gem 'lexoranker'`
|
17
|
+
|
18
|
+
And then run:
|
19
|
+
|
20
|
+
`$ bundle install`
|
21
|
+
|
22
|
+
## Ruby
|
23
|
+
|
24
|
+
### Usage
|
25
|
+
|
26
|
+
You can use the ranker stand-alone via the `LexoRanker::Ranker` class.
|
27
|
+
|
28
|
+
```ruby
|
29
|
+
# Use `.only` to get the first ranking of a set.
|
30
|
+
LexoRanker::Ranker.new.only # => "M"
|
31
|
+
|
32
|
+
# Use `.between` to get the ranking of an element between two other rankings
|
33
|
+
LexoRanker::Ranker.new.between("M", "T") # => "R"
|
34
|
+
|
35
|
+
# Use `.first` to get the ranking that comes before the first element
|
36
|
+
LexoRanker::Ranker.new.first("M") # => "H"
|
37
|
+
|
38
|
+
# Use `.init_from_enumerable(enum_list)` to generate a rank for each element
|
39
|
+
# in an already sorted list of elements, returns a hash with the elements as
|
40
|
+
# the keys and the rank as values.
|
41
|
+
list = %w[my sorted list]
|
42
|
+
LexoRanker::Ranker.new.init_from_enumerable(list) # { "my" => "M", "sorted" => "R", "list" => "t"}
|
43
|
+
```
|
44
|
+
|
45
|
+
## Ruby on Rails
|
46
|
+
|
47
|
+
### Setup
|
48
|
+
|
49
|
+
You can also use LexoRanker combined with ActiveRecord to add ranking
|
50
|
+
support to any model.
|
51
|
+
|
52
|
+
Each model that uses LexoRanker will need to have a column used to hold the
|
53
|
+
rankings. Then include the `LexoRanker::Rankable.new` module in the model, and
|
54
|
+
call the
|
55
|
+
`.rankable` method.
|
56
|
+
|
57
|
+
```ruby
|
58
|
+
|
59
|
+
class Page < ApplicationRecord
|
60
|
+
include LexoRanker::Rankable.new
|
61
|
+
|
62
|
+
rankable
|
63
|
+
end
|
64
|
+
```
|
65
|
+
|
66
|
+
By default, LexoRanker will use the `rank` column, but this, and several
|
67
|
+
other options can be customized.
|
68
|
+
|
69
|
+
```ruby
|
70
|
+
|
71
|
+
class Page < ApplicationRecord
|
72
|
+
include LexoRanker::Rankable.new
|
73
|
+
|
74
|
+
rankable field: "priority", # Set the name of the column used to hold ranking information
|
75
|
+
scope_by: "department_id", # Set the name of the column used to scope the uniqueness validation to
|
76
|
+
default_insert_pos: :top # The default position to use when inserting a new row with `.create_ranked` that doesn't include a rank
|
77
|
+
end
|
78
|
+
```
|
79
|
+
|
80
|
+
### Usage
|
81
|
+
|
82
|
+
Given the setup:
|
83
|
+
|
84
|
+
```ruby
|
85
|
+
|
86
|
+
class Page < ApplicationRecord
|
87
|
+
include LexoRanker::Rankable.new
|
88
|
+
|
89
|
+
rankable
|
90
|
+
end
|
91
|
+
```
|
92
|
+
|
93
|
+
```ruby
|
94
|
+
# Creating an instance with a ranking:
|
95
|
+
Page.create_ranked({name: "My Awesome Page"}, position: :top)
|
96
|
+
|
97
|
+
# Getting the ranked list of rows (can be chained with other scopes)
|
98
|
+
Page.ranked # or Page.ranked(direction: :desc)
|
99
|
+
|
100
|
+
# Moving an instance in the rankings (All have ! versions that will
|
101
|
+
# automatically save and raise an error on failed validations)
|
102
|
+
page.move_to_top # Move to the top of the rankings
|
103
|
+
page.move_to_bottom # Move to the bottom of the rankings
|
104
|
+
page.move_between("H", "M") # Move between two other rankings
|
105
|
+
page.move_to(4) # Move to the 4th ranking (or bottom if there are less than 4 rows)
|
106
|
+
|
107
|
+
page.rank_value # The value that LexoRank is using to rank the row
|
108
|
+
page.ranked? # Returns true if the instance has a ranking.
|
109
|
+
```
|
110
|
+
|
111
|
+
## Advanced Features
|
112
|
+
|
113
|
+
### BYOR (Bring Your Own Ranker)
|
114
|
+
|
115
|
+
The ranker of LexoRanker is pretty simple and you can provide your own
|
116
|
+
ranking solution. When calling `.rankable` in your class, you can pass in
|
117
|
+
the `ranker:` option with whatever ranker you'd like to use. It needs to
|
118
|
+
respond to `between(previous_element_rank, next_element_rank)` and will be
|
119
|
+
used instead of the ranker that is shipped with LexoRanker.
|
120
|
+
|
121
|
+
### Character Spaces
|
122
|
+
|
123
|
+
The default ranker for LexoRanker can also be customized with a different
|
124
|
+
character space. This can be passed to LexoRanker with:
|
125
|
+
|
126
|
+
`LexoRanker::Ranker.new(characterspace: MyCustomCharacterSpace)`
|
127
|
+
|
128
|
+
The character space you pass in will need to respond to:
|
129
|
+
|
130
|
+
- `ord(char) # convert a character to an ordinal value`
|
131
|
+
- `char(ord) # convert an ordinal value to a character`
|
132
|
+
- `min # the topmost ranking character in your character space`
|
133
|
+
- `max # the bottommost ranking character in your character space`
|
134
|
+
|
135
|
+
#### Notes about Character Spaces
|
136
|
+
|
137
|
+
In order to use LexoRank, the database system you use must know how to
|
138
|
+
lexicographically order the same way Ruby does. For MySQL that is generally
|
139
|
+
the `ascii_bin` collation for the ranking column. For PostgreSQL, it's the
|
140
|
+
`C` collation. The character space that is used by default includes
|
141
|
+
[A-Z, a-z, 0-9] and should be ordered correctly without the collations, however
|
142
|
+
if you wish to provide your own, you need to adjust your database accordingly.
|
143
|
+
|
144
|
+
## Contributing
|
145
|
+
|
146
|
+
### Issues
|
147
|
+
|
148
|
+
Everyone is welcome to browse the issues and make pull requests. If you need
|
149
|
+
help, please search the issues to see if there is already one for your
|
150
|
+
problem. If not, feel free to create a new one.
|
151
|
+
|
152
|
+
### Pull Requests
|
153
|
+
|
154
|
+
If you see a problem that you can fix, we welcome pull requests. This not
|
155
|
+
only includes code, but documentation or example usage as well. Here are
|
156
|
+
some steps to help you get started.
|
157
|
+
|
158
|
+
1. Create a Issue for your fix or improvement
|
159
|
+
2. Fork the repository
|
160
|
+
3. Create a branch off of `main` for your changes (name it something reasonable)
|
161
|
+
4. Make your changes (be sure you have tests!)
|
162
|
+
5. Make sure all specs and the linter run successfully (`rake`)
|
163
|
+
6. Commit your changes with a reference to your issue number in the commit
|
164
|
+
message
|
165
|
+
7. Push your changes to your fork.
|
166
|
+
8. Create your pull request.
|
167
|
+
|
168
|
+
## Changelog
|
169
|
+
See CHANGELOG.md
|
170
|
+
|
171
|
+
## License
|
172
|
+
|
173
|
+
Copyright (c) 2023 PJ Davis (pj.davis@gmail.com)
|
174
|
+
|
175
|
+
LexoRanker is released under the [MIT License](https://mit-license.org/).
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# The MIT License (MIT)
|
4
|
+
#
|
5
|
+
# Parts of this code Copyright (c) 2021 Richard Böhme
|
6
|
+
#
|
7
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
8
|
+
# of this software and associated documentation files (the "Software"), to deal
|
9
|
+
# in the Software without restriction, including without limitation the rights
|
10
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
11
|
+
# copies of the Software, and to permit persons to whom the Software is
|
12
|
+
# furnished to do so, subject to the following conditions:
|
13
|
+
#
|
14
|
+
# The above copyright notice and this permission notice shall be included in
|
15
|
+
# all copies or substantial portions of the Software.
|
16
|
+
#
|
17
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
18
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
19
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
20
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
21
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
22
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
23
|
+
# THE SOFTWARE.
|
24
|
+
|
25
|
+
require_relative "rankable_methods/base"
|
26
|
+
|
27
|
+
module LexoRanker
|
28
|
+
# Module for adding convenience methods to ActiveRecord or Sequel database adapters to support ranking items. See
|
29
|
+
# {RankableMethods::Base} for class and instance methods added from this module.
|
30
|
+
#
|
31
|
+
class Rankable < Module
|
32
|
+
# Instance the module to be included in the class for the database adapter.
|
33
|
+
#
|
34
|
+
# @param adapter [Symbol] the adapter to use
|
35
|
+
# @return Rankable the module that can be included
|
36
|
+
#
|
37
|
+
# @example Include {Rankable} in an ActiveRecord model
|
38
|
+
# class MyList < ActiveRecord::Base
|
39
|
+
# include LexoRanker::Rankable.new(:active_record)
|
40
|
+
# end
|
41
|
+
def initialize(adapter = :active_record)
|
42
|
+
@adapter_setting = adapter
|
43
|
+
@adapter = select_adapter
|
44
|
+
class_eval do
|
45
|
+
def self.included(klass)
|
46
|
+
klass.include(LexoRanker::RankableMethods::Base)
|
47
|
+
klass.include(@adapter)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
def select_adapter
|
55
|
+
case @adapter_setting
|
56
|
+
when :active_record
|
57
|
+
require_relative "rankable_methods/adapters/active_record"
|
58
|
+
RankableMethods::Adapters::ActiveRecord
|
59
|
+
when :sequel
|
60
|
+
require_relative "rankable_methods/adapters/sequel"
|
61
|
+
RankableMethods::Adapters::Sequel
|
62
|
+
else
|
63
|
+
raise InvalidAdapterError, "#{@adapter_setting} is not a valid adapter. Choices are: :active_record, :sequel"
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module LexoRanker
|
4
|
+
module RankableMethods
|
5
|
+
module Adapters
|
6
|
+
module ActiveRecord
|
7
|
+
def self.included(klass)
|
8
|
+
klass.extend(ClassMethods)
|
9
|
+
end
|
10
|
+
|
11
|
+
module ClassMethods
|
12
|
+
def set_ranked_scope(field)
|
13
|
+
scope :ranked, ->(direction: :asc) { where.not("#{field}": nil).order("#{field}": direction) }
|
14
|
+
end
|
15
|
+
|
16
|
+
def set_ranked_validations(field, scope = nil)
|
17
|
+
if scope.nil?
|
18
|
+
validates field, uniqueness: true, allow_nil: true
|
19
|
+
else
|
20
|
+
validates field, uniqueness: {scope: @rankable_scope}, allow_nil: true
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def create_ranked(attributes, position: nil, &block)
|
25
|
+
position = case position
|
26
|
+
when :top, :bottom
|
27
|
+
[:"move_to_#{position}"]
|
28
|
+
when Integer
|
29
|
+
[:move_to, position]
|
30
|
+
else
|
31
|
+
[:"move_to_#{rankable_default_insert_pos}"]
|
32
|
+
end
|
33
|
+
instance = new(attributes, &block)
|
34
|
+
instance.send(*position)
|
35
|
+
instance.save
|
36
|
+
instance
|
37
|
+
end
|
38
|
+
|
39
|
+
def ranks_around_position(id, position, scope_value: nil)
|
40
|
+
scope = ranked.where.not(id: id)
|
41
|
+
scope = scope.where("#{rankable_scope}": scope_value) unless scope_value.nil?
|
42
|
+
scope.offset(position - 1).limit(2).pluck(:"#{rankable_column}")
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def ranked_collection
|
47
|
+
@ranked_collection ||= begin
|
48
|
+
scope = self.class.ranked
|
49
|
+
scope = scope.where("#{self.class.rankable_scope}": send(self.class.rankable_scope)) if rankable_scoped?
|
50
|
+
scope.pluck(:"#{self.class.rankable_column}")
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def move_to!(position)
|
55
|
+
move_to(position)
|
56
|
+
save!
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module LexoRanker
|
4
|
+
module RankableMethods
|
5
|
+
module Adapters
|
6
|
+
module Sequel
|
7
|
+
def self.included(klass)
|
8
|
+
klass.extend(ClassMethods)
|
9
|
+
end
|
10
|
+
|
11
|
+
module ClassMethods
|
12
|
+
def set_ranked_scope(field)
|
13
|
+
dataset_module do
|
14
|
+
define_method(:ranked) do |direction: :asc|
|
15
|
+
order(::Sequel.send(direction, field)).exclude(field => nil)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def set_ranked_validations(field, scope = nil)
|
21
|
+
args = scope.nil? ? field : [field, scope]
|
22
|
+
plugin :validation_helpers
|
23
|
+
define_method :validate do
|
24
|
+
super()
|
25
|
+
validates_unique(args)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def create_ranked(attributes, position: nil, &block)
|
30
|
+
position = case position
|
31
|
+
when :top, :bottom
|
32
|
+
[:"move_to_#{position}"]
|
33
|
+
when Integer
|
34
|
+
[:move_to, position]
|
35
|
+
else
|
36
|
+
[:"move_to_#{rankable_default_insert_pos}"]
|
37
|
+
end
|
38
|
+
instance = new(attributes, &block)
|
39
|
+
instance.send(*position)
|
40
|
+
instance.save
|
41
|
+
instance
|
42
|
+
end
|
43
|
+
|
44
|
+
def ranks_around_position(id, position, scope_value: nil)
|
45
|
+
scope = ranked.exclude(id: id)
|
46
|
+
scope = scope.where("#{rankable_scope}": scope_value) unless scope_value.nil?
|
47
|
+
scope.offset(position - 1).limit(2).select(rankable_column).map(&:"#{rankable_column}")
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def ranked_collection
|
52
|
+
scope = self.class.ranked
|
53
|
+
scope = scope.where("#{self.class.rankable_scope}": send(self.class.rankable_scope)) if rankable_scoped?
|
54
|
+
scope.select(self.class.rankable_column).map(&:"#{self.class.rankable_column}") || []
|
55
|
+
end
|
56
|
+
|
57
|
+
def move_to!(position)
|
58
|
+
move_to(position)
|
59
|
+
save
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,162 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module LexoRanker
|
4
|
+
module RankableMethods
|
5
|
+
module Base
|
6
|
+
# Ruby lifecycle callback, executed when included into other classes
|
7
|
+
def self.included(klass)
|
8
|
+
klass.extend(ClassMethods)
|
9
|
+
end
|
10
|
+
|
11
|
+
module ClassMethods
|
12
|
+
# The column to hold the rank value
|
13
|
+
# @return [String] the rank value
|
14
|
+
attr_reader :rankable_column
|
15
|
+
|
16
|
+
# The scope of the rankings
|
17
|
+
# @return [String] the column the rankings are scoped to
|
18
|
+
attr_reader :rankable_scope
|
19
|
+
|
20
|
+
# The ranker to use
|
21
|
+
# @return [Class] The ranker being used
|
22
|
+
attr_reader :rankable_ranker
|
23
|
+
|
24
|
+
# Default insert position for newly rank-created elements
|
25
|
+
# @return [Symbol] The default position
|
26
|
+
attr_reader :rankable_default_insert_pos
|
27
|
+
|
28
|
+
# Method to set up the rankable column and add the rankable scope and validations to the class including
|
29
|
+
# Rankable
|
30
|
+
# @param field [String, Symbol] The field to use for the ranking column.
|
31
|
+
# @param scope_by [String, Symbol] The field that is used to scope the rankings
|
32
|
+
# @param ranker [Class] The class used to determine rankings
|
33
|
+
# @param default_insert_pos [Symbol] the default position for newly created rankable elements to be placed.
|
34
|
+
# @return [void]
|
35
|
+
def rankable_by(field: :rank, scope_by: nil, ranker: LexoRanker::Ranker.new, default_insert_pos: :bottom)
|
36
|
+
unless %i[top bottom].include?(default_insert_pos)
|
37
|
+
raise ArgumentError,
|
38
|
+
"#{default_insert_pos} is not a valid default_insert_position. Must be one of [:top, :bottom]"
|
39
|
+
end
|
40
|
+
@rankable_column = field
|
41
|
+
@rankable_scope = scope_by
|
42
|
+
@rankable_ranker = ranker
|
43
|
+
@rankable_default_insert_pos = default_insert_pos
|
44
|
+
|
45
|
+
set_ranked_scope(field)
|
46
|
+
set_ranked_validations(field, @rankable_scope)
|
47
|
+
end
|
48
|
+
|
49
|
+
alias_method :rankable, :rankable_by
|
50
|
+
|
51
|
+
# Create a new instance of the rankable class with a ranking at `position`.
|
52
|
+
#
|
53
|
+
# @param attributes [Hash] attributes for the newly created instance
|
54
|
+
# @param position [Integer] position that the instance should be created at
|
55
|
+
# @yield [instance] The instance before it is saved.
|
56
|
+
# @return [Object] The record that was created
|
57
|
+
def create_ranked(attributes, position: nil, &block)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
# Move an instance to the top of the rankings
|
62
|
+
#
|
63
|
+
# @return [String] The rank the instance has been assigned
|
64
|
+
#
|
65
|
+
# @example Moving an element to the top of the rankings (ActiveRecord, #rank column)
|
66
|
+
# element = Element.find('some_id')
|
67
|
+
# element.move_to_top # => 'aaa'
|
68
|
+
# element.rank # => 'aaa'
|
69
|
+
# element.changed? => true
|
70
|
+
def move_to_top
|
71
|
+
move_to(0)
|
72
|
+
end
|
73
|
+
|
74
|
+
# Move an instance to the top of the rankings and save. Raises an error if it can not be saved
|
75
|
+
#
|
76
|
+
# @return [String] The rank the instance has been assigned
|
77
|
+
#
|
78
|
+
# @example Moving an element to the top of the rankings (ActiveRecord, #rank column)
|
79
|
+
# element = Element.find('some_id')
|
80
|
+
# element.move_to_top! # => 'aaa'
|
81
|
+
# element.rank # => 'aaa'
|
82
|
+
# element.changed? => false
|
83
|
+
def move_to_top!
|
84
|
+
move_to!(0)
|
85
|
+
end
|
86
|
+
|
87
|
+
# Move an instance to the bottom of the rankings
|
88
|
+
#
|
89
|
+
# @return [String] The rank the instance has been assigned
|
90
|
+
#
|
91
|
+
# @example Moving an element to the bottom of the rankings (ActiveRecord, #rank column)
|
92
|
+
# element = Element.find('some_id')
|
93
|
+
# element.move_to_bottom # => 'zzz'
|
94
|
+
# element.rank # => 'zzz'
|
95
|
+
# element.changed? => true
|
96
|
+
def move_to_bottom
|
97
|
+
move_to(ranked_collection.length)
|
98
|
+
end
|
99
|
+
|
100
|
+
# Move an instance to the bottom of the rankings and save. Raises an error if it can not be saved.
|
101
|
+
#
|
102
|
+
# @return [String] The rank the instance has been assigned
|
103
|
+
#
|
104
|
+
# @example Moving an element to the bottom of the rankings (ActiveRecord, #rank column)
|
105
|
+
# element = Element.find('some_id')
|
106
|
+
# element.move_to_bottom! # => 'zzz'
|
107
|
+
# element.rank # => 'zzz'
|
108
|
+
# element.changed? => false
|
109
|
+
def move_to_bottom!
|
110
|
+
move_to!(ranked_collection.length)
|
111
|
+
end
|
112
|
+
|
113
|
+
# Returns the value of the rank column
|
114
|
+
#
|
115
|
+
# @return [String] the rank the instance has been assigned
|
116
|
+
#
|
117
|
+
# @example Getting the rank of an instance
|
118
|
+
# element = Element.find('some_id')
|
119
|
+
# element.rank_value # => 'rra'
|
120
|
+
def rank_value
|
121
|
+
send(self.class.rankable_column)
|
122
|
+
end
|
123
|
+
|
124
|
+
# Moves an instance to a rank that corresponds to position (0-indexed). Throws OutOfBoundsError if position is
|
125
|
+
# negative
|
126
|
+
#
|
127
|
+
# @param position [Integer] the position to move the instance to (0-indexed)
|
128
|
+
# @return [String] the rank the instance has been assigned
|
129
|
+
# @raise [LexoRanker::OutOfBoundsError] raised when the position is negative
|
130
|
+
#
|
131
|
+
# @example moving an instance to the 3rd position
|
132
|
+
# element = Element.find('some_id')
|
133
|
+
# element.move_to(2) # => 'Ea'
|
134
|
+
# element.changed? # => false
|
135
|
+
#
|
136
|
+
# @example moving an instance to a negative position
|
137
|
+
# element = Element.find('some_id')
|
138
|
+
# element.move_to(-1) # OutOfBoundsError raised
|
139
|
+
def move_to(position)
|
140
|
+
raise OutOfBoundsError, "position mus be 0 or a positive integer" if position.negative?
|
141
|
+
position = ranked_collection.length if position > ranked_collection.length
|
142
|
+
|
143
|
+
previous, following = if position.zero?
|
144
|
+
[nil, ranked_collection.first]
|
145
|
+
else
|
146
|
+
scope_value = send(self.class.rankable_scope) if rankable_scoped?
|
147
|
+
self.class.ranks_around_position(id, position, scope_value: scope_value)
|
148
|
+
end
|
149
|
+
|
150
|
+
rank = self.class.rankable_ranker.between(previous, following)
|
151
|
+
|
152
|
+
send(:"#{self.class.rankable_column}=", rank)
|
153
|
+
end
|
154
|
+
|
155
|
+
private
|
156
|
+
|
157
|
+
def rankable_scoped?
|
158
|
+
!self.class.rankable_scope.nil?
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
@@ -0,0 +1,277 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Library for sorting a set of elements based on their lexicographic order and the average distance between them.
|
4
|
+
#
|
5
|
+
# {LexoRanker::Ranker} Library for generating lexicographic rankings based on the elements that come before and after
|
6
|
+
# the element that needs to be ranked.
|
7
|
+
#
|
8
|
+
# {LexoRanker::Rankable} Module that can be included into either ActiveRecord or Sequel database adapters for adding
|
9
|
+
# convenience methods for ranking instances.
|
10
|
+
#
|
11
|
+
# MIT License
|
12
|
+
#
|
13
|
+
# Parts of this code Copyright (c) 2019 SeokJoon.Yun
|
14
|
+
#
|
15
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
16
|
+
# of this software and associated documentation files (the "Software"), to deal
|
17
|
+
# in the Software without restriction, including without limitation the rights
|
18
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
19
|
+
# copies of the Software, and to permit persons to whom the Software is
|
20
|
+
# furnished to do so, subject to the following conditions:
|
21
|
+
#
|
22
|
+
# The above copyright notice and this permission notice shall be included in all
|
23
|
+
# copies or substantial portions of the Software.
|
24
|
+
#
|
25
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
26
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
27
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
28
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
29
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
30
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
31
|
+
# SOFTWARE.
|
32
|
+
|
33
|
+
module LexoRanker
|
34
|
+
# LexoRanker is a lexicographic ranking system
|
35
|
+
# that uses lexicographic ordering to sort items in a list, rather than
|
36
|
+
# numbers.
|
37
|
+
class Ranker
|
38
|
+
# Returns the current object used as the character space. By default this is the built-in
|
39
|
+
# LexoRanker::Ranker::CharacterSpace class, but can be overridden in the constructor
|
40
|
+
#
|
41
|
+
# @return Object the current character set
|
42
|
+
attr_reader :character_space
|
43
|
+
|
44
|
+
# @param character_space [#chr, #ord, #min, #max]
|
45
|
+
# @return LexoRanker::Ranker a ranker
|
46
|
+
#
|
47
|
+
# @example Create a new ranker with the default character space
|
48
|
+
# ranker = LexoRanker::Ranker.new
|
49
|
+
#
|
50
|
+
# @example Create a ranker with a custom character space
|
51
|
+
# class CustomCharacterSpace
|
52
|
+
# CHARACTERS = %w[a b c d e]
|
53
|
+
# def self.chr(ord)
|
54
|
+
# CHARACTERS[ord]
|
55
|
+
# end
|
56
|
+
# def self.ord(chr)
|
57
|
+
# CHARACTERS.index(chr)
|
58
|
+
# end
|
59
|
+
# def min
|
60
|
+
# CHARACTERS.first
|
61
|
+
# end
|
62
|
+
# def max
|
63
|
+
# CHARACTERS.last
|
64
|
+
# end
|
65
|
+
# end
|
66
|
+
#
|
67
|
+
# custom_ranker = LexoRanker::Ranker.new(CustomCharacterSpace)
|
68
|
+
def initialize(character_space = CharacterSpace)
|
69
|
+
@character_space = character_space
|
70
|
+
@encoder = CharacterSpaceEncoder.new(character_space)
|
71
|
+
end
|
72
|
+
|
73
|
+
##
|
74
|
+
# Returns a LexoRank at the midpoint between the min and max character space. Used for when you need to rank only
|
75
|
+
# one item in a list
|
76
|
+
#
|
77
|
+
# @return [String] the new LexoRank
|
78
|
+
#
|
79
|
+
# @example Return a LexoRank with no previous or following items.
|
80
|
+
# LexoRanker::Ranker.new.only # => 'M'
|
81
|
+
def only
|
82
|
+
value_between(before: character_space.min, after: character_space.max)
|
83
|
+
end
|
84
|
+
|
85
|
+
##
|
86
|
+
# Return a LexoRank that comes before what would be the first item of a LexoRanked list. If `first_item` is nil,
|
87
|
+
# returns a LexoRank for a list with one element
|
88
|
+
#
|
89
|
+
# @param first_value [String, NilClass] the first LexoRank value of the list the new rank will be inserted into
|
90
|
+
# @return [String] a LexoRank before first_value
|
91
|
+
#
|
92
|
+
# @example Return a LexoRank before `first_value`
|
93
|
+
# LexoRanker::Ranker.new.first('M') # => 'H'
|
94
|
+
#
|
95
|
+
# @example Return a LexoRank with a nil `first_value`
|
96
|
+
# LexoRanker::Ranker.new.first(nil) # => 'M'
|
97
|
+
def first(first_value)
|
98
|
+
value_between(before: character_space.min, after: first_value)
|
99
|
+
end
|
100
|
+
|
101
|
+
##
|
102
|
+
# Return a LexoRank that comes after what would be the last item of a LexoRanked list. If `last_item` is nil,
|
103
|
+
# returns a LexoRank for a list with one element
|
104
|
+
#
|
105
|
+
# @param last_value [String, NilClass] the last LexoRank value of the list the new rank will be inserted into
|
106
|
+
# @return [String] a LexoRank after last_value
|
107
|
+
#
|
108
|
+
# @example Return a LexoRank after `last_value`
|
109
|
+
# LexoRanker::Ranker.new.last('M') # => 'T'
|
110
|
+
#
|
111
|
+
# @example Return a Lexorank with a nil `last_value`
|
112
|
+
# LexoRanker::Ranker.new.last(nil) # => 'M'
|
113
|
+
def last(last_value)
|
114
|
+
value_between(before: last_value, after: character_space.max)
|
115
|
+
end
|
116
|
+
|
117
|
+
##
|
118
|
+
# Return a LexoRank between `previous` and `following` arguments. Either argument can be called with `NilClass` to
|
119
|
+
# return a LexoRank that is after/before the passed argument, but not necessarily before/after any other particular
|
120
|
+
# element.
|
121
|
+
#
|
122
|
+
# Passing `NilClass` as an argument, and then attempting to insert a LexoRank into an existing list may end up with
|
123
|
+
# identical LexoRank rankings, which is invalid.
|
124
|
+
#
|
125
|
+
# @param previous [String, NilClass] the LexoRank that will be preceding the returned LexoRank
|
126
|
+
# @param following [String, NilClass] the LexoRank that will be following the returned LexoRank
|
127
|
+
# @return [String] a LexoRank between `previous` and `following`
|
128
|
+
#
|
129
|
+
# @example Return a LexoRank between `previous` and `following`
|
130
|
+
# LexoRanker::Ranker.new.between('M', 'T') # => 'R'
|
131
|
+
#
|
132
|
+
# @example Return a LexoRank anywhere before `following`
|
133
|
+
# LexoRanker::Ranker.new.between(nil, 'M') # => 'H'
|
134
|
+
def between(previous, following)
|
135
|
+
value_between(before: previous, after: following)
|
136
|
+
end
|
137
|
+
|
138
|
+
##
|
139
|
+
# Init a new LexoRanking for an already sorted list of elements
|
140
|
+
# @todo: Will cause issues with lists that have duplicate elements, either warn or fix.
|
141
|
+
#
|
142
|
+
# @param list [Array] the existing list to be assigned LexoRanks
|
143
|
+
# @return [Hash] a hash with key being the element of the list, and value being the assigned LexoRank
|
144
|
+
#
|
145
|
+
# @example Return a hash with element => LexoRank hash
|
146
|
+
# list = [1,2,3]
|
147
|
+
# LexoRanker::Ranker.new.init_from_array(list) # { 1 => 'M', 2 => 'T', 3 => 'W' }
|
148
|
+
def init_from_array(list)
|
149
|
+
raise ArgumentError, "`list` can not be nil" if list.nil?
|
150
|
+
|
151
|
+
list.zip(balanced_ranks(list.size)).to_h
|
152
|
+
end
|
153
|
+
|
154
|
+
##
|
155
|
+
# Return an array of rankings in order that cover `element_count` number of elements.
|
156
|
+
#
|
157
|
+
# @param element_count [Integer] number of ranking elements
|
158
|
+
# @return [Array] an array of ranks in order.
|
159
|
+
#
|
160
|
+
# @example Return an array with 5 elements
|
161
|
+
# list = LexoRanker::Ranker.new.balanced_ranks(5) # ["2", "E", "Q", "c", "o"]
|
162
|
+
def balanced_ranks(element_count)
|
163
|
+
raise ArgumentError, "`element_count` must be greater than zero" if element_count.nil? || element_count <= 0
|
164
|
+
|
165
|
+
start = 2
|
166
|
+
places = (Math.log(element_count) / Math.log(character_space.size)).ceil
|
167
|
+
ending = (character_space.size**places) - 2
|
168
|
+
|
169
|
+
Array.new(element_count).map.with_index do |_, i|
|
170
|
+
encoder.encode(start + (i.to_f / element_count.to_f * ending).round).rjust(places, character_space.min)
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
private
|
175
|
+
|
176
|
+
attr_reader :encoder
|
177
|
+
|
178
|
+
# rubocop:disable Metrics/MethodLength
|
179
|
+
def value_between(before:, after:)
|
180
|
+
before ||= character_space.min
|
181
|
+
after ||= character_space.max
|
182
|
+
rank = ""
|
183
|
+
|
184
|
+
(before.length + after.length).times do |i|
|
185
|
+
prev_char = get_char(before, i, character_space.min)
|
186
|
+
after_char = get_char(after, i, character_space.max)
|
187
|
+
|
188
|
+
if prev_char == after_char
|
189
|
+
rank += prev_char
|
190
|
+
next
|
191
|
+
end
|
192
|
+
|
193
|
+
mid = mid_char(prev_char, after_char)
|
194
|
+
|
195
|
+
if mid == prev_char || mid == after_char
|
196
|
+
rank += prev_char
|
197
|
+
next
|
198
|
+
end
|
199
|
+
|
200
|
+
rank += mid
|
201
|
+
break
|
202
|
+
end
|
203
|
+
|
204
|
+
raise InvalidRankError, "Computed rank #{rank} comes after the provided after rank #{after}" if rank >= after
|
205
|
+
|
206
|
+
rank
|
207
|
+
end
|
208
|
+
|
209
|
+
# rubocop:enable Metrics/MethodLength
|
210
|
+
|
211
|
+
def mid_char(prev, after)
|
212
|
+
character_space.chr(((character_space.ord(prev) + character_space.ord(after)) / 2.0).round)
|
213
|
+
end
|
214
|
+
|
215
|
+
def get_char(string, index, default)
|
216
|
+
(index >= string.length) ? default : string[index]
|
217
|
+
end
|
218
|
+
|
219
|
+
class CharacterSpaceEncoder
|
220
|
+
attr_reader :character_space
|
221
|
+
|
222
|
+
def initialize(character_space)
|
223
|
+
@character_space = character_space
|
224
|
+
end
|
225
|
+
|
226
|
+
def encode(num)
|
227
|
+
str = ""
|
228
|
+
while num > 0
|
229
|
+
str = character_space.chr(num % character_space.size) + str
|
230
|
+
num /= character_space.size
|
231
|
+
end
|
232
|
+
str
|
233
|
+
end
|
234
|
+
end
|
235
|
+
|
236
|
+
class CharacterSpace
|
237
|
+
CHARACTERS = [*"0".."9", *"A".."Z", *"a".."z"].sort.freeze
|
238
|
+
|
239
|
+
class EmptyCharacterSpaceError < StandardError; end
|
240
|
+
|
241
|
+
class CharNotInCharacterSpaceError < StandardError; end
|
242
|
+
|
243
|
+
class IndexOutOfCharacterSpaceError < StandardError; end
|
244
|
+
|
245
|
+
class << self
|
246
|
+
##
|
247
|
+
# @return [Integer]
|
248
|
+
def ord(char)
|
249
|
+
CHARACTERS.index(char) || (raise CharNotInCharacterSpaceError,
|
250
|
+
"Character: #{char} not found in current character space")
|
251
|
+
end
|
252
|
+
|
253
|
+
##
|
254
|
+
# @return [String]
|
255
|
+
def chr(ord)
|
256
|
+
CHARACTERS[ord] || (raise IndexOutOfCharacterSpaceError, "Index: #{ord} outside current character space")
|
257
|
+
end
|
258
|
+
|
259
|
+
##
|
260
|
+
# @return [String]
|
261
|
+
def min
|
262
|
+
CHARACTERS.first || (raise EmptyCharacterSpaceError, "Character Space is empty")
|
263
|
+
end
|
264
|
+
|
265
|
+
##
|
266
|
+
# @return [String]
|
267
|
+
def max
|
268
|
+
CHARACTERS.last || (raise EmptyCharacterSpaceError, "Character Space is empty")
|
269
|
+
end
|
270
|
+
|
271
|
+
def size
|
272
|
+
CHARACTERS.size
|
273
|
+
end
|
274
|
+
end
|
275
|
+
end
|
276
|
+
end
|
277
|
+
end
|
data/lib/lexoranker.rb
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "lexoranker/version"
|
4
|
+
require_relative "lexoranker/ranker"
|
5
|
+
require_relative "lexoranker/rankable"
|
6
|
+
|
7
|
+
module LexoRanker
|
8
|
+
class Error < StandardError; end
|
9
|
+
|
10
|
+
# Error raised when attempting to move a ranked element to a negative rank
|
11
|
+
class OutOfBoundsError < Error; end
|
12
|
+
|
13
|
+
# Error raised when attempting to use an adapter that is not available to LexoRanker::Rankable
|
14
|
+
class InvalidAdapterError < Error; end
|
15
|
+
|
16
|
+
# Error raised when attempting to rank an element between 2 ranks that are out of order
|
17
|
+
class InvalidRankError < Error; end
|
18
|
+
end
|
metadata
ADDED
@@ -0,0 +1,65 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: lexoranker
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.3.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- PJ Davis
|
8
|
+
bindir: bin
|
9
|
+
cert_chain: []
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
11
|
+
dependencies: []
|
12
|
+
description: 'Library for sorting a set of elements based on their lexicographic order
|
13
|
+
and the average distance between them
|
14
|
+
|
15
|
+
'
|
16
|
+
email:
|
17
|
+
- pj.davis@gmail.com
|
18
|
+
executables: []
|
19
|
+
extensions: []
|
20
|
+
extra_rdoc_files:
|
21
|
+
- CHANGELOG.md
|
22
|
+
- LICENSE.txt
|
23
|
+
- README.md
|
24
|
+
files:
|
25
|
+
- CHANGELOG.md
|
26
|
+
- LICENSE.txt
|
27
|
+
- README.md
|
28
|
+
- lib/lexoranker.rb
|
29
|
+
- lib/lexoranker/rankable.rb
|
30
|
+
- lib/lexoranker/rankable_methods/adapters/active_record.rb
|
31
|
+
- lib/lexoranker/rankable_methods/adapters/sequel.rb
|
32
|
+
- lib/lexoranker/rankable_methods/base.rb
|
33
|
+
- lib/lexoranker/ranker.rb
|
34
|
+
- lib/lexoranker/version.rb
|
35
|
+
homepage: https://github.com/pjdavis/lexoranker
|
36
|
+
licenses:
|
37
|
+
- MIT
|
38
|
+
metadata:
|
39
|
+
allowed_push_host: https://rubygems.org
|
40
|
+
homepage_uri: https://github.com/pjdavis/lexoranker
|
41
|
+
source_code_uri: https://github.com/pjdavis/lexoranker
|
42
|
+
rdoc_options:
|
43
|
+
- "--title"
|
44
|
+
- LexoRanker - Lexicographic Ranking for Ruby
|
45
|
+
- "--main"
|
46
|
+
- README.md
|
47
|
+
- "--inline-source"
|
48
|
+
- "--quiet"
|
49
|
+
require_paths:
|
50
|
+
- lib
|
51
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
52
|
+
requirements:
|
53
|
+
- - ">="
|
54
|
+
- !ruby/object:Gem::Version
|
55
|
+
version: 3.2.0
|
56
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
57
|
+
requirements:
|
58
|
+
- - ">="
|
59
|
+
- !ruby/object:Gem::Version
|
60
|
+
version: '0'
|
61
|
+
requirements: []
|
62
|
+
rubygems_version: 3.6.8
|
63
|
+
specification_version: 4
|
64
|
+
summary: Lexicographic ranker for Ruby
|
65
|
+
test_files: []
|