Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

When using FETCH without specific ordering, use projection to order #1322

Draft
wants to merge 6 commits into
base: 8-0-stable
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
50 changes: 42 additions & 8 deletions lib/arel/visitors/sqlserver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions test/cases/order_test_sqlserver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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