diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c0a92b65..a774d2d6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - [#1318](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/pull/1318) Reverse order of values when upserting - [#1321](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/pull/1321) Fix SQL statement to calculate `updated_at` when upserting +- [1322](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/pull/1322) When using `FETCH` without ordering try to order using projection rather than the primary key. ## v8.0.5 diff --git a/lib/arel/visitors/sqlserver.rb b/lib/arel/visitors/sqlserver.rb index 5890b7fc3..93e25875c 100644 --- a/lib/arel/visitors/sqlserver.rb +++ b/lib/arel/visitors/sqlserver.rb @@ -9,6 +9,8 @@ class SQLServer < Arel::Visitors::ToSql FETCH0 = " FETCH FIRST (SELECT 0) " ROWS_ONLY = " ROWS ONLY" + ONE_AS_ONE = ActiveRecord::FinderMethods::ONE_AS_ONE + private # SQLServer ToSql/Visitor (Overrides) @@ -300,24 +302,56 @@ def select_statement_lock? @select_statement && @select_statement.lock end + # FETCH cannot be used without an order. If an order is not given then try to use the projections for the ordering. + # If no suitable projection are present then fallback to using the primary key of the table. def make_Fetch_Possible_And_Deterministic(o) return if o.limit.nil? && o.offset.nil? return if o.orders.any? - t = table_From_Statement o - pk = primary_Key_From_Table t - return unless pk + if (projection = projection_to_order_by_for_fetch(o)) + o.orders = [projection.asc] + else + pk = primary_Key_From_Table(table_From_Statement(o)) + o.orders = [pk.asc] if pk + end + end + + # Find the first projection or part of projection that can be used for ordering. Cannot use + # projections with '*' or '1 AS one' in them. + def projection_to_order_by_for_fetch(o) + o.cores.first.projections.each do |projection| + case projection + when Arel::Attributes::Attribute + return projection unless projection.name.include?("*") + when Arel::Nodes::SqlLiteral + projection.split(",").each do |p| + next if p.match?(/#{Regexp.escape(ONE_AS_ONE)}/i) || p.include?("*") + + return Arel::Nodes::SqlLiteral.new(remove_last_AS_from_projection(p)) + end + end + end - # Prefer deterministic vs a simple `(SELECT NULL)` expr. - o.orders = [pk.asc] + nil + end + + # Remove last AS from projection. Example projections: + # - 'name' + # - 'name AS first_name' + # - 'AVG(accounts.credit_limit AS DECIMAL) AS avg_credit_limit)' + def remove_last_AS_from_projection(projection) + parts = projection.split(/\sAS\s/i) + parts.pop if parts.length > 1 + parts.join(" AS ") end def distinct_One_As_One_Is_So_Not_Fetch(o) core = o.cores.first distinct = Nodes::Distinct === core.set_quantifier - oneasone = core.projections.all? { |x| x == ActiveRecord::FinderMethods::ONE_AS_ONE } - limitone = [nil, 0, 1].include? node_value(o.limit) - if distinct && oneasone && limitone && !o.offset + one_as_one = core.projections.all? { |x| x == ONE_AS_ONE } + limit_one = [nil, 0, 1].include? node_value(o.limit) + + if distinct && one_as_one && limit_one && !o.offset core.projections = [Arel.sql("TOP(1) 1 AS [one]")] o.limit = nil end diff --git a/test/cases/order_test_sqlserver.rb b/test/cases/order_test_sqlserver.rb index 82f43d539..bd59e586d 100644 --- a/test/cases/order_test_sqlserver.rb +++ b/test/cases/order_test_sqlserver.rb @@ -150,4 +150,59 @@ class OrderTestSQLServer < ActiveRecord::TestCase sql = Post.order(:id).order("posts.id ASC").to_sql assert_equal "SELECT [posts].* FROM [posts] ORDER BY [posts].[id] ASC, posts.id ASC", sql end + + describe "simple query containing limit" do + it "order by primary key if no projections" do + sql = Post.limit(5).to_sql + + assert_equal "SELECT [posts].* FROM [posts] ORDER BY [posts].[id] ASC OFFSET 0 ROWS FETCH NEXT 5 ROWS ONLY", sql + end + + it "use order provided" do + sql = Post.select(:legacy_comments_count).order(:tags_count).limit(5).to_sql + + assert_equal "SELECT [posts].[legacy_comments_count] FROM [posts] ORDER BY [posts].[tags_count] ASC OFFSET 0 ROWS FETCH NEXT 5 ROWS ONLY", sql + end + + it "order by first projection if no order provided" do + sql = Post.select(:legacy_comments_count).limit(5).to_sql + + assert_equal "SELECT [posts].[legacy_comments_count] FROM [posts] ORDER BY [posts].[legacy_comments_count] ASC OFFSET 0 ROWS FETCH NEXT 5 ROWS ONLY", sql + end + + it "order by first projection (when multiple projections) if no order provided" do + sql = Post.select(:legacy_comments_count, :tags_count).limit(5).to_sql + + assert_equal "SELECT [posts].[legacy_comments_count], [posts].[tags_count] FROM [posts] ORDER BY [posts].[legacy_comments_count] ASC OFFSET 0 ROWS FETCH NEXT 5 ROWS ONLY", sql + end + end + + describe "query containing FROM and limit" do + it "uses the provided orderings" do + sql = "SELECT sum(legacy_comments_count), count(*), min(legacy_comments_count) FROM (SELECT [posts].[legacy_comments_count] FROM [posts] ORDER BY [posts].[legacy_comments_count] DESC OFFSET 0 ROWS FETCH NEXT @0 ROWS ONLY) subquery ORDER BY sum(legacy_comments_count) ASC OFFSET 0 ROWS FETCH NEXT @1 ROWS ONLY" + + assert_queries_match(/#{Regexp.escape(sql)}/) do + result = Post.from(Post.order(legacy_comments_count: :desc).limit(5).select(:legacy_comments_count)).pick(Arel.sql("sum(legacy_comments_count), count(*), min(legacy_comments_count)")) + assert_equal result, [11, 5, 1] + end + end + + it "in the subquery the first projection is used for ordering if none provided" do + sql = "SELECT sum(legacy_comments_count), count(*), min(legacy_comments_count) FROM (SELECT [posts].[legacy_comments_count], [posts].[tags_count] FROM [posts] ORDER BY [posts].[legacy_comments_count] ASC OFFSET 0 ROWS FETCH NEXT @0 ROWS ONLY) subquery ORDER BY sum(legacy_comments_count) ASC OFFSET 0 ROWS FETCH NEXT @1 ROWS ONLY" + + assert_queries_match(/#{Regexp.escape(sql)}/) do + result = Post.from(Post.limit(5).select(:legacy_comments_count, :tags_count)).pick(Arel.sql("sum(legacy_comments_count), count(*), min(legacy_comments_count)")) + assert_equal result, [0, 5, 0] + end + end + + it "in the subquery the primary key is used for ordering if none provided" do + sql = "SELECT sum(legacy_comments_count), count(*), min(legacy_comments_count) FROM (SELECT [posts].* FROM [posts] ORDER BY [posts].[id] ASC OFFSET 0 ROWS FETCH NEXT @0 ROWS ONLY) subquery ORDER BY sum(legacy_comments_count) ASC OFFSET 0 ROWS FETCH NEXT @1 ROWS ONLY" + + assert_queries_match(/#{Regexp.escape(sql)}/) do + result = Post.from(Post.limit(5)).pick(Arel.sql("sum(legacy_comments_count), count(*), min(legacy_comments_count)")) + assert_equal result, [10, 5, 0] + end + end + end end