sequel 5.44.0 → 5.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG +38 -0
- data/README.rdoc +1 -2
- data/doc/association_basics.rdoc +70 -11
- data/doc/migration.rdoc +11 -5
- data/doc/querying.rdoc +1 -1
- data/doc/release_notes/5.45.0.txt +34 -0
- data/doc/release_notes/5.46.0.txt +87 -0
- data/doc/release_notes/5.47.0.txt +59 -0
- data/doc/release_notes/5.48.0.txt +14 -0
- data/doc/sql.rdoc +12 -0
- data/doc/testing.rdoc +4 -0
- data/doc/virtual_rows.rdoc +1 -1
- data/lib/sequel/adapters/odbc.rb +5 -1
- data/lib/sequel/adapters/shared/mssql.rb +15 -2
- data/lib/sequel/adapters/shared/mysql.rb +17 -0
- data/lib/sequel/adapters/shared/postgres.rb +0 -12
- data/lib/sequel/adapters/shared/sqlite.rb +53 -7
- data/lib/sequel/database/schema_methods.rb +1 -1
- data/lib/sequel/dataset/query.rb +2 -4
- data/lib/sequel/dataset/sql.rb +7 -0
- data/lib/sequel/extensions/pg_loose_count.rb +3 -1
- data/lib/sequel/extensions/schema_dumper.rb +11 -0
- data/lib/sequel/model/associations.rb +236 -81
- data/lib/sequel/model/errors.rb +10 -1
- data/lib/sequel/plugins/auto_validations_constraint_validations_presence_message.rb +68 -0
- data/lib/sequel/plugins/many_through_many.rb +108 -9
- data/lib/sequel/plugins/pg_array_associations.rb +46 -34
- data/lib/sequel/plugins/prepared_statements.rb +10 -1
- data/lib/sequel/plugins/timestamps.rb +1 -1
- data/lib/sequel/plugins/unused_associations.rb +521 -0
- data/lib/sequel/plugins/validation_helpers.rb +5 -8
- data/lib/sequel/version.rb +1 -1
- metadata +13 -3
data/lib/sequel/model/errors.rb
CHANGED
@@ -38,7 +38,7 @@ module Sequel
|
|
38
38
|
def full_messages
|
39
39
|
inject([]) do |m, kv|
|
40
40
|
att, errors = *kv
|
41
|
-
errors.each {|e| m << (e.is_a?(LiteralString) ? e :
|
41
|
+
errors.each {|e| m << (e.is_a?(LiteralString) ? e : full_message(att, e))}
|
42
42
|
m
|
43
43
|
end
|
44
44
|
end
|
@@ -53,6 +53,15 @@ module Sequel
|
|
53
53
|
v
|
54
54
|
end
|
55
55
|
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
# Create full error message to use for the given attribute (or array of attributes)
|
60
|
+
# and error message. This can be overridden for easier internalization.
|
61
|
+
def full_message(att, error_msg)
|
62
|
+
att = att.join(' and ') if att.is_a?(Array)
|
63
|
+
"#{att} #{error_msg}"
|
64
|
+
end
|
56
65
|
end
|
57
66
|
end
|
58
67
|
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
# frozen-string-literal: true
|
2
|
+
|
3
|
+
module Sequel
|
4
|
+
module Plugins
|
5
|
+
# The auto_validations_constraint_validations_presence_message plugin provides
|
6
|
+
# integration for the auto_validations and constraint_validations plugins in
|
7
|
+
# the following situation:
|
8
|
+
#
|
9
|
+
# * A column has a NOT NULL constraint in the database
|
10
|
+
# * A constraint validation for presence exists on the column, with a :message
|
11
|
+
# option to set a column-specific message, and with the :allow_nil option set
|
12
|
+
# to true because the CHECK constraint doesn't need to check for NULL values
|
13
|
+
# as the column itself is NOT NULL
|
14
|
+
#
|
15
|
+
# In this case, by default the validation error message on the column will
|
16
|
+
# use the more specific constraint validation error message if the column
|
17
|
+
# has a non-NULL empty value, but will use the default auto_validations
|
18
|
+
# message if the column has a NULL value. With this plugin, the column-specific
|
19
|
+
# constraint validation error message will be used in both cases.
|
20
|
+
#
|
21
|
+
# Usage:
|
22
|
+
#
|
23
|
+
# # Make all model subclasses use this auto_validations/constraint_validations
|
24
|
+
# # integration (called before loading subclasses)
|
25
|
+
# Sequel::Model.plugin :auto_validations_constraint_validations_presence_message
|
26
|
+
#
|
27
|
+
# # Make the Album class use this auto_validations/constraint_validations integration
|
28
|
+
# Album.plugin :auto_validations_constraint_validations_presence_message
|
29
|
+
module AutoValidationsConstraintValidationsPresenceMessage
|
30
|
+
def self.apply(model)
|
31
|
+
model.plugin :auto_validations
|
32
|
+
model.plugin :constraint_validations
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.configure(model, opts=OPTS)
|
36
|
+
model.send(:_adjust_auto_validations_constraint_validations_presence_message)
|
37
|
+
end
|
38
|
+
|
39
|
+
module ClassMethods
|
40
|
+
Plugins.after_set_dataset(self, :_adjust_auto_validations_constraint_validations_presence_message)
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def _adjust_auto_validations_constraint_validations_presence_message
|
45
|
+
if @dataset &&
|
46
|
+
!@auto_validate_options[:not_null][:message] &&
|
47
|
+
!@auto_validate_options[:explicit_not_null][:message]
|
48
|
+
|
49
|
+
@constraint_validations.each do |array|
|
50
|
+
meth, column, opts = array
|
51
|
+
|
52
|
+
if meth == :validates_presence &&
|
53
|
+
opts &&
|
54
|
+
opts[:message] &&
|
55
|
+
opts[:allow_nil] &&
|
56
|
+
(@auto_validate_not_null_columns.include?(column) || @auto_validate_explicit_not_null_columns.include?(column))
|
57
|
+
|
58
|
+
@auto_validate_not_null_columns.delete(column)
|
59
|
+
@auto_validate_explicit_not_null_columns.delete(column)
|
60
|
+
array[2] = array[2].merge(:allow_nil=>false)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -123,16 +123,25 @@ module Sequel
|
|
123
123
|
nil
|
124
124
|
end
|
125
125
|
|
126
|
+
# Whether a separate query should be used for each join table.
|
127
|
+
def separate_query_per_table?
|
128
|
+
self[:separate_query_per_table]
|
129
|
+
end
|
130
|
+
|
126
131
|
private
|
127
132
|
|
128
133
|
def _associated_dataset
|
129
134
|
ds = associated_class
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
135
|
+
if separate_query_per_table?
|
136
|
+
ds = ds.dataset
|
137
|
+
else
|
138
|
+
(reverse_edges + [final_reverse_edge]).each do |t|
|
139
|
+
h = {:qualify=>:deep}
|
140
|
+
if t[:alias] != t[:table]
|
141
|
+
h[:table_alias] = t[:alias]
|
142
|
+
end
|
143
|
+
ds = ds.join(t[:table], Array(t[:left]).zip(Array(t[:right])), h)
|
134
144
|
end
|
135
|
-
ds = ds.join(t[:table], Array(t[:left]).zip(Array(t[:right])), h)
|
136
145
|
end
|
137
146
|
ds
|
138
147
|
end
|
@@ -208,6 +217,7 @@ module Sequel
|
|
208
217
|
# :right (last array element) :: The key joining the table to the next table. Can use an
|
209
218
|
# array of symbols for a composite key association.
|
210
219
|
# If a hash is provided, the following keys are respected when using eager_graph:
|
220
|
+
# :db :: The Database containing the table. This changes lookup to use a separate query for each join table.
|
211
221
|
# :block :: A proc to use as the block argument to join.
|
212
222
|
# :conditions :: Extra conditions to add to the JOIN ON clause. Must be a hash or array of two pairs.
|
213
223
|
# :join_type :: The join type to use for the join, defaults to :left_outer.
|
@@ -233,32 +243,121 @@ module Sequel
|
|
233
243
|
opts[:after_load].unshift(:array_uniq!)
|
234
244
|
end
|
235
245
|
opts[:cartesian_product_number] ||= one_through_many ? 0 : 2
|
236
|
-
|
246
|
+
separate_query_per_table = false
|
247
|
+
through = opts[:through] = opts[:through].map do |e|
|
237
248
|
case e
|
238
249
|
when Array
|
239
250
|
raise(Error, "array elements of the through option/argument for many_through_many associations must have at least three elements") unless e.length == 3
|
240
251
|
{:table=>e[0], :left=>e[1], :right=>e[2]}
|
241
252
|
when Hash
|
242
253
|
raise(Error, "hash elements of the through option/argument for many_through_many associations must contain :table, :left, and :right keys") unless e[:table] && e[:left] && e[:right]
|
254
|
+
separate_query_per_table = true if e[:db]
|
243
255
|
e
|
244
256
|
else
|
245
257
|
raise(Error, "the through option/argument for many_through_many associations must be an enumerable of arrays or hashes")
|
246
258
|
end
|
247
259
|
end
|
260
|
+
opts[:separate_query_per_table] = separate_query_per_table
|
248
261
|
|
249
262
|
left_key = opts[:left_key] = opts[:through].first[:left]
|
250
263
|
opts[:left_keys] = Array(left_key)
|
251
|
-
opts[:uses_left_composite_keys] = left_key.is_a?(Array)
|
264
|
+
uses_lcks = opts[:uses_left_composite_keys] = left_key.is_a?(Array)
|
252
265
|
left_pk = (opts[:left_primary_key] ||= self.primary_key)
|
253
266
|
raise(Error, "no primary key specified for #{inspect}") unless left_pk
|
254
267
|
opts[:eager_loader_key] = left_pk unless opts.has_key?(:eager_loader_key)
|
255
268
|
opts[:left_primary_keys] = Array(left_pk)
|
256
269
|
lpkc = opts[:left_primary_key_column] ||= left_pk
|
257
270
|
lpkcs = opts[:left_primary_key_columns] ||= Array(lpkc)
|
258
|
-
opts[:dataset] ||= opts.association_dataset_proc
|
259
271
|
|
260
272
|
opts[:left_key_alias] ||= opts.default_associated_key_alias
|
261
|
-
|
273
|
+
if separate_query_per_table
|
274
|
+
opts[:use_placeholder_loader] = false
|
275
|
+
opts[:allow_eager_graph] = false
|
276
|
+
opts[:allow_filtering_by] = false
|
277
|
+
opts[:eager_limit_strategy] = nil
|
278
|
+
|
279
|
+
opts[:dataset] ||= proc do |r|
|
280
|
+
def_db = r.associated_class.db
|
281
|
+
vals = uses_lcks ? [lpkcs.map{|k| get_column_value(k)}] : get_column_value(left_pk)
|
282
|
+
|
283
|
+
has_results = through.each do |edge|
|
284
|
+
ds = (edge[:db] || def_db).from(edge[:table]).where(edge[:left]=>vals)
|
285
|
+
ds = ds.where(edge[:conditions]) if edge[:conditions]
|
286
|
+
right = edge[:right]
|
287
|
+
vals = ds.select_map(right)
|
288
|
+
if right.is_a?(Array)
|
289
|
+
vals.delete_if{|v| v.any?(&:nil?)}
|
290
|
+
else
|
291
|
+
vals.delete(nil)
|
292
|
+
end
|
293
|
+
break if vals.empty?
|
294
|
+
end
|
295
|
+
|
296
|
+
ds = r.associated_dataset.where(opts.right_primary_key=>vals)
|
297
|
+
ds = ds.clone(:no_results=>true) unless has_results
|
298
|
+
ds
|
299
|
+
end
|
300
|
+
opts[:eager_loader] ||= proc do |eo|
|
301
|
+
h = eo[:id_map]
|
302
|
+
assign_singular = opts.assign_singular?
|
303
|
+
uses_rcks = opts.right_primary_key.is_a?(Array)
|
304
|
+
rpk = uses_rcks ? opts.right_primary_keys : opts.right_primary_key
|
305
|
+
name = opts[:name]
|
306
|
+
def_db = opts.associated_class.db
|
307
|
+
join_map = h
|
308
|
+
|
309
|
+
run_query = through.each do |edge|
|
310
|
+
ds = (edge[:db] || def_db).from(edge[:table])
|
311
|
+
ds = ds.where(edge[:conditions]) if edge[:conditions]
|
312
|
+
left = edge[:left]
|
313
|
+
right = edge[:right]
|
314
|
+
prev_map = join_map
|
315
|
+
join_map = ds.where(left=>join_map.keys).select_hash_groups(right, left)
|
316
|
+
if right.is_a?(Array)
|
317
|
+
join_map.delete_if{|v,| v.any?(&:nil?)}
|
318
|
+
else
|
319
|
+
join_map.delete(nil)
|
320
|
+
end
|
321
|
+
break if join_map.empty?
|
322
|
+
join_map.each_value do |vs|
|
323
|
+
vs.replace(vs.flat_map{|v| prev_map[v]})
|
324
|
+
vs.uniq!
|
325
|
+
end
|
326
|
+
end
|
327
|
+
|
328
|
+
eo = Hash[eo]
|
329
|
+
|
330
|
+
if run_query
|
331
|
+
eo[:loader] = false
|
332
|
+
eo[:right_keys] = join_map.keys
|
333
|
+
else
|
334
|
+
eo[:no_results] = true
|
335
|
+
end
|
336
|
+
|
337
|
+
opts[:model].eager_load_results(opts, eo) do |assoc_record|
|
338
|
+
rpkv = if uses_rcks
|
339
|
+
assoc_record.values.values_at(*rpk)
|
340
|
+
else
|
341
|
+
assoc_record.values[rpk]
|
342
|
+
end
|
343
|
+
|
344
|
+
objects = join_map[rpkv]
|
345
|
+
|
346
|
+
if assign_singular
|
347
|
+
objects.each do |object|
|
348
|
+
object.associations[name] ||= assoc_record
|
349
|
+
end
|
350
|
+
else
|
351
|
+
objects.each do |object|
|
352
|
+
object.associations[name].push(assoc_record)
|
353
|
+
end
|
354
|
+
end
|
355
|
+
end
|
356
|
+
end
|
357
|
+
else
|
358
|
+
opts[:dataset] ||= opts.association_dataset_proc
|
359
|
+
opts[:eager_loader] ||= opts.method(:default_eager_loader)
|
360
|
+
end
|
262
361
|
|
263
362
|
join_type = opts[:graph_join_type]
|
264
363
|
select = opts[:graph_select]
|
@@ -384,26 +384,32 @@ module Sequel
|
|
384
384
|
save_opts = {:validate=>opts[:validate]}
|
385
385
|
save_opts[:raise_on_failure] = opts[:raise_on_save_failure] != false
|
386
386
|
|
387
|
-
opts
|
388
|
-
|
389
|
-
array
|
390
|
-
|
391
|
-
|
387
|
+
unless opts.has_key?(:adder)
|
388
|
+
opts[:adder] = proc do |o|
|
389
|
+
if array = o.get_column_value(key)
|
390
|
+
array << get_column_value(pk)
|
391
|
+
else
|
392
|
+
o.set_column_value("#{key}=", Sequel.pg_array([get_column_value(pk)], opts.array_type))
|
393
|
+
end
|
394
|
+
o.save(save_opts)
|
392
395
|
end
|
393
|
-
o.save(save_opts)
|
394
396
|
end
|
395
|
-
|
396
|
-
opts
|
397
|
-
|
398
|
-
array.
|
399
|
-
|
397
|
+
|
398
|
+
unless opts.has_key?(:remover)
|
399
|
+
opts[:remover] = proc do |o|
|
400
|
+
if (array = o.get_column_value(key)) && !array.empty?
|
401
|
+
array.delete(get_column_value(pk))
|
402
|
+
o.save(save_opts)
|
403
|
+
end
|
400
404
|
end
|
401
405
|
end
|
402
406
|
|
403
|
-
opts
|
404
|
-
|
405
|
-
|
406
|
-
|
407
|
+
unless opts.has_key?(:clearer)
|
408
|
+
opts[:clearer] = proc do
|
409
|
+
pk_value = get_column_value(pk)
|
410
|
+
db_type = opts.array_type
|
411
|
+
opts.associated_dataset.where(Sequel.pg_array_op(key).contains(Sequel.pg_array([pk_value], db_type))).update(key=>Sequel.function(:array_remove, key, Sequel.cast(pk_value, db_type)))
|
412
|
+
end
|
407
413
|
end
|
408
414
|
end
|
409
415
|
|
@@ -486,30 +492,36 @@ module Sequel
|
|
486
492
|
end
|
487
493
|
end
|
488
494
|
|
489
|
-
opts
|
490
|
-
|
491
|
-
|
492
|
-
|
493
|
-
|
494
|
-
|
495
|
-
|
495
|
+
unless opts.has_key?(:adder)
|
496
|
+
opts[:adder] = proc do |o|
|
497
|
+
opk = o.get_column_value(opts.primary_key)
|
498
|
+
if array = get_column_value(key)
|
499
|
+
modified!(key)
|
500
|
+
array << opk
|
501
|
+
else
|
502
|
+
set_column_value("#{key}=", Sequel.pg_array([opk], opts.array_type))
|
503
|
+
end
|
504
|
+
save_after_modify.call(self) if save_after_modify
|
496
505
|
end
|
497
|
-
save_after_modify.call(self) if save_after_modify
|
498
506
|
end
|
499
|
-
|
500
|
-
opts
|
501
|
-
|
502
|
-
|
503
|
-
|
504
|
-
|
507
|
+
|
508
|
+
unless opts.has_key?(:remover)
|
509
|
+
opts[:remover] = proc do |o|
|
510
|
+
if (array = get_column_value(key)) && !array.empty?
|
511
|
+
modified!(key)
|
512
|
+
array.delete(o.get_column_value(opts.primary_key))
|
513
|
+
save_after_modify.call(self) if save_after_modify
|
514
|
+
end
|
505
515
|
end
|
506
516
|
end
|
507
517
|
|
508
|
-
opts
|
509
|
-
|
510
|
-
|
511
|
-
|
512
|
-
|
518
|
+
unless opts.has_key?(:clearer)
|
519
|
+
opts[:clearer] = proc do
|
520
|
+
if (array = get_column_value(key)) && !array.empty?
|
521
|
+
modified!(key)
|
522
|
+
array.clear
|
523
|
+
save_after_modify.call(self) if save_after_modify
|
524
|
+
end
|
513
525
|
end
|
514
526
|
end
|
515
527
|
end
|
@@ -169,8 +169,17 @@ module Sequel
|
|
169
169
|
end
|
170
170
|
|
171
171
|
case type
|
172
|
-
when :insert, :
|
172
|
+
when :insert, :update
|
173
173
|
true
|
174
|
+
when :insert_select
|
175
|
+
# SQLite RETURNING support has a bug that doesn't allow for committing transactions
|
176
|
+
# when a prepared statement with RETURNING has been used on the connection:
|
177
|
+
#
|
178
|
+
# SQLite3::BusyException: cannot commit transaction - SQL statements in progress: COMMIT
|
179
|
+
#
|
180
|
+
# Disabling usage of prepared statements for insert_select on SQLite seems to be the
|
181
|
+
# simplest way to workaround the problem.
|
182
|
+
db.database_type != :sqlite
|
174
183
|
# :nocov:
|
175
184
|
when :delete, :refresh
|
176
185
|
Sequel::Deprecation.deprecate("The :delete and :refresh prepared statement types", "There should be no need to check if these types are supported")
|
@@ -19,7 +19,7 @@ module Sequel
|
|
19
19
|
#
|
20
20
|
# # Timestamp Artist instances, forcing an overwrite of the create
|
21
21
|
# # timestamp, and setting the update timestamp when creating
|
22
|
-
#
|
22
|
+
# Artist.plugin :timestamps, force: true, update_on_create: true
|
23
23
|
module Timestamps
|
24
24
|
# Configure the plugin by setting the available options. Note that
|
25
25
|
# if this method is run more than once, previous settings are ignored,
|
@@ -0,0 +1,521 @@
|
|
1
|
+
# frozen-string-literal: true
|
2
|
+
|
3
|
+
# :nocov:
|
4
|
+
|
5
|
+
# This entire file is excluded from coverage testing. This is because it
|
6
|
+
# requires coverage testing to work, and if you've already loaded Sequel
|
7
|
+
# without enabling coverage, then coverage testing won't work correctly
|
8
|
+
# for methods defined by Sequel.
|
9
|
+
#
|
10
|
+
# While automated coverage testing is disabled, manual coverage testing
|
11
|
+
# was used during spec development to make sure this code is 100% covered.
|
12
|
+
|
13
|
+
if RUBY_VERSION < '2.5'
|
14
|
+
raise LoadError, "The Sequel unused_associations plugin depends on Ruby 2.5+ method coverage"
|
15
|
+
end
|
16
|
+
|
17
|
+
require 'coverage'
|
18
|
+
require 'json'
|
19
|
+
|
20
|
+
module Sequel
|
21
|
+
module Plugins
|
22
|
+
# The unused_associations plugin detects which model associations are not
|
23
|
+
# used and can be removed, and which model association methods are not used
|
24
|
+
# and can skip being defined. The advantage of removing unused associations
|
25
|
+
# and unused association methods is decreased memory usage, since each
|
26
|
+
# method defined takes memory and adds more work for the garbage collector.
|
27
|
+
#
|
28
|
+
# In order to detect which associations are used, this relies on the method
|
29
|
+
# coverage support added in Ruby 2.5. To allow flexibility to override
|
30
|
+
# association methods, the association methods that Sequel defines are
|
31
|
+
# defined in a module included in the class instead of directly in the
|
32
|
+
# class. Unfortunately, that makes it difficult to directly use the
|
33
|
+
# coverage data to find unused associations. The advantage of this plugin
|
34
|
+
# is that it is able to figure out from the coverage information whether
|
35
|
+
# the association methods Sequel defines are actually used.
|
36
|
+
#
|
37
|
+
# = Basic Usage
|
38
|
+
#
|
39
|
+
# The expected usage of the unused_associations plugin is to load it
|
40
|
+
# into the base class for models in your application, which will often
|
41
|
+
# be Sequel::Model:
|
42
|
+
#
|
43
|
+
# Sequel::Model.plugin :unused_associations
|
44
|
+
#
|
45
|
+
# Then you run your test suite with method coverage enabled, passing the
|
46
|
+
# coverage result to +update_associations_coverage+.
|
47
|
+
# +update_associations_coverage+ returns a data structure containing
|
48
|
+
# method coverage information for all subclasses of the base class.
|
49
|
+
# You can pass the coverage information to
|
50
|
+
# +update_unused_associations_data+, which will return a data structure
|
51
|
+
# with information on unused associations.
|
52
|
+
#
|
53
|
+
# require 'coverage'
|
54
|
+
# Coverage.start(methods: true)
|
55
|
+
# # load sequel after starting coverage, then run your tests
|
56
|
+
# cov_data = Sequel::Model.update_associations_coverage
|
57
|
+
# unused_associations_data = Sequel::Model.update_unused_associations_data(coverage_data: cov_data)
|
58
|
+
#
|
59
|
+
# You can take that unused association data and pass it to the
|
60
|
+
# +unused_associations+ method to get a array of information on
|
61
|
+
# associations which have not been used. Each entry in the array
|
62
|
+
# will contain a class name and association name for each unused
|
63
|
+
# association, both as a string:
|
64
|
+
#
|
65
|
+
# Sequel::Model.unused_associations(unused_associations_data: unused_associations_data)
|
66
|
+
# # => [["Class1", "assoc1"], ...]
|
67
|
+
#
|
68
|
+
# You can use the output of the +unused_associations+ method to determine
|
69
|
+
# which associations are not used at all in your application, and can
|
70
|
+
# be eliminiated.
|
71
|
+
#
|
72
|
+
# You can also take that unused association data and pass it to the
|
73
|
+
# +unused_association_options+ method, which will return an array of
|
74
|
+
# information on associations which are used, but have related methods
|
75
|
+
# defined that are not used. The first two entries in each array are
|
76
|
+
# the class name and association name as a string, and the third
|
77
|
+
# entry is a hash of association options:
|
78
|
+
#
|
79
|
+
# Sequel::Model.unused_association_options(unused_associations_data: unused_associations_data)
|
80
|
+
# # => [["Class2", "assoc2", {:read_only=>true}], ...]
|
81
|
+
#
|
82
|
+
# You can use the output of the +unused_association_options+ to
|
83
|
+
# find out which association options can be provided when defining
|
84
|
+
# the association so that the association method will not define
|
85
|
+
# methods that are not used.
|
86
|
+
#
|
87
|
+
# = Combining Coverage Results
|
88
|
+
#
|
89
|
+
# It is common to want to combine results from multiple separate
|
90
|
+
# coverage runs. For example, if you have multiple test suites
|
91
|
+
# for your application, one for model or unit tests and one for
|
92
|
+
# web or integration tests, you would want to combine the
|
93
|
+
# coverage information from all test suites before determining
|
94
|
+
# that the associations are not used.
|
95
|
+
#
|
96
|
+
# The unused_associations plugin supports combining multiple
|
97
|
+
# coverage results using the :coverage_file plugin option:
|
98
|
+
#
|
99
|
+
# Sequel::Model.plugin :unused_associations,
|
100
|
+
# coverage_file: 'unused_associations_coverage.json'
|
101
|
+
#
|
102
|
+
# With the coverage file option, +update_associations_coverage+
|
103
|
+
# will look in the given file for existing coverage information,
|
104
|
+
# if it exists. If the file exists, the data from it will be
|
105
|
+
# merged with the coverage result passed to the method.
|
106
|
+
# Before returning, the coverage file will be updated with the
|
107
|
+
# merged result. When using the :coverage_file plugin option,
|
108
|
+
# you can each of your test suites update the coverage
|
109
|
+
# information:
|
110
|
+
#
|
111
|
+
# require 'coverage'
|
112
|
+
# Coverage.start(methods: true)
|
113
|
+
# # run this test suite
|
114
|
+
# Sequel::Model.update_associations_coverage
|
115
|
+
#
|
116
|
+
# After all test suites have been run, you can run
|
117
|
+
# +update_unused_associations_data+, without an argument:
|
118
|
+
#
|
119
|
+
# unused_associations_data = Sequel::Model.update_unused_associations_data
|
120
|
+
#
|
121
|
+
# With no argument, +update_unused_associations_data+ will get
|
122
|
+
# the coverage data from the coverage file, and then use that
|
123
|
+
# to prepare the information. You can then use the returned
|
124
|
+
# value the same as before to get the data on unused associations.
|
125
|
+
# To prevent stale coverage information, calling
|
126
|
+
# +update_unused_associations_data+ when using the :coverage_file
|
127
|
+
# plugin option will remove the coverage file by default (you can
|
128
|
+
# use the :keep_coverage option to prevent the deletion of the
|
129
|
+
# coverage file).
|
130
|
+
#
|
131
|
+
# = Automatic Usage of Unused Association Data
|
132
|
+
#
|
133
|
+
# Since it can be a pain to manually update all of your code
|
134
|
+
# to remove unused assocations or add options to prevent the
|
135
|
+
# definition of unused associations, the unused_associations
|
136
|
+
# plugin comes with support to take previously saved unused
|
137
|
+
# association data, and use it to not create unused associations,
|
138
|
+
# and to automatically use the appropriate options so that unused
|
139
|
+
# association methods are not created.
|
140
|
+
#
|
141
|
+
# To use this option, you first need to save the unused association
|
142
|
+
# data previously prepared. You can do this by passing an
|
143
|
+
# :file option when loading the plugin.
|
144
|
+
#
|
145
|
+
# Sequel::Model.plugin :unused_associations,
|
146
|
+
# file: 'unused_associations.json'
|
147
|
+
#
|
148
|
+
# With the :file option provided, you no longer need to use
|
149
|
+
# the return value of +update_unused_associations_data+, as
|
150
|
+
# the file will be updated with the information:
|
151
|
+
#
|
152
|
+
# Sequel::Model.update_unused_associations_data(coverage_data: cov_data)
|
153
|
+
#
|
154
|
+
# Then, to use the saved unused associations data, add the
|
155
|
+
# :modify_associations plugin option:
|
156
|
+
#
|
157
|
+
# Sequel::Model.plugin :unused_associations,
|
158
|
+
# file: 'unused_associations.json',
|
159
|
+
# modify_associations: true
|
160
|
+
#
|
161
|
+
# With the :modify_associations used, and the unused association
|
162
|
+
# data file is available, when subclasses attempt to create an
|
163
|
+
# unused association, the attempt will be ignored. If the
|
164
|
+
# subclasses attempt to create an association where not
|
165
|
+
# all association methods are used, the plugin will automatically
|
166
|
+
# set the appropriate options so that the unused association
|
167
|
+
# methods are not defined.
|
168
|
+
#
|
169
|
+
# When you are testing which associations are used, make sure
|
170
|
+
# not to set the :modify_associations plugin option, or make sure
|
171
|
+
# that the unused associations data file does not exist.
|
172
|
+
#
|
173
|
+
# == Automatic Usage with Combined Coverage Results
|
174
|
+
#
|
175
|
+
# If you have multiple test suites and want to automatically
|
176
|
+
# use the unused association data, you should provide both
|
177
|
+
# :file and :coverage_file options when loading the plugin:
|
178
|
+
#
|
179
|
+
# Sequel::Model.plugin :unused_associations,
|
180
|
+
# file: 'unused_associations.json',
|
181
|
+
# coverage_file: 'unused_associations_coverage.json'
|
182
|
+
#
|
183
|
+
# Then each test suite just needs to run
|
184
|
+
# +update_associations_coverage+ to update the coverage information:
|
185
|
+
#
|
186
|
+
# Sequel::Model.update_associations_coverage
|
187
|
+
#
|
188
|
+
# After all test suites have been run, you can run
|
189
|
+
# +update_unused_associations_data+ to update the unused
|
190
|
+
# association data file (and remove the coverage file):
|
191
|
+
#
|
192
|
+
# Sequel::Model.update_unused_associations_data
|
193
|
+
#
|
194
|
+
# Then you can add the :modify_associations plugin option to
|
195
|
+
# automatically use the unused association data.
|
196
|
+
#
|
197
|
+
# = Caveats
|
198
|
+
#
|
199
|
+
# Since this plugin is based on coverage information, if you do
|
200
|
+
# not have tests that cover all usage of associations in your
|
201
|
+
# application, you can end up with coverage that shows the
|
202
|
+
# association is not used, when it is used in code that is not
|
203
|
+
# covered. The output of plugin can still be useful in such cases,
|
204
|
+
# as long as you are manually checking it. However, you should
|
205
|
+
# avoid using the :modify_associations unless you have
|
206
|
+
# confidence that your tests cover all usage of associations
|
207
|
+
# in your application. You can specify the :is_used association
|
208
|
+
# option for any association that you know is used. If an
|
209
|
+
# association uses the :is_used association option, this plugin
|
210
|
+
# will not modify it if the :modify_associations option is used.
|
211
|
+
#
|
212
|
+
# This plugin does not handle anonymous classes. Any unused
|
213
|
+
# associations defined in anonymous classes will not be
|
214
|
+
# reported by this plugin.
|
215
|
+
#
|
216
|
+
# This plugin only considers the public instance methods the
|
217
|
+
# association defines, and direct access to the related
|
218
|
+
# association reflection via Sequel::Model.association_reflection
|
219
|
+
# to determine if the association was used. If the association
|
220
|
+
# metadata was accessed another way, it's possible this plugin
|
221
|
+
# will show the association as unused.
|
222
|
+
#
|
223
|
+
# As this relies on the method coverage added in Ruby 2.5, it does
|
224
|
+
# not work on older versions of Ruby. It also does not work on
|
225
|
+
# JRuby, as JRuby does not implement method coverage.
|
226
|
+
module UnusedAssociations
|
227
|
+
# Load the subclasses plugin, as the unused associations plugin
|
228
|
+
# is designed to handle all subclasses of the class it is loaded
|
229
|
+
# into.
|
230
|
+
def self.apply(mod, opts=OPTS)
|
231
|
+
mod.plugin :subclasses
|
232
|
+
end
|
233
|
+
|
234
|
+
# Plugin options:
|
235
|
+
# :coverage_file :: The file to store the coverage information,
|
236
|
+
# when combining coverage information from
|
237
|
+
# multiple test suites.
|
238
|
+
# :file :: The file to store and/or load the unused associations data.
|
239
|
+
# :modify_associations :: Whether to use the unused associations data
|
240
|
+
# to skip defining associations or association
|
241
|
+
# methods.
|
242
|
+
# :unused_associations_data :: The unused associations data to use if the
|
243
|
+
# :modify_associations is used (by default, the
|
244
|
+
# :modify_associations option will use the data from
|
245
|
+
# the file specified by the :file option). This is
|
246
|
+
# same data returned by the
|
247
|
+
# +update_unused_associations_data+ method.
|
248
|
+
def self.configure(mod, opts=OPTS)
|
249
|
+
mod.instance_exec do
|
250
|
+
@unused_associations_coverage_file = opts[:coverage_file]
|
251
|
+
@unused_associations_file = opts[:file]
|
252
|
+
@unused_associations_data = if opts[:modify_associations]
|
253
|
+
if opts[:unused_associations_data]
|
254
|
+
opts[:unused_associations_data]
|
255
|
+
elsif File.file?(opts[:file])
|
256
|
+
Sequel.parse_json(File.binread(opts[:file]))
|
257
|
+
end
|
258
|
+
end
|
259
|
+
end
|
260
|
+
end
|
261
|
+
|
262
|
+
module ClassMethods
|
263
|
+
# Only the data is copied to subclasses, to allow the :modify_associations
|
264
|
+
# plugin option to affect them. The :file and :coverage_file are not copied
|
265
|
+
# to subclasses, as users are expected ot call methods such as
|
266
|
+
# unused_associations only on the class that is loading the plugin.
|
267
|
+
Plugins.inherited_instance_variables(self, :@unused_associations_data=>nil)
|
268
|
+
|
269
|
+
# Synchronize access to the used association reflections.
|
270
|
+
def used_association_reflections
|
271
|
+
Sequel.synchronize{@used_association_reflections ||= {}}
|
272
|
+
end
|
273
|
+
|
274
|
+
# Record access to association reflections to determine which associations are not used.
|
275
|
+
def association_reflection(association)
|
276
|
+
uar = used_association_reflections
|
277
|
+
Sequel.synchronize{uar[association] ||= true}
|
278
|
+
super
|
279
|
+
end
|
280
|
+
|
281
|
+
# If modifying associations, and this association is marked as not used,
|
282
|
+
# and the association does not include the specific :is_used option,
|
283
|
+
# skip defining the association.
|
284
|
+
def associate(type, assoc_name, opts=OPTS)
|
285
|
+
if !opts[:is_used] && @unused_associations_data && (data = @unused_associations_data[name]) && data[assoc_name.to_s] == 'unused'
|
286
|
+
return
|
287
|
+
end
|
288
|
+
|
289
|
+
super
|
290
|
+
end
|
291
|
+
|
292
|
+
# Setup the used_association_reflections storage before freezing
|
293
|
+
def freeze
|
294
|
+
used_association_reflections
|
295
|
+
super
|
296
|
+
end
|
297
|
+
|
298
|
+
# Parse the coverage result, and return the coverage data for the
|
299
|
+
# associations for descendants of this class. If the plugin
|
300
|
+
# uses the :coverage_file option, the existing coverage file will be loaded
|
301
|
+
# if present, and before the method returns, the coverage file will be updated.
|
302
|
+
#
|
303
|
+
# Options:
|
304
|
+
# :coverage_result :: The coverage result to use. This defaults to +Coverage.result+.
|
305
|
+
def update_associations_coverage(opts=OPTS)
|
306
|
+
coverage_result = opts[:coverage_result] || Coverage.result
|
307
|
+
module_mapping = {}
|
308
|
+
file = @unused_associations_coverage_file
|
309
|
+
|
310
|
+
coverage_data = if file && File.file?(file)
|
311
|
+
Sequel.parse_json(File.binread(file))
|
312
|
+
else
|
313
|
+
{}
|
314
|
+
end
|
315
|
+
|
316
|
+
([self] + descendents).each do |sc|
|
317
|
+
next if sc.associations.empty? || !sc.name
|
318
|
+
module_mapping[sc.send(:overridable_methods_module)] = sc
|
319
|
+
cov_data = coverage_data[sc.name] ||= {''=>[]}
|
320
|
+
cov_data[''].concat(sc.used_association_reflections.keys.map(&:to_s).sort).uniq!
|
321
|
+
end
|
322
|
+
|
323
|
+
coverage_result.each do |file, coverage|
|
324
|
+
coverage[:methods].each do |(mod, meth), times|
|
325
|
+
next unless sc = module_mapping[mod]
|
326
|
+
coverage_data[sc.name][meth.to_s] ||= 0
|
327
|
+
coverage_data[sc.name][meth.to_s] += times
|
328
|
+
end
|
329
|
+
end
|
330
|
+
|
331
|
+
if file
|
332
|
+
File.binwrite(file, Sequel.object_to_json(coverage_data))
|
333
|
+
end
|
334
|
+
|
335
|
+
coverage_data
|
336
|
+
end
|
337
|
+
|
338
|
+
# Parse the coverage data returned by #update_associations_coverage,
|
339
|
+
# and return data on unused associations and unused association methods.
|
340
|
+
#
|
341
|
+
# Options:
|
342
|
+
# :coverage_data :: The coverage data to use. If not given, it is taken
|
343
|
+
# from the file specified by the :coverage_file plugin option.
|
344
|
+
# :keep_coverage :: Do not delete the file specified by the :coverage_file plugin
|
345
|
+
# option, even if it exists.
|
346
|
+
def update_unused_associations_data(options=OPTS)
|
347
|
+
coverage_data = options[:coverage_data] || Sequel.parse_json(File.binread(@unused_associations_coverage_file))
|
348
|
+
|
349
|
+
unused_associations_data = {}
|
350
|
+
|
351
|
+
([self] + descendents).each do |sc|
|
352
|
+
next unless cov_data = coverage_data[sc.name]
|
353
|
+
reflection_data = cov_data[''] || []
|
354
|
+
|
355
|
+
sc.association_reflections.each do |assoc, ref|
|
356
|
+
# Only report associations for the class they are defined in
|
357
|
+
next unless ref[:model] == sc
|
358
|
+
|
359
|
+
# Do not report associations using methods_module option, because this plugin only
|
360
|
+
# looks in the class's overridable_methods_module
|
361
|
+
next if ref[:methods_module]
|
362
|
+
|
363
|
+
info = {}
|
364
|
+
if reflection_data.include?(assoc.to_s)
|
365
|
+
info[:used] = [:reflection]
|
366
|
+
end
|
367
|
+
|
368
|
+
_update_association_coverage_info(info, cov_data, ref.dataset_method, :dataset_method)
|
369
|
+
_update_association_coverage_info(info, cov_data, ref.association_method, :association_method)
|
370
|
+
|
371
|
+
unless ref[:orig_opts][:read_only]
|
372
|
+
if ref.returns_array?
|
373
|
+
_update_association_coverage_info(info, cov_data, ref[:add_method], :adder)
|
374
|
+
_update_association_coverage_info(info, cov_data, ref[:remove_method], :remover)
|
375
|
+
_update_association_coverage_info(info, cov_data, ref[:remove_all_method], :clearer)
|
376
|
+
else
|
377
|
+
_update_association_coverage_info(info, cov_data, ref[:setter_method], :setter)
|
378
|
+
end
|
379
|
+
end
|
380
|
+
|
381
|
+
next if info.keys == [:missing]
|
382
|
+
|
383
|
+
if !info[:used]
|
384
|
+
(unused_associations_data[sc.name] ||= {})[assoc.to_s] = 'unused'
|
385
|
+
elsif unused = info[:unused]
|
386
|
+
if unused.include?(:setter) || [:adder, :remover, :clearer].all?{|k| unused.include?(k)}
|
387
|
+
[:setter, :adder, :remover, :clearer].each do |k|
|
388
|
+
unused.delete(k)
|
389
|
+
end
|
390
|
+
unused << :read_only
|
391
|
+
end
|
392
|
+
(unused_associations_data[sc.name] ||= {})[assoc.to_s] = unused.map(&:to_s)
|
393
|
+
end
|
394
|
+
end
|
395
|
+
end
|
396
|
+
|
397
|
+
if @unused_associations_file
|
398
|
+
File.binwrite(@unused_associations_file, Sequel.object_to_json(unused_associations_data))
|
399
|
+
end
|
400
|
+
unless options[:keep_coverage]
|
401
|
+
_delete_unused_associations_file(@unused_associations_coverage_file)
|
402
|
+
end
|
403
|
+
|
404
|
+
unused_associations_data
|
405
|
+
end
|
406
|
+
|
407
|
+
# Return an array of unused associations. These are associations where none of the
|
408
|
+
# association methods are used, according to the coverage information. Each entry
|
409
|
+
# in the array is an array of two strings, with the first string being the class name
|
410
|
+
# and the second string being the association name.
|
411
|
+
#
|
412
|
+
# Options:
|
413
|
+
# :unused_associations_data :: The data to use for determining which associations
|
414
|
+
# are unused, which is returned from
|
415
|
+
# +update_unused_associations_data+. If not given,
|
416
|
+
# loads the data from the file specified by the :file
|
417
|
+
# plugin option.
|
418
|
+
def unused_associations(opts=OPTS)
|
419
|
+
unused_associations_data = opts[:unused_associations_data] || Sequel.parse_json(File.binread(@unused_associations_file))
|
420
|
+
|
421
|
+
unused_associations = []
|
422
|
+
unused_associations_data.each do |sc, associations|
|
423
|
+
associations.each do |assoc, unused|
|
424
|
+
if unused == 'unused'
|
425
|
+
unused_associations << [sc, assoc]
|
426
|
+
end
|
427
|
+
end
|
428
|
+
end
|
429
|
+
unused_associations
|
430
|
+
end
|
431
|
+
|
432
|
+
# Return an array of unused association options. These are associations some but not all
|
433
|
+
# of the association methods are used, according to the coverage information. Each entry
|
434
|
+
# in the array is an array of three elements. The first element is the class name string,
|
435
|
+
# the second element is the association name string, and the third element is a hash of
|
436
|
+
# association options that can be used in the association so it does not define methods
|
437
|
+
# that are not used.
|
438
|
+
#
|
439
|
+
# Options:
|
440
|
+
# :unused_associations_data :: The data to use for determining which associations
|
441
|
+
# are unused, which is returned from
|
442
|
+
# +update_unused_associations_data+. If not given,
|
443
|
+
# loads the data from the file specified by the :file
|
444
|
+
# plugin option.
|
445
|
+
def unused_association_options(opts=OPTS)
|
446
|
+
unused_associations_data = opts[:unused_associations_data] || Sequel.parse_json(File.binread(@unused_associations_file))
|
447
|
+
|
448
|
+
unused_association_methods = []
|
449
|
+
unused_associations_data.each do |sc, associations|
|
450
|
+
associations.each do |assoc, unused|
|
451
|
+
unless unused == 'unused'
|
452
|
+
unused_association_methods << [sc, assoc, set_unused_options_for_association({}, unused)]
|
453
|
+
end
|
454
|
+
end
|
455
|
+
end
|
456
|
+
unused_association_methods
|
457
|
+
end
|
458
|
+
|
459
|
+
# Delete the unused associations coverage file and unused associations data file,
|
460
|
+
# if either exist.
|
461
|
+
def delete_unused_associations_files
|
462
|
+
_delete_unused_associations_file(@unused_associations_coverage_file)
|
463
|
+
_delete_unused_associations_file(@unused_associations_file)
|
464
|
+
end
|
465
|
+
|
466
|
+
private
|
467
|
+
|
468
|
+
# Delete the given file if it exists.
|
469
|
+
def _delete_unused_associations_file(file)
|
470
|
+
if file && File.file?(file)
|
471
|
+
File.unlink(file)
|
472
|
+
end
|
473
|
+
end
|
474
|
+
|
475
|
+
# Update the info hash with information on whether the given method was
|
476
|
+
# called, according to the coverage information.
|
477
|
+
def _update_association_coverage_info(info, coverage_data, meth, key)
|
478
|
+
type = case coverage_data[meth.to_s]
|
479
|
+
when 0
|
480
|
+
:unused
|
481
|
+
when Integer
|
482
|
+
:used
|
483
|
+
else
|
484
|
+
# Missing here means there is no coverage information for the
|
485
|
+
# the method, which indicates the expected method was never
|
486
|
+
# defined. In that case, it can be ignored.
|
487
|
+
:missing
|
488
|
+
end
|
489
|
+
|
490
|
+
(info[type] ||= []) << key
|
491
|
+
end
|
492
|
+
|
493
|
+
# Based on the value of the unused, update the opts hash with association
|
494
|
+
# options that will prevent unused association methods from being
|
495
|
+
# defined.
|
496
|
+
def set_unused_options_for_association(opts, unused)
|
497
|
+
opts[:read_only] = true if unused.include?('read_only')
|
498
|
+
opts[:no_dataset_method] = true if unused.include?('dataset_method')
|
499
|
+
opts[:no_association_method] = true if unused.include?('association_method')
|
500
|
+
opts[:adder] = nil if unused.include?('adder')
|
501
|
+
opts[:remover] = nil if unused.include?('remover')
|
502
|
+
opts[:clearer] = nil if unused.include?('clearer')
|
503
|
+
opts
|
504
|
+
end
|
505
|
+
|
506
|
+
# If modifying associations, and this association has unused association
|
507
|
+
# methods, automatically set the appropriate options so the unused association
|
508
|
+
# methods are not defined, unless the association explicitly uses the :is_used
|
509
|
+
# options.
|
510
|
+
def def_association(opts)
|
511
|
+
if !opts[:is_used] && @unused_associations_data && (data = @unused_associations_data[name]) && (unused = data[opts[:name].to_s])
|
512
|
+
set_unused_options_for_association(opts, unused)
|
513
|
+
end
|
514
|
+
|
515
|
+
super
|
516
|
+
end
|
517
|
+
end
|
518
|
+
end
|
519
|
+
end
|
520
|
+
end
|
521
|
+
# :nocov:
|