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 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LexoRanker
4
+ VERSION = "0.3.0"
5
+ 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: []