activerecord-sqlserver-adapter 6.0.1 → 6.1.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +26 -0
  3. data/CHANGELOG.md +29 -46
  4. data/README.md +32 -3
  5. data/RUNNING_UNIT_TESTS.md +1 -1
  6. data/VERSION +1 -1
  7. data/activerecord-sqlserver-adapter.gemspec +1 -1
  8. data/lib/active_record/connection_adapters/sqlserver/core_ext/attribute_methods.rb +2 -0
  9. data/lib/active_record/connection_adapters/sqlserver/core_ext/calculations.rb +5 -10
  10. data/lib/active_record/connection_adapters/sqlserver/core_ext/explain.rb +9 -2
  11. data/lib/active_record/connection_adapters/sqlserver/core_ext/finder_methods.rb +2 -0
  12. data/lib/active_record/connection_adapters/sqlserver/core_ext/preloader.rb +2 -0
  13. data/lib/active_record/connection_adapters/sqlserver/database_limits.rb +0 -4
  14. data/lib/active_record/connection_adapters/sqlserver/database_statements.rb +28 -16
  15. data/lib/active_record/connection_adapters/sqlserver/quoting.rb +8 -7
  16. data/lib/active_record/connection_adapters/sqlserver/schema_creation.rb +22 -1
  17. data/lib/active_record/connection_adapters/sqlserver/schema_dumper.rb +9 -3
  18. data/lib/active_record/connection_adapters/sqlserver/schema_statements.rb +31 -9
  19. data/lib/active_record/connection_adapters/sqlserver/sql_type_metadata.rb +36 -7
  20. data/lib/active_record/connection_adapters/sqlserver/table_definition.rb +0 -1
  21. data/lib/active_record/connection_adapters/sqlserver/transaction.rb +2 -2
  22. data/lib/active_record/connection_adapters/sqlserver/type.rb +1 -0
  23. data/lib/active_record/connection_adapters/sqlserver/type/date.rb +2 -1
  24. data/lib/active_record/connection_adapters/sqlserver/type/decimal_without_scale.rb +22 -0
  25. data/lib/active_record/connection_adapters/sqlserver/utils.rb +1 -1
  26. data/lib/active_record/connection_adapters/sqlserver_adapter.rb +100 -69
  27. data/lib/active_record/connection_adapters/sqlserver_column.rb +75 -19
  28. data/lib/active_record/sqlserver_base.rb +9 -15
  29. data/lib/active_record/tasks/sqlserver_database_tasks.rb +17 -14
  30. data/lib/arel/visitors/sqlserver.rb +125 -40
  31. data/test/cases/adapter_test_sqlserver.rb +50 -16
  32. data/test/cases/change_column_collation_test_sqlserver.rb +33 -0
  33. data/test/cases/coerced_tests.rb +611 -78
  34. data/test/cases/column_test_sqlserver.rb +9 -2
  35. data/test/cases/disconnected_test_sqlserver.rb +39 -0
  36. data/test/cases/execute_procedure_test_sqlserver.rb +9 -0
  37. data/test/cases/fetch_test_sqlserver.rb +18 -0
  38. data/test/cases/in_clause_test_sqlserver.rb +27 -0
  39. data/test/cases/lateral_test_sqlserver.rb +35 -0
  40. data/test/cases/migration_test_sqlserver.rb +51 -0
  41. data/test/cases/optimizer_hints_test_sqlserver.rb +72 -0
  42. data/test/cases/order_test_sqlserver.rb +7 -0
  43. data/test/cases/primary_keys_test_sqlserver.rb +103 -0
  44. data/test/cases/rake_test_sqlserver.rb +38 -2
  45. data/test/cases/schema_dumper_test_sqlserver.rb +14 -3
  46. data/test/migrations/create_clients_and_change_column_collation.rb +19 -0
  47. data/test/models/sqlserver/composite_pk.rb +9 -0
  48. data/test/models/sqlserver/sst_string_collation.rb +3 -0
  49. data/test/schema/sqlserver_specific_schema.rb +25 -0
  50. data/test/support/marshal_compatibility_fixtures/SQLServer/rails_6_0_topic.dump +0 -0
  51. data/test/support/marshal_compatibility_fixtures/SQLServer/rails_6_0_topic_associations.dump +0 -0
  52. data/test/support/sql_counter_sqlserver.rb +14 -12
  53. metadata +29 -9
  54. data/.travis.yml +0 -23
  55. data/lib/active_record/connection_adapters/sqlserver/core_ext/query_methods.rb +0 -28
@@ -2,31 +2,87 @@
2
2
 
3
3
  module ActiveRecord
4
4
  module ConnectionAdapters
5
- class SQLServerColumn < Column
6
- def initialize(name, default, sql_type_metadata = nil, null = true, default_function = nil, collation: nil, comment: nil, **sqlserver_options)
7
- @sqlserver_options = sqlserver_options
8
- super
9
- end
5
+ module SQLServer
6
+ class Column < ConnectionAdapters::Column
7
+ delegate :is_identity, :is_primary, :table_name, :ordinal_position, to: :sql_type_metadata
10
8
 
11
- def is_identity?
12
- @sqlserver_options[:is_identity]
13
- end
9
+ def initialize(*, is_identity: nil, is_primary: nil, table_name: nil, ordinal_position: nil, **)
10
+ super
11
+ @is_identity = is_identity
12
+ @is_primary = is_primary
13
+ @table_name = table_name
14
+ @ordinal_position = ordinal_position
15
+ end
14
16
 
15
- def is_primary?
16
- @sqlserver_options[:is_primary]
17
- end
17
+ def is_identity?
18
+ is_identity
19
+ end
18
20
 
19
- def table_name
20
- @sqlserver_options[:table_name]
21
- end
21
+ def is_primary?
22
+ is_primary
23
+ end
22
24
 
23
- def is_utf8?
24
- sql_type =~ /nvarchar|ntext|nchar/i
25
- end
25
+ def is_utf8?
26
+ sql_type =~ /nvarchar|ntext|nchar/i
27
+ end
28
+
29
+ def case_sensitive?
30
+ collation && collation.match(/_CS/)
31
+ end
26
32
 
27
- def case_sensitive?
28
- collation && collation.match(/_CS/)
33
+ def init_with(coder)
34
+ @is_identity = coder["is_identity"]
35
+ @is_primary = coder["is_primary"]
36
+ @table_name = coder["table_name"]
37
+ @ordinal_position = coder["ordinal_position"]
38
+ super
39
+ end
40
+
41
+ def encode_with(coder)
42
+ coder["is_identity"] = @is_identity
43
+ coder["is_primary"] = @is_primary
44
+ coder["table_name"] = @table_name
45
+ coder["ordinal_position"] = @ordinal_position
46
+ super
47
+ end
48
+
49
+ def ==(other)
50
+ other.is_a?(Column) &&
51
+ super &&
52
+ is_identity? == other.is_identity? &&
53
+ is_primary? == other.is_primary? &&
54
+ table_name == other.table_name &&
55
+ ordinal_position == other.ordinal_position
56
+ end
57
+ alias :eql? :==
58
+
59
+ def hash
60
+ Column.hash ^
61
+ super.hash ^
62
+ is_identity?.hash ^
63
+ is_primary?.hash ^
64
+ table_name.hash ^
65
+ ordinal_position.hash
66
+ end
67
+
68
+ private
69
+
70
+ # In the Rails version of this method there is an assumption that the `default` value will always be a
71
+ # `String` class, which must be true for the MySQL/PostgreSQL/SQLite adapters. However, in the SQL Server
72
+ # adapter the `default` value can also be Boolean/Date/Time/etc. Changed the implementation of this method
73
+ # to handle non-String `default` objects.
74
+ def deduplicated
75
+ @name = -name
76
+ @sql_type_metadata = sql_type_metadata.deduplicate if sql_type_metadata
77
+ @default = (default.is_a?(String) ? -default : default.dup.freeze) if default
78
+ @default_function = -default_function if default_function
79
+ @collation = -collation if collation
80
+ @comment = -comment if comment
81
+ freeze
82
+ end
29
83
  end
84
+
85
+ SQLServerColumn = SQLServer::Column
30
86
  end
31
87
  end
32
88
  end
@@ -4,21 +4,15 @@ module ActiveRecord
4
4
  module ConnectionHandling
5
5
  def sqlserver_connection(config) #:nodoc:
6
6
  config = config.symbolize_keys
7
- config.reverse_merge! mode: :dblib
8
- mode = config[:mode].to_s.downcase.underscore.to_sym
9
- case mode
10
- when :dblib
11
- require "tiny_tds"
12
- else
13
- raise ArgumentError, "Unknown connection mode in #{config.inspect}."
14
- end
15
- ConnectionAdapters::SQLServerAdapter.new(nil, nil, config.merge(mode: mode))
16
- rescue TinyTds::Error => e
17
- if e.message.match(/database .* does not exist/i)
18
- raise ActiveRecord::NoDatabaseError
19
- else
20
- raise
21
- end
7
+ config.reverse_merge!(mode: :dblib)
8
+ config[:mode] = config[:mode].to_s.downcase.underscore.to_sym
9
+
10
+ ConnectionAdapters::SQLServerAdapter.new(
11
+ ConnectionAdapters::SQLServerAdapter.new_client(config),
12
+ logger,
13
+ nil,
14
+ config
15
+ )
22
16
  end
23
17
  end
24
18
  end
@@ -13,13 +13,18 @@ module ActiveRecord
13
13
  delegate :connection, :establish_connection, :clear_active_connections!,
14
14
  to: ActiveRecord::Base
15
15
 
16
+ def self.using_database_configurations?
17
+ true
18
+ end
19
+
16
20
  def initialize(configuration)
17
21
  @configuration = configuration
22
+ @configuration_hash = @configuration.configuration_hash
18
23
  end
19
24
 
20
25
  def create(master_established = false)
21
26
  establish_master_connection unless master_established
22
- connection.create_database configuration["database"], configuration.merge("collation" => default_collation)
27
+ connection.create_database configuration.database, configuration_hash.merge(collation: default_collation)
23
28
  establish_connection configuration
24
29
  rescue ActiveRecord::StatementInvalid => e
25
30
  if /database .* already exists/i === e.message
@@ -31,7 +36,7 @@ module ActiveRecord
31
36
 
32
37
  def drop
33
38
  establish_master_connection
34
- connection.drop_database configuration["database"]
39
+ connection.drop_database configuration.database
35
40
  end
36
41
 
37
42
  def charset
@@ -49,14 +54,14 @@ module ActiveRecord
49
54
  end
50
55
 
51
56
  def structure_dump(filename, extra_flags)
52
- server_arg = "-S #{Shellwords.escape(configuration['host'])}"
53
- server_arg += ":#{Shellwords.escape(configuration['port'])}" if configuration["port"]
57
+ server_arg = "-S #{Shellwords.escape(configuration_hash[:host])}"
58
+ server_arg += ":#{Shellwords.escape(configuration_hash[:port])}" if configuration_hash[:port]
54
59
  command = [
55
60
  "defncopy-ttds",
56
61
  server_arg,
57
- "-D #{Shellwords.escape(configuration['database'])}",
58
- "-U #{Shellwords.escape(configuration['username'])}",
59
- "-P #{Shellwords.escape(configuration['password'])}",
62
+ "-D #{Shellwords.escape(configuration_hash[:database])}",
63
+ "-U #{Shellwords.escape(configuration_hash[:username])}",
64
+ "-P #{Shellwords.escape(configuration_hash[:password])}",
60
65
  "-o #{Shellwords.escape(filename)}",
61
66
  ]
62
67
  table_args = connection.tables.map { |t| Shellwords.escape(t) }
@@ -80,16 +85,14 @@ module ActiveRecord
80
85
 
81
86
  private
82
87
 
83
- def configuration
84
- @configuration
85
- end
88
+ attr_reader :configuration, :configuration_hash
86
89
 
87
90
  def default_collation
88
- configuration["collation"] || DEFAULT_COLLATION
91
+ configuration_hash[:collation] || DEFAULT_COLLATION
89
92
  end
90
93
 
91
94
  def establish_master_connection
92
- establish_connection configuration.merge("database" => "master")
95
+ establish_connection configuration_hash.merge(database: "master")
93
96
  end
94
97
  end
95
98
 
@@ -110,9 +113,9 @@ module ActiveRecord
110
113
  end
111
114
 
112
115
  def configuration_host_ip(configuration)
113
- return nil unless configuration["host"]
116
+ return nil unless configuration.host
114
117
 
115
- Socket::getaddrinfo(configuration["host"], "echo", Socket::AF_INET)[0][3]
118
+ Socket::getaddrinfo(configuration.host, "echo", Socket::AF_INET)[0][3]
116
119
  end
117
120
 
118
121
  def local_ipaddr?(host_ip)
@@ -11,13 +11,14 @@ module Arel
11
11
 
12
12
  private
13
13
 
14
- # SQLServer ToSql/Visitor (Overides)
14
+ # SQLServer ToSql/Visitor (Overrides)
15
15
 
16
- def visit_Arel_Nodes_BindParam o, collector
17
- collector.add_bind(o.value) { |i| "@#{i - 1}" }
18
- end
16
+ BIND_BLOCK = proc { |i| "@#{i - 1}" }
17
+ private_constant :BIND_BLOCK
18
+
19
+ def bind_block; BIND_BLOCK; end
19
20
 
20
- def visit_Arel_Nodes_Bin o, collector
21
+ def visit_Arel_Nodes_Bin(o, collector)
21
22
  visit o.expr, collector
22
23
  collector << " #{ActiveRecord::ConnectionAdapters::SQLServerAdapter.cs_equality_operator} "
23
24
  end
@@ -28,26 +29,26 @@ module Arel
28
29
  visit o.right, collector
29
30
  end
30
31
 
31
- def visit_Arel_Nodes_UpdateStatement(o, a)
32
+ def visit_Arel_Nodes_UpdateStatement(o, collector)
32
33
  if o.orders.any? && o.limit.nil?
33
34
  o.limit = Nodes::Limit.new(9_223_372_036_854_775_807)
34
35
  end
35
36
  super
36
37
  end
37
38
 
38
- def visit_Arel_Nodes_Lock o, collector
39
+ def visit_Arel_Nodes_Lock(o, collector)
39
40
  o.expr = Arel.sql("WITH(UPDLOCK)") if o.expr.to_s =~ /FOR UPDATE/
40
41
  collector << " "
41
42
  visit o.expr, collector
42
43
  end
43
44
 
44
- def visit_Arel_Nodes_Offset o, collector
45
+ def visit_Arel_Nodes_Offset(o, collector)
45
46
  collector << OFFSET
46
47
  visit o.expr, collector
47
48
  collector << ROWS
48
49
  end
49
50
 
50
- def visit_Arel_Nodes_Limit o, collector
51
+ def visit_Arel_Nodes_Limit(o, collector)
51
52
  if node_value(o) == 0
52
53
  collector << FETCH0
53
54
  collector << ROWS_ONLY
@@ -63,7 +64,38 @@ module Arel
63
64
  super
64
65
  end
65
66
 
66
- def visit_Arel_Nodes_SelectStatement o, collector
67
+ def visit_Arel_Nodes_HomogeneousIn(o, collector)
68
+ collector.preparable = false
69
+
70
+ collector << quote_table_name(o.table_name) << "." << quote_column_name(o.column_name)
71
+
72
+ if o.type == :in
73
+ collector << " IN ("
74
+ else
75
+ collector << " NOT IN ("
76
+ end
77
+
78
+ values = o.casted_values
79
+
80
+ if values.empty?
81
+ collector << @connection.quote(nil)
82
+ elsif @connection.prepared_statements
83
+ # Monkey-patch start. Add query attribute bindings rather than just values.
84
+ column_name = o.column_name
85
+ column_type = o.attribute.relation.type_for_attribute(o.column_name)
86
+ attrs = values.map { |value| ActiveRecord::Relation::QueryAttribute.new(column_name, value, column_type) }
87
+
88
+ collector.add_binds(attrs, &bind_block)
89
+ # Monkey-patch end.
90
+ else
91
+ collector.add_binds(values, &bind_block)
92
+ end
93
+
94
+ collector << ")"
95
+ collector
96
+ end
97
+
98
+ def visit_Arel_Nodes_SelectStatement(o, collector)
67
99
  @select_statement = o
68
100
  distinct_One_As_One_Is_So_Not_Fetch o
69
101
  if o.with
@@ -80,7 +112,17 @@ module Arel
80
112
  @select_statement = nil
81
113
  end
82
114
 
83
- def visit_Arel_Table o, collector
115
+ def visit_Arel_Nodes_SelectCore(o, collector)
116
+ collector = super
117
+ maybe_visit o.optimizer_hints, collector
118
+ end
119
+
120
+ def visit_Arel_Nodes_OptimizerHints(o, collector)
121
+ hints = o.expr.map { |v| sanitize_as_option_clause(v) }.join(", ")
122
+ collector << "OPTION (#{hints})"
123
+ end
124
+
125
+ def visit_Arel_Table(o, collector)
84
126
  # Apparently, o.engine.connection can actually be a different adapter
85
127
  # than sqlserver. Can be removed if fixed in ActiveRecord. See:
86
128
  # github.com/rails-sqlserver/activerecord-sqlserver-adapter/issues/450
@@ -102,7 +144,7 @@ module Arel
102
144
  end
103
145
  end
104
146
 
105
- def visit_Arel_Nodes_JoinSource o, collector
147
+ def visit_Arel_Nodes_JoinSource(o, collector)
106
148
  if o.left
107
149
  collector = visit o.left, collector
108
150
  collector = visit_Arel_Nodes_SelectStatement_SQLServer_Lock collector
@@ -114,39 +156,53 @@ module Arel
114
156
  collector
115
157
  end
116
158
 
117
- def visit_Arel_Nodes_InnerJoin o, collector
118
- collector << "INNER JOIN "
119
- collector = visit o.left, collector
120
- collector = visit_Arel_Nodes_SelectStatement_SQLServer_Lock collector, space: true
121
- if o.right
122
- collector << " "
123
- visit(o.right, collector)
159
+ def visit_Arel_Nodes_InnerJoin(o, collector)
160
+ if o.left.is_a?(Arel::Nodes::As) && o.left.left.is_a?(Arel::Nodes::Lateral)
161
+ collector << "CROSS "
162
+ visit o.left, collector
124
163
  else
125
- collector
164
+ collector << "INNER JOIN "
165
+ collector = visit o.left, collector
166
+ collector = visit_Arel_Nodes_SelectStatement_SQLServer_Lock collector, space: true
167
+ if o.right
168
+ collector << " "
169
+ visit(o.right, collector)
170
+ else
171
+ collector
172
+ end
126
173
  end
127
174
  end
128
175
 
129
- def visit_Arel_Nodes_OuterJoin o, collector
130
- collector << "LEFT OUTER JOIN "
131
- collector = visit o.left, collector
132
- collector = visit_Arel_Nodes_SelectStatement_SQLServer_Lock collector, space: true
133
- collector << " "
134
- visit o.right, collector
176
+ def visit_Arel_Nodes_OuterJoin(o, collector)
177
+ if o.left.is_a?(Arel::Nodes::As) && o.left.left.is_a?(Arel::Nodes::Lateral)
178
+ collector << "OUTER "
179
+ visit o.left, collector
180
+ else
181
+ collector << "LEFT OUTER JOIN "
182
+ collector = visit o.left, collector
183
+ collector = visit_Arel_Nodes_SelectStatement_SQLServer_Lock collector, space: true
184
+ collector << " "
185
+ visit o.right, collector
186
+ end
135
187
  end
136
188
 
137
- def collect_in_clause(left, right, collector)
138
- if Array === right
139
- right.each { |node| remove_invalid_ordering_from_select_statement(node) }
189
+ def visit_Arel_Nodes_In(o, collector)
190
+ if Array === o.right
191
+ o.right.each { |node| remove_invalid_ordering_from_select_statement(node) }
140
192
  else
141
- remove_invalid_ordering_from_select_statement(right)
193
+ remove_invalid_ordering_from_select_statement(o.right)
142
194
  end
143
195
 
144
196
  super
145
197
  end
146
198
 
199
+ def collect_optimizer_hints(o, collector)
200
+ collector
201
+ end
202
+
147
203
  # SQLServer ToSql/Visitor (Additions)
148
204
 
149
- def visit_Arel_Nodes_SelectStatement_SQLServer_Lock collector, options = {}
205
+ def visit_Arel_Nodes_SelectStatement_SQLServer_Lock(collector, options = {})
150
206
  if select_statement_lock?
151
207
  collector = visit @select_statement.lock, collector
152
208
  collector << " " if options[:space]
@@ -154,7 +210,7 @@ module Arel
154
210
  collector
155
211
  end
156
212
 
157
- def visit_Orders_And_Let_Fetch_Happen o, collector
213
+ def visit_Orders_And_Let_Fetch_Happen(o, collector)
158
214
  make_Fetch_Possible_And_Deterministic o
159
215
  unless o.orders.empty?
160
216
  collector << " ORDER BY "
@@ -167,13 +223,25 @@ module Arel
167
223
  collector
168
224
  end
169
225
 
170
- def visit_Make_Fetch_Happen o, collector
226
+ def visit_Make_Fetch_Happen(o, collector)
171
227
  o.offset = Nodes::Offset.new(0) if o.limit && !o.offset
172
228
  collector = visit o.offset, collector if o.offset
173
229
  collector = visit o.limit, collector if o.limit
174
230
  collector
175
231
  end
176
232
 
233
+ def visit_Arel_Nodes_Lateral(o, collector)
234
+ collector << "APPLY"
235
+ collector << " "
236
+ if o.expr.is_a?(Arel::Nodes::SelectStatement)
237
+ collector << "("
238
+ visit(o.expr, collector)
239
+ collector << ")"
240
+ else
241
+ visit(o.expr, collector)
242
+ end
243
+ end
244
+
177
245
  # SQLServer Helpers
178
246
 
179
247
  def node_value(node)
@@ -190,7 +258,7 @@ module Arel
190
258
  @select_statement && @select_statement.lock
191
259
  end
192
260
 
193
- def make_Fetch_Possible_And_Deterministic o
261
+ def make_Fetch_Possible_And_Deterministic(o)
194
262
  return if o.limit.nil? && o.offset.nil?
195
263
 
196
264
  t = table_From_Statement o
@@ -203,7 +271,7 @@ module Arel
203
271
  end
204
272
  end
205
273
 
206
- def distinct_One_As_One_Is_So_Not_Fetch o
274
+ def distinct_One_As_One_Is_So_Not_Fetch(o)
207
275
  core = o.cores.first
208
276
  distinct = Nodes::Distinct === core.set_quantifier
209
277
  oneasone = core.projections.all? { |x| x == ActiveRecord::FinderMethods::ONE_AS_ONE }
@@ -214,7 +282,7 @@ module Arel
214
282
  end
215
283
  end
216
284
 
217
- def table_From_Statement o
285
+ def table_From_Statement(o)
218
286
  core = o.cores.first
219
287
  if Arel::Table === core.from
220
288
  core.from
@@ -225,15 +293,28 @@ module Arel
225
293
  end
226
294
  end
227
295
 
228
- def primary_Key_From_Table t
296
+ def primary_Key_From_Table(t)
229
297
  return unless t
230
298
 
231
- column_name = @connection.schema_cache.primary_keys(t.name) ||
232
- @connection.schema_cache.columns_hash(t.name).first.try(:second).try(:name)
299
+ primary_keys = @connection.schema_cache.primary_keys(t.name)
300
+ column_name = nil
301
+
302
+ case primary_keys
303
+ when NilClass
304
+ column_name = @connection.schema_cache.columns_hash(t.name).first.try(:second).try(:name)
305
+ when String
306
+ column_name = primary_keys
307
+ when Array
308
+ candidate_columns = @connection.schema_cache.columns_hash(t.name).slice(*primary_keys).values
309
+ candidate_column = candidate_columns.find(&:is_identity?)
310
+ candidate_column ||= candidate_columns.first
311
+ column_name = candidate_column.try(:name)
312
+ end
313
+
233
314
  column_name ? t[column_name] : nil
234
315
  end
235
316
 
236
- def remote_server_table_name o
317
+ def remote_server_table_name(o)
237
318
  ActiveRecord::ConnectionAdapters::SQLServer::Utils.extract_identifiers(
238
319
  "#{o.class.engine.connection.database_prefix}#{o.name}"
239
320
  ).quoted
@@ -247,6 +328,10 @@ module Arel
247
328
 
248
329
  node.orders = [] unless node.offset || node.limit
249
330
  end
331
+
332
+ def sanitize_as_option_clause(value)
333
+ value.gsub(%r{OPTION \s* \( (.+) \)}xi, "\\1")
334
+ end
250
335
  end
251
336
  end
252
337
  end