order_as_specified 1.2 → 1.7

Sign up to get free protection for your applications and to get access to all the features.
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