active_record_extended 1.2.0 → 1.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 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