active_record_extended 1.2.0 → 1.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b524c24c37e0b6f7449d70e5030b893119b98dec6646b905c3a307c9a671c7f1
4
- data.tar.gz: 18cb235fba334ebfaa752b92ad024117f686176b0e9107af6333e317a333c70f
3
+ metadata.gz: 69a8ed712b33088a8aa79eb2d89ba86f645882d085d3bb752fb997eb1888b6af
4
+ data.tar.gz: a60306c9e2551606772007b683dee9477d7c87545569647c47a78e45a92b21bc
5
5
  SHA512:
6
- metadata.gz: 78539255c169c26a1d6bab3968a2eed038d8391d532f97d55dabf7a7c071cddf4eee1424586021cd7b790db4abf042cf0617ac69ede670caeb9cde55a23125a4
7
- data.tar.gz: 798ea355e5ebe213735db1552e7328d05c855c45800c941d4c5400a812b6d71f169ff52a48c0b9e6bf678d2dcf83ac1c7308a172b120caf9f83a240b0025e599
6
+ metadata.gz: 5530aaaf8f2f45aa1f7daaed6398eeac033cd50c3fb542a427dac4b9097c6ebffe08928f6d7a13bacdf9e04509f26f85ed607d4622e16423ced1308e079a2cf7
7
+ data.tar.gz: 8ca7691c2c23da13e8f1a409eeb5528ed47f01736f4cabbefa06fe441f56cae24ff504b1a0b31996645ef6bba7e2c308e1a8ee6254ee795d3a5fdbd15b0de57b
data/README.md CHANGED
@@ -36,6 +36,9 @@
36
36
  - [Union As](#union-as)
37
37
  - [Union Order](#union-order)
38
38
  - [Union Reorder](#union-reorder)
39
+ - [Window Functions](#window-functions)
40
+ - [Define Window](#define-window)
41
+ - [Select Window](#select-window)
39
42
 
40
43
  ## Description and History
41
44
 
@@ -787,6 +790,80 @@ SELECT "people".*
787
790
  ) ) ORDER BY personal_id DESC, id DESC) people
788
791
  ```
789
792
 
793
+ #### Window Functions
794
+ [Postgres Window Functions](https://www.postgresql.org/docs/current/tutorial-window.html)
795
+
796
+ Let's address the elephant in the room. Arel has had, for a long time now, window function capabilities;
797
+ However they've never seen the lime light in ActiveRecord's query logic.
798
+ The following brings the dormant Arel methods up to the ActiveRecord Querying level.
799
+
800
+ #### Define Window
801
+
802
+ To set up a window function, we first must establish the window and we do this by using the `.define_window/1` method.
803
+ This method also requires you to call chain `.partition_by/2`
804
+
805
+ `.define_window/1` - Establishes the name of the window you'll reference later on in [.select_window](#select-window)
806
+ - Aliased name of window
807
+
808
+ `.partition_by/2` - Establishes the windows operations a [pre-defined window function](https://www.postgresql.org/docs/current/functions-window.html) will leverage.
809
+ - column name being partitioned against
810
+ - (**optional**) `order_by`: Processes how the window should be ordered
811
+
812
+ ```ruby
813
+ User
814
+ .define_window(:number_window).partition_by(:number, order_by: { id: :desc })
815
+ .define_window(:name_window).partition_by(:name, order_by: :id)
816
+ .define_window(:no_order_name).partition_by(:name)
817
+ ```
818
+
819
+ Query Output
820
+ ```sql
821
+ SELECT *
822
+ FROM users
823
+ WINDOW number_window AS (PARTITION BY number ORDER BY id DESC),
824
+ name_window AS (PARTITION BY name ORDER BY id),
825
+ no_order_name AS (PARTITION BY name)
826
+ ```
827
+
828
+ #### Select Window
829
+
830
+ Once you've define a window, the next step to to utilize it on one of the many provided postgres window functions.
831
+
832
+ `.select_window/3`
833
+ - [window function name](https://www.postgresql.org/docs/current/functions-window.html)
834
+ - (**optional**) Window function arguments (treated as a splatted array)
835
+ - (**optional**) `as:` : Alias name of the final result
836
+ - `over:` : name of [defined window](#define-window)
837
+
838
+ ```ruby
839
+ User.create!(name: "Alice", number: 100) #=> id: 1
840
+ User.create!(name: "Randy", number: 100) #=> id: 2
841
+ User.create!(name: "Bob", number: 300) #=> id: 3
842
+
843
+ User
844
+ .define_window(:number_window).partition_by(:number, order_by: { id: :desc })
845
+ .select(:id, :name)
846
+ .select_window(:row_number, over: :number_window, as: :row_id)
847
+ .select_window(:first_value, :name, over: :number_window, as: :first_value_name)
848
+ #=> [
849
+ # { id: 1, name: "Alice", row_id: 2, first_value_name: "Randy" }
850
+ # { id: 2, name: "Randy", row_id: 1, first_value_name: "Randy" }
851
+ # { id: 3, name: "Bob", row_id: 1, first_value_name: "Bob" }
852
+ # ]
853
+ #
854
+
855
+ ```
856
+
857
+ Query Output
858
+ ```sql
859
+ SELECT "users"."id",
860
+ "users"."name",
861
+ (ROW_NUMBER() OVER number_window) AS "row_id",
862
+ (FIRST_VALUE(name) OVER number_window) AS "first_value_name"
863
+ FROM "users"
864
+ WINDOW number_window AS (PARTITION BY number ORDER BY id DESC)
865
+ ```
866
+
790
867
  ## License
791
868
 
792
869
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -1,27 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_record_extended/query_methods/window"
3
4
  require "active_record_extended/query_methods/unionize"
4
5
  require "active_record_extended/query_methods/json"
5
6
 
6
7
  module ActiveRecordExtended
7
8
  module RelationPatch
8
9
  module QueryDelegation
9
- delegate :with, :foster_select, to: :all
10
+ delegate :with, :define_window, :select_window, :foster_select, to: :all
10
11
  delegate(*::ActiveRecordExtended::QueryMethods::Unionize::UNIONIZE_METHODS, to: :all)
11
12
  delegate(*::ActiveRecordExtended::QueryMethods::Json::JSON_QUERY_METHODS, to: :all)
12
13
  end
13
14
 
14
15
  module Merger
15
16
  def normal_values
16
- super + [:with, :union]
17
+ super + [:with, :union, :define_window]
17
18
  end
18
19
  end
19
20
 
20
21
  module ArelBuildPatch
21
22
  def build_arel(*aliases)
22
23
  super.tap do |arel|
23
- build_unions(arel) if union_values?
24
- build_with(arel) if with_values?
24
+ build_windows(arel) if window_values?
25
+ build_unions(arel) if union_values?
26
+ build_with(arel) if with_values?
25
27
  end
26
28
  end
27
29
  end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordExtended
4
+ module QueryMethods
5
+ module Window
6
+ class DefineWindowChain
7
+ include ::ActiveRecordExtended::Utilities::Support
8
+ include ::ActiveRecordExtended::Utilities::OrderBy
9
+
10
+ def initialize(scope, window_name)
11
+ @scope = scope
12
+ @window_name = window_name
13
+ end
14
+
15
+ def partition_by(*partitions, order_by: nil)
16
+ @scope.window_values! << {
17
+ window_name: to_arel_sql(@window_name),
18
+ partition_by: flatten_to_sql(partitions),
19
+ order_by: order_by_expression(order_by),
20
+ }
21
+
22
+ @scope
23
+ end
24
+ end
25
+
26
+ class WindowSelectBuilder
27
+ include ::ActiveRecordExtended::Utilities::Support
28
+
29
+ def initialize(window_function, args, window_name)
30
+ @window_function = window_function
31
+ @win_args = to_sql_array(args)
32
+ @over = to_arel_sql(window_name)
33
+ end
34
+
35
+ def build_select(alias_name = nil)
36
+ window_arel = generate_named_function(@window_function, *@win_args).over(@over)
37
+
38
+ if alias_name.nil?
39
+ window_arel
40
+ else
41
+ nested_alias_escape(window_arel, alias_name)
42
+ end
43
+ end
44
+ end
45
+
46
+ def window_values
47
+ @values.fetch(:window, [])
48
+ end
49
+
50
+ def window_values!
51
+ @values[:window] ||= []
52
+ end
53
+
54
+ def window_values?
55
+ !window_values.empty?
56
+ end
57
+
58
+ def window_values=(*values)
59
+ @values[:window] = values.flatten(1)
60
+ end
61
+
62
+ def define_window(name)
63
+ spawn.define_window!(name)
64
+ end
65
+
66
+ def define_window!(name)
67
+ DefineWindowChain.new(self, name)
68
+ end
69
+
70
+ def select_window(window_function, *args, over:, as: nil)
71
+ spawn.select_window!(window_function, args, over: over, as: as)
72
+ end
73
+
74
+ def select_window!(window_function, *args, over:, as: nil)
75
+ args.flatten!
76
+ args.compact!
77
+
78
+ select_statement = WindowSelectBuilder.new(window_function, args, over).build_select(as)
79
+ _select!(select_statement)
80
+ end
81
+
82
+ def build_windows(arel)
83
+ window_values.each do |window_value|
84
+ window = arel.window(window_value[:window_name]).partition(window_value[:partition_by])
85
+ window.order(window_value[:order_by]) if window_value[:order_by]
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+
92
+ ActiveRecord::Relation.prepend(ActiveRecordExtended::QueryMethods::Window)
@@ -30,7 +30,7 @@ module ActiveRecordExtended
30
30
  # Applies aliases to the given query
31
31
  # Ex: `SELECT * FROM users` => `(SELECT * FROM users) AS "members"`
32
32
  def nested_alias_escape(query, alias_name)
33
- sql_query = Arel::Nodes::Grouping.new(to_arel_sql(query))
33
+ sql_query = generate_grouping(query)
34
34
  Arel::Nodes::As.new(sql_query, to_arel_sql(double_quote(alias_name)))
35
35
  end
36
36
 
@@ -160,13 +160,23 @@ module ActiveRecordExtended
160
160
 
161
161
  def group_when_needed(arel_or_rel_query)
162
162
  return arel_or_rel_query unless needs_to_be_grouped?(arel_or_rel_query)
163
- Arel::Nodes::Grouping.new(to_arel_sql(arel_or_rel_query))
163
+ generate_grouping(arel_or_rel_query)
164
164
  end
165
165
 
166
166
  def needs_to_be_grouped?(query)
167
167
  query.respond_to?(:to_sql) || (query.is_a?(String) && /^SELECT.+/i.match?(query))
168
168
  end
169
169
 
170
+ def generate_grouping(expr)
171
+ ::Arel::Nodes::Grouping.new(to_arel_sql(expr))
172
+ end
173
+
174
+ def generate_named_function(function_name, *args)
175
+ args.map!(&method(:to_arel_sql))
176
+ function_name = function_name.to_s.upcase
177
+ ::Arel::Nodes::NamedFunction.new(to_arel_sql(function_name), args)
178
+ end
179
+
170
180
  def key_generator
171
181
  A_TO_Z_KEYS.sample
172
182
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveRecordExtended
4
- VERSION = "1.2.0"
4
+ VERSION = "1.3.0"
5
5
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "Active Record Window Function Query Methods" do
6
+ let!(:user_one) { User.create! }
7
+ let!(:user_two) { User.create! }
8
+
9
+ let!(:tag_one) { Tag.create!(user: user_one, tag_number: 1) }
10
+ let!(:tag_two) { Tag.create!(user: user_two, tag_number: 2) }
11
+
12
+ let!(:tag_three) { Tag.create!(user: user_one, tag_number: 3) }
13
+ let!(:tag_four) { Tag.create!(user: user_two, tag_number: 4) }
14
+
15
+ let(:tag_group1) { [tag_one, tag_three] }
16
+ let(:tag_group2) { [tag_two, tag_four] }
17
+
18
+ describe ".window_select" do
19
+ context "when using ROW_NUMBER() ordered in asc" do
20
+ let(:base_query) do
21
+ Tag.define_window(:w).partition_by(:user_id, order_by: :tag_number).select(:id)
22
+ end
23
+
24
+ it "should return tag_one with r_id 1 and tag_three with r_id 2" do
25
+ results = base_query.select_window(:row_number, over: :w, as: :r_id).group_by(&:id)
26
+ tag_group1.each.with_index { |tag, idx| expect(results[tag.id].first.r_id).to eq(idx + 1) }
27
+ end
28
+
29
+ it "should return tag_two with r_id 1 and tag_four with r_id 2" do
30
+ results = base_query.select_window(:row_number, over: :w, as: :r_id).group_by(&:id)
31
+ tag_group2.each.with_index { |tag, idx| expect(results[tag.id].first.r_id).to eq(idx + 1) }
32
+ end
33
+ end
34
+
35
+ context "when using ROW_NUMBER() ordered in desc" do
36
+ let(:base_query) do
37
+ Tag.define_window(:w).partition_by(:user_id, order_by: { tag_number: :desc }).select(:id)
38
+ end
39
+
40
+ it "should return tag_one with r_id 2 and tag_three with r_id 1" do
41
+ results = base_query.select_window(:row_number, over: :w, as: :r_id).group_by(&:id)
42
+ tag_group1.reverse_each.with_index { |tag, idx| expect(results[tag.id].first.r_id).to eq(idx + 1) }
43
+ end
44
+
45
+ it "should return tag_two with r_id 2 and tag_four with r_id 1" do
46
+ results = base_query.select_window(:row_number, over: :w, as: :r_id).group_by(&:id)
47
+ tag_group2.reverse_each.with_index { |tag, idx| expect(results[tag.id].first.r_id).to eq(idx + 1) }
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "Active Record WINDOW Query inspection" do
6
+ describe "#define_window" do
7
+ context "when there is a single defined window" do
8
+ it "should only contain a single defined window statement at the bottom" do
9
+ query = Tag.define_window(:w_test).partition_by(:user_id).to_sql
10
+ expect(query).to eq('SELECT "tags".* FROM "tags" WINDOW w_test AS (PARTITION BY user_id)')
11
+ end
12
+
13
+ it "should return a single defined window with a defined ORDER BY" do
14
+ query = Tag.define_window(:w_test).partition_by(:user_id, order_by: { tags: { user_id: :desc } }).to_sql
15
+ expect(query).to end_with("WINDOW w_test AS (PARTITION BY user_id ORDER BY tags.user_id DESC)")
16
+ end
17
+
18
+ it "should place the window function below the WHERE and GROUP BY statements" do
19
+ query = Tag.define_window(:w_test).partition_by(:user_id).where(id: 1).group(:user_id).to_sql
20
+ expect(query).to eq('SELECT "tags".* FROM "tags" WHERE "tags"."id" = 1 GROUP BY "tags"."user_id" WINDOW w_test AS (PARTITION BY user_id)')
21
+ end
22
+ end
23
+
24
+ context "when there are multiple defined windows" do
25
+ it "should only contain a single defined window statement at the bottom" do
26
+ query =
27
+ Tag
28
+ .define_window(:test).partition_by(:user_id)
29
+ .define_window(:other_window).partition_by(:id)
30
+ .to_sql
31
+
32
+ expect(query).to end_with("WINDOW test AS (PARTITION BY user_id), other_window AS (PARTITION BY id)")
33
+ end
34
+
35
+ it "should contain each windows order by statements" do
36
+ query =
37
+ Tag
38
+ .define_window(:test).partition_by(:user_id, order_by: :id)
39
+ .define_window(:other_window).partition_by(:id, order_by: { tags: :user_id })
40
+ .to_sql
41
+
42
+ expect(query).to end_with("WINDOW test AS (PARTITION BY user_id ORDER BY id), other_window AS (PARTITION BY id ORDER BY tags.user_id ASC)")
43
+ end
44
+ end
45
+ end
46
+
47
+ describe "#window_select" do
48
+ let(:query_base) { Tag.define_window(:w).partition_by(:user_id, order_by: :id) }
49
+ let(:expected_end) { "WINDOW w AS (PARTITION BY user_id ORDER BY id)" }
50
+
51
+ context "when using no argument window methods" do
52
+ [:row_to_number, :rank, :dense_rank, :percent_rank, :cume_dist].each do |window_function|
53
+ context "#{window_function.to_s.upcase}()" do
54
+ let(:expected_function) { "#{window_function.to_s.upcase}()" }
55
+ let(:query) do
56
+ query_base.select_window(window_function, over: :w, as: :window_response).to_sql
57
+ end
58
+
59
+ it "appends the function to the select query" do
60
+ expected_start = "SELECT (#{expected_function} OVER w) AS \"window_response\""
61
+ expect(query).to start_with(expected_start).and(end_with(expected_end))
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ context "when using an argument based window method" do
68
+ let(:argument_list) { ["a", 1, :sauce] }
69
+
70
+ { ntile: 1, lag: 2, lead: 3, first_value: 1, last_value: 1, nth_value: 2 }.each_pair do |window_function, arg_count|
71
+ context "#{window_function.to_s.upcase}/#{arg_count}" do
72
+ let(:arguments) { argument_list.first(arg_count) }
73
+ let(:expected_function) { "#{window_function.to_s.upcase}(#{arguments.join(', ')})" }
74
+ let(:query) do
75
+ query_base.select_window(window_function, *arguments, over: :w, as: :window_response).to_sql
76
+ end
77
+
78
+ it "appends the function to the select query" do
79
+ expected_start = "SELECT (#{expected_function} OVER w) AS \"window_response\""
80
+ expect(query).to start_with(expected_start).and(end_with(expected_end))
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_record_extended
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - George Protacio-Karaszi
@@ -10,7 +10,7 @@ authors:
10
10
  autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
- date: 2019-08-11 00:00:00.000000000 Z
13
+ date: 2019-09-04 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: activerecord
@@ -166,6 +166,7 @@ files:
166
166
  - lib/active_record_extended/query_methods/select.rb
167
167
  - lib/active_record_extended/query_methods/unionize.rb
168
168
  - lib/active_record_extended/query_methods/where_chain.rb
169
+ - lib/active_record_extended/query_methods/window.rb
169
170
  - lib/active_record_extended/query_methods/with_cte.rb
170
171
  - lib/active_record_extended/utilities/order_by.rb
171
172
  - lib/active_record_extended/utilities/support.rb
@@ -179,6 +180,7 @@ files:
179
180
  - spec/query_methods/json_spec.rb
180
181
  - spec/query_methods/select_spec.rb
181
182
  - spec/query_methods/unionize_spec.rb
183
+ - spec/query_methods/window_spec.rb
182
184
  - spec/query_methods/with_cte_spec.rb
183
185
  - spec/spec_helper.rb
184
186
  - spec/sql_inspections/any_of_sql_spec.rb
@@ -189,6 +191,7 @@ files:
189
191
  - spec/sql_inspections/either_sql_spec.rb
190
192
  - spec/sql_inspections/json_sql_spec.rb
191
193
  - spec/sql_inspections/unionize_sql_spec.rb
194
+ - spec/sql_inspections/window_sql_spec.rb
192
195
  - spec/sql_inspections/with_cte_sql_spec.rb
193
196
  - spec/support/database_cleaner.rb
194
197
  - spec/support/models.rb
@@ -226,6 +229,7 @@ test_files:
226
229
  - spec/query_methods/json_spec.rb
227
230
  - spec/query_methods/select_spec.rb
228
231
  - spec/query_methods/unionize_spec.rb
232
+ - spec/query_methods/window_spec.rb
229
233
  - spec/query_methods/with_cte_spec.rb
230
234
  - spec/spec_helper.rb
231
235
  - spec/sql_inspections/any_of_sql_spec.rb
@@ -236,6 +240,7 @@ test_files:
236
240
  - spec/sql_inspections/either_sql_spec.rb
237
241
  - spec/sql_inspections/json_sql_spec.rb
238
242
  - spec/sql_inspections/unionize_sql_spec.rb
243
+ - spec/sql_inspections/window_sql_spec.rb
239
244
  - spec/sql_inspections/with_cte_sql_spec.rb
240
245
  - spec/support/database_cleaner.rb
241
246
  - spec/support/models.rb