order_as_specified 1.2 → 1.7

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.
data/CHANGELOG.md CHANGED
@@ -1,3 +1,37 @@
1
+ # Unreleased (`master`)
2
+
3
+ # 1.7
4
+
5
+ This release adds support for `nil` values in the ordering list. Thanks to
6
+ [yfulmes](https://github.com/yfulmes) for requesting this feature!
7
+
8
+ # 1.6
9
+
10
+ We are dropping official support for Ruby 2.3 and below, though they may
11
+ continue to work.
12
+
13
+ This release adds support for ordering by `Range`s. Big thanks to
14
+ [Karl-Aksel Puulmann](https://github.com/macobo) for adding this functionality!
15
+
16
+ # 1.5
17
+
18
+ This release improves performance by switching to use `CASE` statements. Huge
19
+ thanks to [Yen-Nan (Maso) Lin](https://github.com/masolin) for this improvement!
20
+
21
+ # 1.4
22
+
23
+ This release removes deprecation warnings for ActiveRecord 5.2 users, and drops
24
+ support for ActiveRecord 4.x. Many thanks to
25
+ [George Protacio-Karaszi](https://github.com/GeorgeKaraszi) for pointing out
26
+ this issue!
27
+
28
+ # 1.3
29
+
30
+ This release adds support for ActiveRecord 5.1. Many thanks to
31
+ [cohki0305](https://github.com/cohki0305) for identifying the issue,
32
+ [Billy Ferguson](https://github.com/fergyfresh) for investigating it, and
33
+ especially to [Alex Heeton](https://github.com/heeton) for fixing it.
34
+
1
35
  # 1.2
2
36
 
3
37
  This release contains an important change: `order_as_specified` is no longer
@@ -0,0 +1,74 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ In the interest of fostering an open and welcoming environment, we as
6
+ contributors and maintainers pledge to making participation in our project and
7
+ our community a harassment-free experience for everyone, regardless of age, body
8
+ size, disability, ethnicity, gender identity and expression, level of experience,
9
+ nationality, personal appearance, race, religion, or sexual identity and
10
+ orientation.
11
+
12
+ ## Our Standards
13
+
14
+ Examples of behavior that contributes to creating a positive environment
15
+ include:
16
+
17
+ * Using welcoming and inclusive language
18
+ * Being respectful of differing viewpoints and experiences
19
+ * Gracefully accepting constructive criticism
20
+ * Focusing on what is best for the community
21
+ * Showing empathy towards other community members
22
+
23
+ Examples of unacceptable behavior by participants include:
24
+
25
+ * The use of sexualized language or imagery and unwelcome sexual attention or
26
+ advances
27
+ * Trolling, insulting/derogatory comments, and personal or political attacks
28
+ * Public or private harassment
29
+ * Publishing others' private information, such as a physical or electronic
30
+ address, without explicit permission
31
+ * Other conduct which could reasonably be considered inappropriate in a
32
+ professional setting
33
+
34
+ ## Our Responsibilities
35
+
36
+ Project maintainers are responsible for clarifying the standards of acceptable
37
+ behavior and are expected to take appropriate and fair corrective action in
38
+ response to any instances of unacceptable behavior.
39
+
40
+ Project maintainers have the right and responsibility to remove, edit, or
41
+ reject comments, commits, code, wiki edits, issues, and other contributions
42
+ that are not aligned to this Code of Conduct, or to ban temporarily or
43
+ permanently any contributor for other behaviors that they deem inappropriate,
44
+ threatening, offensive, or harmful.
45
+
46
+ ## Scope
47
+
48
+ This Code of Conduct applies both within project spaces and in public spaces
49
+ when an individual is representing the project or its community. Examples of
50
+ representing a project or community include using an official project e-mail
51
+ address, posting via an official social media account, or acting as an appointed
52
+ representative at an online or offline event. Representation of a project may be
53
+ further defined and clarified by project maintainers.
54
+
55
+ ## Enforcement
56
+
57
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
58
+ reported by contacting the project team at engineering@panoramaed.com. All
59
+ complaints will be reviewed and investigated and will result in a response that
60
+ is deemed necessary and appropriate to the circumstances. The project team is
61
+ obligated to maintain confidentiality with regard to the reporter of an incident.
62
+ Further details of specific enforcement policies may be posted separately.
63
+
64
+ Project maintainers who do not follow or enforce the Code of Conduct in good
65
+ faith may face temporary or permanent repercussions as determined by other
66
+ members of the project's leadership.
67
+
68
+ ## Attribution
69
+
70
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71
+ available at [https://contributor-covenant.org/version/1/4][version]
72
+
73
+ [homepage]: https://contributor-covenant.org
74
+ [version]: https://contributor-covenant.org/version/1/4/
data/Gemfile CHANGED
@@ -1,6 +1,12 @@
1
+ # frozen_string_literal: true
2
+
1
3
  source "https://rubygems.org"
2
4
 
3
- # Specify your gem's dependencies in order_as_specified.gemspec
5
+ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
6
+
7
+ # Specify your gem's dependencies in unique_attributes.gemspec
4
8
  gemspec
5
9
 
6
- gem "activerecord", ENV["AR_VERSION"] if ENV["AR_VERSION"]
10
+ group :development do
11
+ gem "panolint", github: "panorama-ed/panolint", branch: "main"
12
+ end
data/README.md CHANGED
@@ -1,4 +1,7 @@
1
- [![Code Climate](https://codeclimate.com/github/panorama-ed/order_as_specified/badges/gpa.svg)](https://codeclimate.com/github/panorama-ed/order_as_specified) [![Test Coverage](https://codeclimate.com/github/panorama-ed/order_as_specified/badges/coverage.svg)](https://codeclimate.com/github/panorama-ed/order_as_specified) [![Build Status](https://travis-ci.org/panorama-ed/order_as_specified.svg)](https://travis-ci.org/panorama-ed/order_as_specified) [![Inline docs](http://inch-ci.org/github/panorama-ed/order_as_specified.png)](http://inch-ci.org/github/panorama-ed/order_as_specified) [![Gem Version](https://badge.fury.io/rb/order_as_specified.svg)](http://badge.fury.io/rb/order_as_specified)
1
+ [![Code Coverage](https://codecov.io/gh/panorama-ed/order_as_specified/branch/master/graph/badge.svg)](https://codecov.io/gh/panorama-ed/order_as_specified)
2
+ [![Build Status](https://travis-ci.com/panorama-ed/order_as_specified.svg)](https://travis-ci.com/panorama-ed/order_as_specified)
3
+ [![Inline docs](http://inch-ci.org/github/panorama-ed/order_as_specified.png)](http://inch-ci.org/github/panorama-ed/order_as_specified)
4
+ [![Gem Version](https://badge.fury.io/rb/order_as_specified.svg)](http://badge.fury.io/rb/order_as_specified)
2
5
 
3
6
  # OrderAsSpecified
4
7
 
@@ -26,8 +29,8 @@ TestObject.order_as_specified(language: ["es", "en", "fr"])
26
29
 
27
30
  Other gems like `ranked-model`, `acts_as_sortable`, etc. assume you want the
28
31
  same ordering each time, and store data to keep track of this in the database.
29
- They're great at what they do, but if your desired ordering changes, or if you
30
- don't always want an ordering, this gem is your friend.
32
+ They're great at what they do, but if you want to change the ordering, or if
33
+ you don't always want an ordering, this gem is your friend.
31
34
 
32
35
  ## Installation
33
36
 
@@ -139,6 +142,20 @@ TestObject.order_as_specified(language: ["fr", "es"])
139
142
  ]>
140
143
  ```
141
144
 
145
+ The order can also include `nil` attributes:
146
+
147
+ ```ruby
148
+ TestObject.order_as_specified(language: ["es", nil, "fr"])
149
+ => #<ActiveRecord::Relation [
150
+ #<TestObject id: 3, language: "es">,
151
+ #<TestObject id: 1, language: nil>,
152
+ #<TestObject id: 4, language: nil>,
153
+ #<TestObject id: 2, language: "fr">
154
+ ]>
155
+ ```
156
+
157
+ ### `distinct_on`
158
+
142
159
  In databases that support it (such as PostgreSQL), you can also use an option to
143
160
  add a `DISTINCT ON` to your query when you would otherwise have duplicates:
144
161
 
@@ -151,10 +168,32 @@ TestObject.order_as_specified(distinct_on: true, language: ["fr", "en"])
151
168
  ]>
152
169
  ```
153
170
 
154
- Note that if a `nil` value is passed in the ordering an error is raised, because
155
- databases do not have good or consistent support for ordering with `NULL` values
156
- in an arbitrary order, so we don't permit this behavior instead of allowing an
157
- unexpected result.
171
+ ### `case_insensitive`
172
+
173
+ If you want objects to come back in an order that is case-insensitive, you can
174
+ pass the `case_insensitive: true` value to the `order_as_specified` call, as in:
175
+
176
+ ```ruby
177
+ TestObject.order_as_specified(case_insensitive: true, language: ["fr", "en"])
178
+ => #<ActiveRecord::Relation [
179
+ #<TestObject language: "fr">
180
+ #<TestObject language: "FR">
181
+ #<TestObject language: "EN">
182
+ #<TestObject language: "en">
183
+ ]>
184
+ ```
185
+
186
+ ## Limitations
187
+
188
+ Databases may have limitations on the underlying number of fields you can have
189
+ in an `ORDER BY` clause. For example, in PostgreSQL if you pass in more than
190
+ 1664 list elements you'll [receive this error](https://github.com/panorama-ed/order_as_specified/issues/34):
191
+
192
+ ```ruby
193
+ PG::ProgramLimitExceeded: ERROR: target lists can have at most 1664 entries
194
+ ```
195
+
196
+ That's a database limitation that this gem cannot avoid, unfortunately.
158
197
 
159
198
  ## Documentation
160
199
 
@@ -169,8 +208,7 @@ We have documentation on [RubyDoc](http://www.rubydoc.info/github/panorama-ed/or
169
208
  5. Create a new Pull Request
170
209
 
171
210
  **Make sure your changes have appropriate tests (`bundle exec rspec`)
172
- and conform to the Rubocop style specified.** We use
173
- [overcommit](https://github.com/causes/overcommit) to enforce good code.
211
+ and conform to the Rubocop style specified.**
174
212
 
175
213
  ## License
176
214
 
data/RELEASING.md ADDED
@@ -0,0 +1,14 @@
1
+ # Releasing
2
+
3
+ ## These are steps for the maintainer to take to release a new version of this gem.
4
+
5
+ 1. Create a new branch for bumping the version.
6
+ 1. On the new branch, update the VERSION constant in `lib/order_as_specified/version.rb`.
7
+ 1. Update the Changelog.
8
+ 1. Commit the change: `git add -A && git commit -m 'Bump to vX.X'`.
9
+ 1. Make a PR.
10
+ 1. Merge the PR.
11
+ 1. Add a tag: `git tag -am "vX.X" vX.X`.
12
+ 1. Push the tag: `git push --tags`
13
+ 1. Push to rubygems: `gem build order_as_specified.gemspec && gem push *.gem && rm *.gem`
14
+ 1. Celebrate!
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "order_as_specified/version"
2
4
  require "order_as_specified/error"
3
5
 
@@ -5,31 +7,44 @@ require "order_as_specified/error"
5
7
  # the database in an arbitrary order, without having to store anything extra
6
8
  # in the database. Simply `extend` it into your class and then you can use the
7
9
  # `order_as_specified` class method.
8
-
9
10
  module OrderAsSpecified
10
11
  # @param hash [Hash] the ActiveRecord arguments hash
11
12
  # @return [ActiveRecord::Relation] the objects, ordered as specified
12
13
  def order_as_specified(hash)
13
14
  distinct_on = hash.delete(:distinct_on)
15
+ case_insensitive = hash.delete(:case_insensitive)
16
+
14
17
  params = extract_params(hash)
18
+ return all if params[:values].empty?
15
19
 
16
- table = connection.quote_table_name(params[:table])
17
- attribute = connection.quote_column_name(params[:attribute])
20
+ table = Arel::Table.new(params[:table])
21
+ node = Arel::Nodes::Case.new
18
22
 
19
- # We have to explicitly quote for now because SQL sanitization for ORDER BY
20
- # queries isn't in less current versions of Rails.
21
- # See: https://github.com/rails/rails/pull/13008
22
- conditions = params[:values].map do |value|
23
- raise OrderAsSpecified::Error, "Cannot order by `nil`" if value.nil?
23
+ params[:values].each_with_index do |value, index|
24
+ attribute = table[params[:attribute]]
25
+ condition =
26
+ if value.is_a?(Range)
27
+ if value.first >= value.last
28
+ raise OrderAsSpecified::Error, "Range needs to be increasing"
29
+ end
24
30
 
25
- # Sanitize each value to reduce the risk of SQL injection.
26
- "#{table}.#{attribute}=#{sanitize(value)}"
31
+ attribute.between(value)
32
+ elsif case_insensitive
33
+ attribute.matches(value)
34
+ else
35
+ attribute.eq(value)
36
+ end
37
+
38
+ node.when(condition).then(index)
27
39
  end
28
40
 
29
- scope = order(conditions.map { |cond| "#{cond} DESC" }.join(", "))
41
+ node.else(node.conditions.size)
42
+ scope = order(Arel::Nodes::Ascending.new(table.grouping(node)))
30
43
 
31
44
  if distinct_on
32
- scope = scope.select("DISTINCT ON (#{conditions.join(', ')}) #{table}.*")
45
+ distinct = Arel::Nodes::DistinctOn.new(node)
46
+ table_alias = connection.quote_table_name(table.name)
47
+ scope = scope.select(Arel.sql("#{distinct.to_sql} #{table_alias}.*"))
33
48
  end
34
49
 
35
50
  scope
@@ -43,13 +58,15 @@ module OrderAsSpecified
43
58
  # @param table [String/Symbol] the name of the table, default: the class table
44
59
  # @param hash [Hash] the ActiveRecord-style arguments, such as:
45
60
  # { other_objects: { id: [1, 5, 3] } }
46
- def extract_params(table = table_name, hash)
47
- raise "Could not parse params" unless hash.size == 1
61
+ def extract_params(hash, table = table_name)
62
+ unless hash.size == 1
63
+ raise OrderAsSpecified::Error, "Could not parse params"
64
+ end
48
65
 
49
66
  key, val = hash.first
50
67
 
51
68
  if val.is_a? Hash
52
- extract_params(key, hash[key])
69
+ extract_params(hash[key], key)
53
70
  else
54
71
  {
55
72
  table: table,
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module OrderAsSpecified
2
4
  class Error < StandardError
3
5
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module OrderAsSpecified
2
- VERSION = "1.2"
4
+ VERSION = "1.7"
3
5
  end
@@ -1,5 +1,6 @@
1
- # coding: utf-8
2
- lib = File.expand_path("../lib", __FILE__)
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path("lib", __dir__)
3
4
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
5
  require "order_as_specified/version"
5
6
 
@@ -19,14 +20,16 @@ Gem::Specification.new do |spec|
19
20
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
20
21
  spec.require_paths = ["lib"]
21
22
 
22
- spec.add_dependency "activerecord", ">= 4.0.1"
23
+ spec.add_dependency "activerecord", ">= 5.0.0"
24
+
25
+ spec.add_development_dependency "bundler"
26
+ spec.add_development_dependency "mysql2"
27
+ spec.add_development_dependency "pg"
28
+ spec.add_development_dependency "rspec"
29
+ spec.add_development_dependency "rspec-rails"
23
30
 
24
- spec.add_development_dependency "bundler", "~> 1.7"
25
- spec.add_development_dependency "codeclimate-test-reporter", "~> 0.4"
26
- spec.add_development_dependency "overcommit", "~> 0.23"
27
- spec.add_development_dependency "pg", ">= 0.18"
28
- spec.add_development_dependency "rspec", "~> 3.2"
29
- spec.add_development_dependency "rspec-rails", "~> 3.2"
30
- spec.add_development_dependency "rubocop", "~> 0.29"
31
- spec.add_development_dependency "sqlite3", ">= 1.3"
31
+ # Older versions of Rails locked in the SQLite3 version, so we have to
32
+ # explicitly specify the version here
33
+ sqlite3 = ENV["ACTIVERECORD_VERSION"] == "~> 5.0.0" ? "~> 1.3.13" : "~> 1.4"
34
+ spec.add_development_dependency "sqlite3", sqlite3
32
35
  end
@@ -5,5 +5,15 @@ sqlite3_test:
5
5
 
6
6
  postgresql_test:
7
7
  adapter: postgresql
8
+ host: localhost
8
9
  database: order_as_specified_test
9
10
  username: postgres
11
+ password: postgres
12
+
13
+ mysql_test:
14
+ adapter: mysql2
15
+ host: 127.0.0.1
16
+ encoding: utf8
17
+ database: order_as_specified_test
18
+ username: mysql
19
+ password: mysql
@@ -1,9 +1,15 @@
1
- class TestSetupMigration < ActiveRecord::Migration
1
+ # frozen_string_literal: true
2
+
3
+ VersionedMigration = ActiveRecord::Migration[ActiveRecord::Migration.current_version] # rubocop:disable Layout/LineLength
4
+
5
+ class TestSetupMigration < VersionedMigration
2
6
  def up
3
- return if ActiveRecord::Base.connection.data_source_exists?(:test_classes)
7
+ db_connection = ActiveRecord::Base.connection
8
+ return if db_connection.try(:data_source_exists?, :test_classes)
4
9
 
5
10
  create_table :test_classes do |t|
6
11
  t.string :field
12
+ t.float :number_field
7
13
  end
8
14
 
9
15
  create_table :association_test_classes do |t|
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "shared/order_as_specified_examples"
5
+ require "config/test_setup_migration"
6
+
7
+ RSpec.describe "MySQL" do
8
+ before :all do # rubocop:disable RSpec/BeforeAfterAll
9
+ ActiveRecord::Base.establish_connection(:mysql_test)
10
+ TestSetupMigration.migrate(:up)
11
+ end
12
+
13
+ after(:all) { ActiveRecord::Base.remove_connection } # rubocop:disable RSpec/BeforeAfterAll
14
+
15
+ include_examples ".order_as_specified"
16
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # Instead of defining specs here, we define them in
2
4
  # shared/order_as_specified_examples.rb. We then have a different spec file for
3
5
  # each supported database adapter, whch runs the shared RSpec tests found in
@@ -1,14 +1,16 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "spec_helper"
2
4
  require "shared/order_as_specified_examples"
3
5
  require "config/test_setup_migration"
4
6
 
5
7
  RSpec.describe "PostgreSQL" do
6
- before :all do
8
+ before :all do # rubocop:disable RSpec/BeforeAfterAll
7
9
  ActiveRecord::Base.establish_connection(:postgresql_test)
8
10
  TestSetupMigration.migrate(:up)
9
11
  end
10
12
 
11
- after(:all) { ActiveRecord::Base.remove_connection }
13
+ after(:all) { ActiveRecord::Base.remove_connection } # rubocop:disable RSpec/BeforeAfterAll
12
14
 
13
15
  include_examples ".order_as_specified"
14
16
 
@@ -21,25 +23,28 @@ RSpec.describe "PostgreSQL" do
21
23
  end
22
24
 
23
25
  let(:shuffled_objects) do
24
- fields = 3.times.map { |i| "Field #{i}" } * 2
25
- 5.times.map { |i| TestClass.create(field: fields[i]) }.shuffle
26
+ fields = Array.new(3) { |i| "Field #{i}" } * 2
27
+ Array.new(5) { |i| TestClass.create(field: fields[i]) }.shuffle
26
28
  end
27
29
 
28
30
  it "returns distinct objects" do
29
31
  expect(subject.length).to eq 3
30
32
  end
31
33
 
32
- context "input safety" do
34
+ describe "input safety" do
33
35
  before(:each) { TestClass.create(field: "foo") }
34
36
 
35
37
  it "sanitizes column values" do
36
- # Attempt to inject code to add a 'hi' field to each record. If the SQL inputs
37
- # are properly sanitized, the code will be ignored and the returned model
38
- # instances will not respond to #hi. If not, the code will execute and
39
- # each of the returned model instances will have a #hi method to access the
40
- # new field.
38
+ # Attempt to inject code to add a 'hi' field to each record. If the SQL
39
+ # inputs are properly sanitized, the code will be ignored and the
40
+ # returned model instances will not respond to #hi. If not, the code
41
+ # will execute and each of the returned model instances will have a #hi
42
+ # method to access the new field.
41
43
  bad_value = "foo') field, 'hi'::varchar AS hi FROM test_classes --"
42
- record = TestClass.order_as_specified(distinct_on: true, field: [bad_value]).to_a.first
44
+ record = TestClass.order_as_specified(
45
+ distinct_on: true,
46
+ field: [bad_value]
47
+ ).to_a.first
43
48
  expect(record).to_not respond_to(:hi)
44
49
  end
45
50
 
@@ -48,10 +53,17 @@ RSpec.describe "PostgreSQL" do
48
53
  quoted_table = AssociationTestClass.connection.quote_table_name(table)
49
54
 
50
55
  column = "id"
51
- quoted_column = AssociationTestClass.connection.quote_column_name(column)
56
+ quoted_column = AssociationTestClass.
57
+ connection.
58
+ quote_column_name(column)
59
+
60
+ sql = TestClass.order_as_specified(
61
+ distinct_on: true,
62
+ table => { column => ["foo"] }
63
+ ).to_sql
52
64
 
53
- sql = TestClass.order_as_specified(distinct_on: true, table => { column => ["foo"] }).to_sql
54
- expect(sql).to include("DISTINCT ON (#{quoted_table}.#{quoted_column}")
65
+ pattern = /DISTINCT ON \(\s*CASE WHEN #{quoted_table}.#{quoted_column}/
66
+ expect(sql).to match(pattern)
55
67
  end
56
68
  end
57
69
  end