diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index be95196bd..0a51771c1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,6 +2,15 @@ name: build on: [push, pull_request] jobs: build: + services: + mssql: + image: mcr.microsoft.com/mssql/server:2022-latest + env: + SA_PASSWORD: yourStrongPassword123 + ACCEPT_EULA: Y + ports: + - 1433:1433 + strategy: fail-fast: false matrix: @@ -18,6 +27,8 @@ jobs: env: BUNDLE_GEMFILE: ${{ matrix.gemfile }} steps: + - run: sudo apt install freetds-dev freetds-bin + - run: echo "CREATE DATABASE groupdate_test" | tsql -H localhost -p 1433 -U sa -P yourStrongPassword123 - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: diff --git a/Gemfile b/Gemfile index 36738b718..57f921caf 100644 --- a/Gemfile +++ b/Gemfile @@ -10,3 +10,5 @@ gem "mysql2" gem "trilogy" gem "sqlite3", "< 2" gem "ruby-prof", require: false +gem "activerecord-sqlserver-adapter" +gem "tiny_tds" diff --git a/Rakefile b/Rakefile index ac691f9b2..2c478f2ea 100644 --- a/Rakefile +++ b/Rakefile @@ -1,7 +1,7 @@ require "bundler/gem_tasks" require "rake/testtask" -ADAPTERS = %w(postgresql mysql trilogy sqlite enumerable redshift) +ADAPTERS = %w(postgresql mysql trilogy sqlite enumerable redshift sqlserver) ADAPTERS.each do |adapter| namespace :test do diff --git a/gemfiles/activerecord61.gemfile b/gemfiles/activerecord61.gemfile index 86765c598..fd16d71cb 100644 --- a/gemfiles/activerecord61.gemfile +++ b/gemfiles/activerecord61.gemfile @@ -9,3 +9,5 @@ gem "pg" gem "mysql2" gem "activerecord-trilogy-adapter" gem "sqlite3", "< 2" +gem "activerecord-sqlserver-adapter" +gem "tiny_tds" \ No newline at end of file diff --git a/gemfiles/activerecord70.gemfile b/gemfiles/activerecord70.gemfile index e7faf9753..4e17db4ae 100644 --- a/gemfiles/activerecord70.gemfile +++ b/gemfiles/activerecord70.gemfile @@ -9,3 +9,5 @@ gem "pg" gem "mysql2" gem "activerecord-trilogy-adapter" gem "sqlite3", "< 2" +gem "activerecord-sqlserver-adapter" +gem "tiny_tds" \ No newline at end of file diff --git a/lib/groupdate.rb b/lib/groupdate.rb index d4f83e69d..788d8dee7 100644 --- a/lib/groupdate.rb +++ b/lib/groupdate.rb @@ -13,6 +13,7 @@ require_relative "groupdate/adapters/mysql_adapter" require_relative "groupdate/adapters/postgresql_adapter" require_relative "groupdate/adapters/sqlite_adapter" +require_relative "groupdate/adapters/sqlserver_adapter" module Groupdate class Error < RuntimeError; end @@ -46,6 +47,7 @@ def self.register_adapter(name, adapter) Groupdate.register_adapter ["Mysql2", "Mysql2Spatial", "Mysql2Rgeo", "Trilogy"], Groupdate::Adapters::MySQLAdapter Groupdate.register_adapter ["PostgreSQL", "PostGIS", "Redshift"], Groupdate::Adapters::PostgreSQLAdapter Groupdate.register_adapter "SQLite", Groupdate::Adapters::SQLiteAdapter +Groupdate.register_adapter "SQLServer", Groupdate::Adapters::SqlServerAdapter require_relative "groupdate/enumerable" diff --git a/lib/groupdate/adapters/sqlserver_adapter.rb b/lib/groupdate/adapters/sqlserver_adapter.rb new file mode 100644 index 000000000..e72e0531a --- /dev/null +++ b/lib/groupdate/adapters/sqlserver_adapter.rb @@ -0,0 +1,48 @@ +module Groupdate + module Adapters + class SqlServerAdapter < BaseAdapter + def group_clause + raise Groupdate::Error, "Time zones not supported for SQLServer" unless @time_zone.utc_offset.zero? + raise Groupdate::Error, "day_start not supported for SQLServer" unless day_start.zero? + + query = + case period + when :minute_of_hour + ["CAST(DATEPART(MINUTE, #{column}) AS INT)"] + when :hour_of_day + ["CAST(DATEPART(HOUR, #{column}) AS INT)"] + when :day_of_week + ["CAST((DATEPART(WEEKDAY, #{column}) + @@DATEFIRST - 1) %% 7 AS INT)"] + when :day_of_month + ["CAST(DATEPART(DAY, #{column}) AS INT)"] + when :day_of_year + ["CAST(DATEPART(DAYOFYEAR, #{column}) AS INT)"] + when :month_of_year + ["CAST(DATEPART(MONTH, #{column}) AS INT)"] + when :week + ["CAST(DATEADD(DAY, -((DATEPART(WEEKDAY, #{column}) - 1 + @@DATEFIRST - ? + 7) % 7), #{column}) AS DATE)", week_start + 1] + when :quarter + raise Groupdate::Error, "Quarter not supported for SQLServer" + when :day + ["CAST(DATETRUNC(DAY, #{column}) AS DATE)"] + when :month + ["CAST(DATETRUNC(MONTH, #{column}) AS DATE)"] + when :year + ["CAST(DATETRUNC(YEAR, #{column}) AS DATE)"] + when :custom + ["DATEADD(SECOND, CAST(LEFT(FLOOR(DATEDIFF(SECOND, '1970-01-01', #{column}) / ?) * ?, 10) AS INT), '1970-01-01')", n_seconds, n_seconds] + when :second + ["DATETRUNC(SECOND, #{column})"] + when :minute + ["DATETRUNC(MINUTE, #{column})"] + when :hour + ["DATETRUNC(HOUR, #{column})"] + else + raise Groupdate::Error, "'#{period}' not supported for SQL Server" + end + + @relation.send(:sanitize_sql_array, query) + end + end + end +end diff --git a/test/adapters/sqlserver.rb b/test/adapters/sqlserver.rb new file mode 100644 index 000000000..f2afec2b4 --- /dev/null +++ b/test/adapters/sqlserver.rb @@ -0,0 +1,8 @@ +ActiveRecord::Base.establish_connection( + adapter: "sqlserver", + database: "groupdate_test", + host: "localhost", + port: 1433, + username: "SA", + password: "yourStrongPassword123" +) diff --git a/test/column_test.rb b/test/column_test.rb index 5925275af..a079f002e 100644 --- a/test/column_test.rb +++ b/test/column_test.rb @@ -24,7 +24,13 @@ def test_string_function end def test_string_function_arel - assert_empty User.joins(:posts).group_by_day(Arel.sql(now_function)).count + if sql_server? + assert_raises(ActiveRecord::StatementInvalid) do + User.joins(:posts).group_by_day(Arel.sql(now_function)).count + end + else + assert_empty User.joins(:posts).group_by_day(Arel.sql(now_function)).count + end end def test_symbol_with_join @@ -89,6 +95,8 @@ def now_function "datetime('now')" elsif redshift? "GETDATE()" + elsif sql_server? + "GETDATE()" else "NOW()" end diff --git a/test/day_start_test.rb b/test/day_start_test.rb index 75f2ca789..751aaae73 100644 --- a/test/day_start_test.rb +++ b/test/day_start_test.rb @@ -174,14 +174,14 @@ def test_decimal_start_of_day end def test_decimal_hour_of_day - skip if sqlite? + skip if sqlite? || sql_server? assert_result :hour_of_day, 23, "2013-05-04 02:29:59", false, day_start: 2.5 end # invalid def test_too_small - skip "call_method expects different error message" if sqlite? + skip "call_method expects different error message" if sqlite? || sql_server? error = assert_raises(ArgumentError) do call_method(:day, :created_at, day_start: -1) @@ -190,7 +190,7 @@ def test_too_small end def test_too_large - skip "call_method expects different error message" if sqlite? + skip "call_method expects different error message" if sqlite? || sql_server? error = assert_raises(ArgumentError) do call_method(:day, :created_at, day_start: 24) @@ -199,7 +199,7 @@ def test_too_large end def test_bad_method - skip "call_method expects different error message" if sqlite? + skip "call_method expects different error message" if sqlite? || sql_server? error = assert_raises(ArgumentError) do call_method(:minute, :created_at, day_start: 24) diff --git a/test/test_helper.rb b/test/test_helper.rb index 6153bea37..7cc3cf55c 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -62,6 +62,10 @@ def redshift? ENV["ADAPTER"] == "redshift" end + def sql_server? + ENV["ADAPTER"] == "sqlserver" + end + def create_user(created_at, score = 1) created_at = created_at.utc.to_s if created_at.is_a?(Time) @@ -105,6 +109,10 @@ def call_method(method, field, options) error = assert_raises(Groupdate::Error) { User.group_by_period(method, field, **options).count } assert_includes error.message, "not supported for SQLite" skip + elsif sql_server? && (method == :quarter || (options[:time_zone] && options[:time_zone] != "bad") || options[:day_start] || (Time.zone && options[:time_zone] != false)) + error = assert_raises(Groupdate::Error) { User.group_by_period(method, field, **options).count } + assert_includes error.message, "not supported for SQLServer" + skip else User.group_by_period(method, field, **options).count end