diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d3100aca..bd6539a2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,8 @@ #### Added - [#855](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/pull/855) Add helpers to create/change/drop a schema. -- [#857](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/pull/857) Included WAITFOR as read query type. +- [#857](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/pull/857) Included WAITFOR as read query type. +- [#865](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/pull/865) Implemented optimizer hints. ## v6.0.1 diff --git a/lib/active_record/connection_adapters/sqlserver_adapter.rb b/lib/active_record/connection_adapters/sqlserver_adapter.rb index 867a66001..c30909aee 100644 --- a/lib/active_record/connection_adapters/sqlserver_adapter.rb +++ b/lib/active_record/connection_adapters/sqlserver_adapter.rb @@ -152,6 +152,10 @@ def supports_savepoints? true end + def supports_optimizer_hints? + true + end + def supports_lazy_transactions? true end diff --git a/lib/arel/visitors/sqlserver.rb b/lib/arel/visitors/sqlserver.rb index 680ee8be5..2642c6950 100644 --- a/lib/arel/visitors/sqlserver.rb +++ b/lib/arel/visitors/sqlserver.rb @@ -80,6 +80,16 @@ def visit_Arel_Nodes_SelectStatement o, collector @select_statement = nil end + def visit_Arel_Nodes_SelectCore(o, collector) + collector = super + maybe_visit o.optimizer_hints, collector + end + + def visit_Arel_Nodes_OptimizerHints(o, collector) + hints = o.expr.map { |v| sanitize_as_option_clause(v) }.join(", ") + collector << "OPTION (#{hints})" + end + def visit_Arel_Table o, collector # Apparently, o.engine.connection can actually be a different adapter # than sqlserver. Can be removed if fixed in ActiveRecord. See: @@ -144,6 +154,10 @@ def collect_in_clause(left, right, collector) super end + def collect_optimizer_hints(o, collector) + collector + end + # SQLServer ToSql/Visitor (Additions) def visit_Arel_Nodes_SelectStatement_SQLServer_Lock collector, options = {} @@ -247,6 +261,10 @@ def remove_invalid_ordering_from_select_statement(node) node.orders = [] unless node.offset || node.limit end + + def sanitize_as_option_clause(value) + value.gsub(%r{OPTION \s* \( (.+) \)}xi, "\\1") + end end end end diff --git a/test/cases/coerced_tests.rb b/test/cases/coerced_tests.rb index 3e8181a3a..bc221e98d 100644 --- a/test/cases/coerced_tests.rb +++ b/test/cases/coerced_tests.rb @@ -982,6 +982,21 @@ def test_reverse_arel_assoc_order_with_function_coerced end end +module ActiveRecord + class RelationTest < ActiveRecord::TestCase + # Skipping this test. SQL Server doesn't support optimizer hint as comments + coerce_tests! :test_relation_with_optimizer_hints_filters_sql_comment_delimiters + + coerce_tests! :test_does_not_duplicate_optimizer_hints_on_merge + def test_does_not_duplicate_optimizer_hints_on_merge_coerced + escaped_table = Post.connection.quote_table_name("posts") + expected = "SELECT #{escaped_table}.* FROM #{escaped_table} OPTION (OMGHINT)" + query = Post.optimizer_hints("OMGHINT").merge(Post.optimizer_hints("OMGHINT")).to_sql + assert_equal expected, query + end + end +end + require "models/post" class SanitizeTest < ActiveRecord::TestCase # Use nvarchar string (N'') in assert diff --git a/test/cases/optimizer_hints_test_sqlserver.rb b/test/cases/optimizer_hints_test_sqlserver.rb new file mode 100644 index 000000000..ba0438c46 --- /dev/null +++ b/test/cases/optimizer_hints_test_sqlserver.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "cases/helper_sqlserver" +require "models/company" + +class OptimizerHitsTestSQLServer < ActiveRecord::TestCase + fixtures :companies + + it "apply optimizations" do + assert_sql(%r{\ASELECT .+ FROM .+ OPTION \(HASH GROUP\)\z}) do + companies = Company.optimizer_hints("HASH GROUP") + companies = companies.distinct.select("firm_id") + assert_includes companies.explain, "| Hash Match | Aggregate |" + end + + assert_sql(%r{\ASELECT .+ FROM .+ OPTION \(ORDER GROUP\)\z}) do + companies = Company.optimizer_hints("ORDER GROUP") + companies = companies.distinct.select("firm_id") + assert_includes companies.explain, "| Stream Aggregate | Aggregate |" + end + end + + it "apply multiple optimizations" do + assert_sql(%r{\ASELECT .+ FROM .+ OPTION \(HASH GROUP, FAST 1\)\z}) do + companies = Company.optimizer_hints("HASH GROUP", "FAST 1") + companies = companies.distinct.select("firm_id") + assert_includes companies.explain, "| Hash Match | Flow Distinct |" + end + end + + it "support subqueries" do + assert_sql(%r{.*'SELECT COUNT\(count_column\) FROM \(SELECT .*\) subquery_for_count OPTION \(MAXDOP 2\)'.*}) do + companies = Company.optimizer_hints("MAXDOP 2") + companies = companies.select(:id).where(firm_id: [0, 1]).limit(3) + assert_equal 3, companies.count + end + end + + it "sanitize values" do + assert_sql(%r{\ASELECT .+ FROM .+ OPTION \(HASH GROUP\)\z}) do + companies = Company.optimizer_hints("OPTION (HASH GROUP)") + companies = companies.distinct.select("firm_id") + companies.to_a + end + + assert_sql(%r{\ASELECT .+ FROM .+ OPTION \(HASH GROUP\)\z}) do + companies = Company.optimizer_hints("OPTION(HASH GROUP)") + companies = companies.distinct.select("firm_id") + companies.to_a + end + + assert_sql(%r{\ASELECT .+ FROM .+ OPTION \(TABLE HINT \(\[companies\], INDEX\(1\)\)\)\z}) do + companies = Company.optimizer_hints("OPTION(TABLE HINT ([companies], INDEX(1)))") + companies = companies.distinct.select("firm_id") + companies.to_a + end + + assert_sql(%r{\ASELECT .+ FROM .+ OPTION \(HASH GROUP\)\z}) do + companies = Company.optimizer_hints("Option(HASH GROUP)") + companies = companies.distinct.select("firm_id") + companies.to_a + end + end + + it "skip optimization after unscope" do + assert_sql("SELECT DISTINCT [companies].[firm_id] FROM [companies]") do + companies = Company.optimizer_hints("HASH GROUP") + companies = companies.distinct.select("firm_id") + companies.unscope(:optimizer_hints).load + end + end +end