support_table_data 1.6.0 → 1.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6aa6d124e04e60a539f6a56207da866bb6aa0d277484104f6685d9499b41d504
4
- data.tar.gz: f3c9791bf9e6b2a46416cca2a154abdb1fe18e108f3e386670818939cabac1a2
3
+ metadata.gz: 7e8cc498db34d48c4547f0c076553eb1c087b198d9b76d467558bcfd9a5249f7
4
+ data.tar.gz: 38b94d4df77e5b75ed7b24c0e21f129d37f1a3a07cf6cc6ce515c537743cc45b
5
5
  SHA512:
6
- metadata.gz: cec5bdca6906dac42489e2f43798bf647eebaf6fd1df1d314bb259cf46170c6f7833aeccd7f7d59917ea0406699f6cc99905afc5977bd907c1e19f750dc0b3e5
7
- data.tar.gz: eb748280d59753b9bde5618900d0f25ae8e01c3ace0a37cdb5021cad1f1751832b14cc6e4bd624b200398178cd8bd3636e5d8d128c829180fe3c547d67ac88db
6
+ metadata.gz: d64e26277408f30222ad135a317172cf0d2ae288bcfdc4c7b8a4113b52f9477d4cb2e0536b9feacdcdeb9af3044c0220c6b16131dee18f468455c2260b305979
7
+ data.tar.gz: df40bb09b9688ac653f8e590fff81d50d4d9cf18d3b3c797d6f28e02603c0fe7a1375b8426ad3df75392fba51cc4b24e8973c52bf8994011542741c11a067444
data/CHANGELOG.md CHANGED
@@ -4,6 +4,41 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 1.7.0
8
+
9
+ ### Fixed
10
+
11
+ - `sync_table_data!` now retries once on `ActiveRecord::RecordNotUnique` errors caused by a concurrent sync in another process inserting the same rows.
12
+ - `sync_table_data!` with `delete_missing: true` now raises an `ArgumentError` instead of deleting every row in the table when the data files contain no rows.
13
+ - `sync_table_data!` now raises an `ArgumentError` when a data file row has no value for the key attribute. Previously such rows were collapsed into a single record with a blank key.
14
+ - `sync_table_data!` now returns an empty array instead of `nil` when the table does not exist.
15
+ - Fixed broken cycle detection in the autosave association check during syncs that could cause infinite recursion on cyclic autosave associations.
16
+ - Syncing a single table inheritance subclass no longer inserts duplicate or wrongly typed rows for records defined in the base class' data files. Existing rows are matched without the inheritance type condition, and new rows default to the type of the class whose data file defines them.
17
+ - Single table inheritance subclasses now share the support table state defined on their base class regardless of load order. Previously class methods like `instance_names` and `protected_instance?` raised `NoMethodError` and named instance helpers could be missing on subclasses.
18
+ - `protected_instance?` and `instance_keys` now include data files added to single table inheritance subclasses and no longer return stale results when data files are added after their values were first computed.
19
+ - `sync_table_data!` with `delete_missing: true` no longer deletes rows that are managed by data files added to single table inheritance subclasses.
20
+ - Records are now merged in strict data file order, so later files take precedence even when named instance files are mixed with list format files.
21
+ - Data files that override attributes on a named instance are now merged by the instance name rather than only by the key attribute. Previously an override that did not repeat the key attribute value was treated as a new record and inserted as an extra row on every sync.
22
+ - Named instance helper methods and `named_instance_data` now return the merged values that are synced to the database. Previously the helpers permanently returned the values from the first file that defined the named instance.
23
+ - Entries under a name beginning with an underscore are treated as anonymous records even when the value is a single hash. Previously two files reusing the same underscore name each with a single hash were merged into one record, silently dropping rows.
24
+ - YAML data files can now use anchors/aliases and date/time values. Previously these raised `Psych::AliasesNotEnabled` or `Psych::DisallowedClass` errors.
25
+ - Generated predicate methods (e.g. `record.active?`) now cast the data file value to the attribute type before comparing, so they no longer silently return `false` when the types differ (always the case for CSV data files, where all values are strings).
26
+ - `named_instance` now raises an `ActiveRecord::RecordNotFound` error for undefined named instances instead of querying the database for a `nil` key.
27
+ - `named_instance_attribute_helpers` can now be called again with an attribute that was already registered without raising an `ArgumentError`.
28
+ - Modifications to memoized class-level state are now synchronized to avoid races on Ruby implementations without a global interpreter lock.
29
+ - Setting `config.support_table.auto_sync = false` before the gem is loaded is no longer overwritten back to `true` by the Railtie.
30
+ - Data file names containing extra dots no longer break the class name detection used by `SupportTableData.sync_all!` to eager load models.
31
+ - Error messages for invalid named instance definitions now include the model class name instead of repeating the instance name.
32
+ - The `:compact` YARD format now emits macros that YARD can actually expand. Previously the generated docs resolved to methods with no description, `@return`, or `@raise` tags and documented the predicate methods as class methods. Macro names are also namespaced per class so models do not overwrite each other's macros.
33
+ - The documentation tasks now remove duplicated generated YARD doc blocks (e.g. left over from a bad merge) instead of corrupting the model source file.
34
+ - The documentation tasks no longer report success when a model raises an `ArgumentError` for an invalid named instance definition.
35
+
36
+ ## 1.6.1
37
+
38
+ ### Fixed
39
+
40
+ - Fixed issue with YARD and RBS documentation tasks possibly raising an error if a named value method is deprecated and wrapped to return an error. Types are now inferred directly from the data rather than calling a method.
41
+
7
42
  ## 1.6.0
8
43
 
9
44
  ### Added
data/README.md CHANGED
@@ -68,6 +68,8 @@ class Status < ApplicationRecord
68
68
 
69
69
  You cannot update the value of the key attribute in a record in the data file. If you do, a new record will be created and the existing record will be left unchanged.
70
70
 
71
+ When you add multiple data files, records defined in more than one file are merged together, with later files taking precedence. Records in files defined as a hash of named instances are matched by the instance name, so an override file only needs to specify the attributes it is changing. Records in files defined as a list are matched by the key attribute value, which must be repeated in each file.
72
+
71
73
  You can specify data files as relative paths. This can be done by setting the `SupportTableData.data_directory` value. You can override this value for a model by setting the `support_table_data_directory` attribute on its class. Otherwise, relative file paths will be resolved from the current working directory. You must define the directory to load relative files from before loading your model classes.
72
74
 
73
75
  In a Rails application, `SupportTableData.data_directory` will be automatically set to `db/support_tables/`. This can be overridden by setting the `config.support_table.data_directory` option in the Rails application configuration.
@@ -203,7 +205,7 @@ A good practice is to add a check to your CI pipeline to ensure the documentatio
203
205
  Each model can choose how its YARD docs are generated by setting `support_table_yard_docs` to one of three values:
204
206
 
205
207
  - `:full` — verbose comment block per generated method (the default)
206
- - `:compact` — shared `@!macro` definitions at the top of the documentation block plus a short `@!method` / `@!macro` pair per generated method. IDEs and `yard doc` resolve the macros into the same per-method documentation as `:full`. Useful when a model has many named instances and the verbose comment block is too long to be useful inline.
208
+ - `:compact` — shared `@!macro` definitions plus a short comment block per generated method that expands them. YARD resolves it to exactly the same methods and return types as `:full`. Useful when a model has many named instances and the verbose comment block is too long to be useful inline.
207
209
  - `:none` — generate no YARD docs for this model. The rake task will also strip any previously generated YARD docs from the source file.
208
210
 
209
211
  ```ruby
@@ -279,6 +281,12 @@ SupportTableData.sync_all!(delete_missing: true)
279
281
  > [!CAUTION]
280
282
  > Use `delete_missing` with care. It will delete any records in the table that are not defined in the data files, which may include user-created data or fail due to foreign key constraints.
281
283
 
284
+ As a safeguard, `sync_table_data!` will raise an `ArgumentError` rather than delete anything when `delete_missing` is enabled but the data files contain no rows (for instance, when a data file was accidentally emptied or truncated).
285
+
286
+ Rows managed by data files added to a single table inheritance subclass are never deleted by a sync on the base class, even if the subclass has not been loaded yet. Each candidate row is resolved to its own class through the inheritance column before it is deleted, so the subclass is loaded on demand and gets to declare which rows it owns.
287
+
288
+ It is recommended to add a unique database index on the key attribute column. Concurrent syncs from multiple processes (for example, parallel deployment jobs) could otherwise insert duplicate rows. If a sync hits a uniqueness violation from a concurrent insert, it will automatically retry once to pick up the other process' changes.
289
+
282
290
  The number of records contained in data files should be fairly small (ideally fewer than 100). It is possible to load just a subset of rows in a large table because only the rows listed in the data files will be synced. You can use this feature if your table allows user-entered data, but has a few rows that must exist for the code to work.
283
291
 
284
292
  Loading data is done inside a database transaction. No changes will be persisted to the database unless all rows for a model can be synced.
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.6.0
1
+ 1.7.0
@@ -42,7 +42,7 @@ module SupportTableData
42
42
  lines << "def self.#{name}: () -> #{klass.name}"
43
43
  lines << "def #{name}?: () -> bool"
44
44
  klass.support_table_attribute_helpers.each do |attribute_name|
45
- return_type = TypeInference.rbs_type(TypeInference.value_type(klass, "#{name}_#{attribute_name}"))
45
+ return_type = TypeInference.rbs_type(TypeInference.value_type(klass, name, attribute_name))
46
46
  lines << "def self.#{name}_#{attribute_name}: () -> #{return_type}"
47
47
  end
48
48
  lines
@@ -7,7 +7,7 @@ module SupportTableData
7
7
 
8
8
  BEGIN_YARD_COMMENT = "# Begin YARD docs for support_table_data"
9
9
  END_YARD_COMMENT = "# End YARD docs for support_table_data"
10
- YARD_COMMENT_REGEX = /^(?<indent>[ \t]*)#{BEGIN_YARD_COMMENT}.*^[ \t]*#{END_YARD_COMMENT}$/m
10
+ YARD_COMMENT_REGEX = /^(?<indent>[ \t]*)#{BEGIN_YARD_COMMENT}.*?^[ \t]*#{END_YARD_COMMENT}$/m
11
11
  CLASS_DEF_REGEX = /^[ \t]*class [a-zA-Z_0-9:]+.*?$/
12
12
  UPDATE_COMMAND_COMMENT = "# To update these docs, run `bundle exec rake support_table_data:yard_docs`"
13
13
 
@@ -32,7 +32,7 @@ module SupportTableData
32
32
  #
33
33
  # @return [String]
34
34
  def source_without_yard_docs
35
- "#{source.sub(YARD_COMMENT_REGEX, "").rstrip}#{trailing_newline}"
35
+ "#{source.gsub(YARD_COMMENT_REGEX, "").rstrip}#{trailing_newline}"
36
36
  end
37
37
 
38
38
  # Return the source code with the generated YARD documentation added.
@@ -63,8 +63,19 @@ module SupportTableData
63
63
  updated_source << "\n#{indent}end" if has_class_def
64
64
  updated_source << "\n#{indent}# rubocop:enable all"
65
65
  updated_source << "\n#{indent}#{END_YARD_COMMENT}"
66
- updated_source << source[existing_yard_docs.end(0)..-1]
67
- updated_source
66
+ # Strip out any duplicate generated blocks (i.e. left over from a bad merge)
67
+ # so that the file is left with exactly one block. Otherwise the tail of the
68
+ # file is preserved verbatim so that a file whose docs are already up to date
69
+ # compares byte for byte with its own source.
70
+ tail = source[existing_yard_docs.end(0)..]
71
+ deduped_tail = tail.gsub(YARD_COMMENT_REGEX, "")
72
+ if deduped_tail == tail
73
+ updated_source << tail
74
+ updated_source
75
+ else
76
+ updated_source << deduped_tail
77
+ "#{updated_source.rstrip}#{trailing_newline}"
78
+ end
68
79
  else
69
80
  yard_comments = <<~SOURCE.chomp("\n")
70
81
  #{BEGIN_YARD_COMMENT}
@@ -3,26 +3,28 @@
3
3
  module SupportTableData
4
4
  module Documentation
5
5
  # Infers documentation types for the dynamically-defined attribute helpers
6
- # by calling the generated method and inspecting the class of the value
7
- # it returns. The values returned by these helpers are frozen literals from
8
- # the parsed data file, so this does not require a database connection.
9
- #
10
- # This module must not be used on finder helpers (e.g. `Color.red`), which
11
- # call `find_by!` and would hit the database.
6
+ # by reading the canonical value out of the parsed data file and
7
+ # inspecting its class. This avoids invoking the generated method, which
8
+ # may have been wrapped (e.g. deprecated) to raise.
12
9
  module TypeInference
13
10
  module_function
14
11
 
15
- # Determine the documentation type for an attribute helper by calling
16
- # the method and looking at the class of the returned value. Returns
17
- # nil when the method is not defined.
12
+ # Determine the documentation type for a named-instance attribute
13
+ # helper by looking up the attribute value in the model's named
14
+ # instance data and returning its class. Returns nil when the
15
+ # attribute is not defined for the named instance.
18
16
  #
19
17
  # @param klass [Class] The model class
20
- # @param method_name [String, Symbol] The class method name to call
18
+ # @param name [String, Symbol] The named instance name
19
+ # @param attribute_name [String, Symbol] The attribute name
21
20
  # @return [Class, nil]
22
- def value_type(klass, method_name)
23
- return nil unless klass.respond_to?(method_name)
21
+ def value_type(klass, name, attribute_name)
22
+ return nil unless klass.respond_to?(:named_instance_data)
24
23
 
25
- klass.public_send(method_name).class
24
+ data = klass.named_instance_data(name)
25
+ return nil unless data.is_a?(Hash) && data.key?(attribute_name.to_s)
26
+
27
+ data[attribute_name.to_s].class
26
28
  end
27
29
 
28
30
  # Map a Ruby value class to a YARD type string.
@@ -3,10 +3,6 @@
3
3
  module SupportTableData
4
4
  module Documentation
5
5
  class YardDoc
6
- MACRO_FINDER = "support_table_data_finder"
7
- MACRO_PREDICATE = "support_table_data_predicate"
8
- MACRO_ATTRIBUTE = "support_table_data_attribute"
9
-
10
6
  # @param klass [Class] The model class to generate documentation for
11
7
  def initialize(klass)
12
8
  @klass = klass
@@ -16,8 +12,8 @@ module SupportTableData
16
12
  # is controlled by the model's `support_table_yard_docs` setting:
17
13
  #
18
14
  # * `:full` - verbose comment block per method (default)
19
- # * `:compact` - shared @!macro definitions plus a short @!method/@!macro
20
- # pair per generated method
15
+ # * `:compact` - shared @!macro definitions plus a short comment block
16
+ # per method that expands them
21
17
  # * `:none` - generate no docs at all
22
18
  #
23
19
  # @return [String, nil] The YARD documentation, or nil if no docs should
@@ -107,16 +103,27 @@ module SupportTableData
107
103
  yard_lines.join("\n")
108
104
  end
109
105
 
106
+ # The compact format defines the shared documentation once as macros and expands
107
+ # them under each generated method. YARD imposes three rules on this layout: each
108
+ # method needs its own comment block (tags and the `self.` scope apply to the whole
109
+ # block), a macro invocation must be indented under its @!method line to attach to
110
+ # that method, and macro parameters ($1, etc.) cannot be used because YARD only
111
+ # fills them in from real method calls in the source. Macro names are global to the
112
+ # YARD registry, so they are namespaced with the class name.
110
113
  def generate_compact_yard_docs(instance_names)
111
114
  yard_lines = ["# @!group Named Instances"]
112
115
  yard_lines << ""
113
- yard_lines << compact_preamble
114
- yard_lines << ""
115
116
  yard_lines << compact_macro_definitions
116
117
 
117
118
  instance_names.sort.each do |name|
118
119
  yard_lines << ""
119
- yard_lines << compact_instance_block(name)
120
+ yard_lines << compact_instance_helper_yard_doc(name)
121
+ yard_lines << ""
122
+ yard_lines << compact_predicate_helper_yard_doc(name)
123
+ klass.support_table_attribute_helpers.each do |attribute_name|
124
+ yard_lines << ""
125
+ yard_lines << compact_attribute_helper_yard_doc(name, attribute_name)
126
+ end
120
127
  end
121
128
 
122
129
  yard_lines << ""
@@ -125,57 +132,67 @@ module SupportTableData
125
132
  yard_lines.join("\n")
126
133
  end
127
134
 
128
- def compact_preamble
129
- <<~YARD.chomp("\n")
130
- # The methods in this group are dynamically defined by support_table_data
131
- # for each named instance in the data file. The macros below are the
132
- # documentation templates; the per-instance @!method lines that follow
133
- # invoke them with the instance name (and attribute name, where applicable).
134
- YARD
135
- end
136
-
137
135
  def compact_macro_definitions
138
- attribute_macro = <<~YARD.chomp("\n")
139
- # @!macro [new] #{MACRO_ATTRIBUTE}
140
- # Get the +$2+ attribute from the data file for the named instance +$1+.
141
- # @return [$3]
142
- # @!visibility public
143
- YARD
144
-
145
136
  finder_macro = <<~YARD.chomp("\n")
146
- # @!macro [new] #{MACRO_FINDER}
147
- # Find the named instance +$1+ from the database.
137
+ # @!macro [new] #{compact_macro_name("finder")}
138
+ # Find this named instance from the database.
148
139
  # @return [#{klass.name}]
149
140
  # @raise [ActiveRecord::RecordNotFound] if the record does not exist
150
141
  # @!visibility public
151
142
  YARD
152
143
 
153
144
  predicate_macro = <<~YARD.chomp("\n")
154
- # @!macro [new] #{MACRO_PREDICATE}
155
- # Check if this record is the named instance +$1+.
145
+ # @!macro [new] #{compact_macro_name("predicate")}
146
+ # Check if this record is this named instance.
156
147
  # @return [Boolean]
157
148
  # @!visibility public
158
149
  YARD
159
150
 
160
- [finder_macro, "", predicate_macro, "", attribute_macro].join("\n")
161
- end
151
+ macros = [finder_macro, "", predicate_macro]
162
152
 
163
- def compact_instance_block(name)
164
- lines = []
165
- lines << "# @!method self.#{name}"
166
- lines << "# @!macro #{MACRO_FINDER} #{name}"
167
- lines << "# @!method #{name}?"
168
- lines << "# @!macro #{MACRO_PREDICATE} #{name}"
169
- klass.support_table_attribute_helpers.each do |attribute_name|
170
- return_type = attribute_yard_return_type(name, attribute_name)
171
- lines << "# @!method self.#{name}_#{attribute_name}"
172
- lines << "# @!macro #{MACRO_ATTRIBUTE} #{name} #{attribute_name} #{return_type}"
153
+ if klass.support_table_attribute_helpers.any?
154
+ attribute_macro = <<~YARD.chomp("\n")
155
+ # @!macro [new] #{compact_macro_name("attribute")}
156
+ # Get this attribute from the data file for this named instance.
157
+ # @!visibility public
158
+ YARD
159
+ macros << ""
160
+ macros << attribute_macro
173
161
  end
174
- lines.join("\n")
162
+
163
+ macros.join("\n")
164
+ end
165
+
166
+ def compact_instance_helper_yard_doc(name)
167
+ <<~YARD.chomp("\n")
168
+ # @!method self.#{name}
169
+ # @!macro #{compact_macro_name("finder")}
170
+ YARD
171
+ end
172
+
173
+ def compact_predicate_helper_yard_doc(name)
174
+ <<~YARD.chomp("\n")
175
+ # @!method #{name}?
176
+ # @!macro #{compact_macro_name("predicate")}
177
+ YARD
178
+ end
179
+
180
+ # The attribute macro cannot carry the @return tag because the return type differs
181
+ # per method, so each attribute method adds its own.
182
+ def compact_attribute_helper_yard_doc(name, attribute_name)
183
+ <<~YARD.chomp("\n")
184
+ # @!method self.#{name}_#{attribute_name}
185
+ # @!macro #{compact_macro_name("attribute")}
186
+ # @return [#{attribute_yard_return_type(name, attribute_name)}]
187
+ YARD
188
+ end
189
+
190
+ def compact_macro_name(suffix)
191
+ "support_table_#{klass.name.underscore.tr("/", "_")}_#{suffix}"
175
192
  end
176
193
 
177
194
  def attribute_yard_return_type(name, attribute_name)
178
- TypeInference.yard_type(TypeInference.value_type(klass, "#{name}_#{attribute_name}"))
195
+ TypeInference.yard_type(TypeInference.value_type(klass, name, attribute_name))
179
196
  end
180
197
  end
181
198
  end
@@ -7,7 +7,7 @@ module SupportTableData
7
7
  end
8
8
 
9
9
  config.support_table.data_directory ||= "db/support_tables"
10
- config.support_table.auto_sync ||= true
10
+ config.support_table.auto_sync = true if config.support_table.auto_sync.nil?
11
11
 
12
12
  initializer "support_table_data" do |app|
13
13
  SupportTableData.data_directory ||= app.root.join(app.config.support_table&.data_directory).to_s
@@ -23,20 +23,20 @@ module SupportTableData
23
23
  # @param file_path [String, Pathname, nil] Optional file path to filter by.
24
24
  # @return [Array<SupportTableData::Documentation::SourceFile>]
25
25
  def support_table_sources(file_path = nil)
26
- file_path = Pathname.new(file_path) if file_path.is_a?(String)
27
- require file_path.expand_path if file_path
26
+ resolved_path = expand_file_path(file_path)
27
+ return [] if file_path && resolved_path.nil?
28
+
29
+ require resolved_path.to_s if resolved_path
28
30
 
29
31
  sources = []
30
32
 
31
33
  ActiveRecord::Base.descendants.each do |klass|
32
34
  next unless klass.include?(SupportTableData)
33
-
34
- begin
35
- next if klass.instance_names.empty?
36
- rescue NoMethodError
37
- # Skip models where instance_names is not properly initialized
38
- next
39
- end
35
+ # Only the class that added the data files is documented. Single table inheritance
36
+ # subclasses that don't add their own data files inherit the helper methods and
37
+ # would otherwise duplicate the base class documentation.
38
+ next unless klass.instance_variable_defined?(:@support_table_data_files)
39
+ next if klass.instance_names.empty?
40
40
 
41
41
  model_file_path = SupportTableData::Tasks::Utils.model_file_path(klass)
42
42
  next unless model_file_path&.file? && model_file_path.readable?
@@ -44,12 +44,9 @@ module SupportTableData
44
44
  sources << Documentation::SourceFile.new(klass, model_file_path)
45
45
  end
46
46
 
47
- return sources if file_path.nil?
47
+ return sources if resolved_path.nil?
48
48
 
49
- resolved_path = Pathname.new(file_path.to_s).expand_path
50
49
  sources.select { |source| source.path.expand_path == resolved_path }
51
- rescue ArgumentError
52
- []
53
50
  end
54
51
 
55
52
  # Return RBS file handlers for all support table models.
@@ -62,7 +59,21 @@ module SupportTableData
62
59
  end
63
60
  end
64
61
 
62
+ # Expand a file path argument to an absolute path.
63
+ #
64
+ # @param file_path [String, Pathname, nil]
65
+ # @return [Pathname, nil] nil if no path was given or it cannot be expanded.
66
+ def expand_file_path(file_path)
67
+ return nil if file_path.nil?
68
+
69
+ Pathname.new(file_path.to_s).expand_path
70
+ rescue ArgumentError
71
+ nil
72
+ end
73
+
65
74
  def model_file_path(klass)
75
+ return nil unless klass.name
76
+
66
77
  file_path = "#{klass.name.underscore}.rb"
67
78
  model_path = nil
68
79
 
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/core_ext/module/redefine_method"
4
+ require "concurrent/map"
5
+
3
6
  # This concern can be mixed into models that represent static support tables. These are small tables
4
7
  # that have a limited number of rows, and have values that are often tied to the logic in the code.
5
8
  #
@@ -19,7 +22,7 @@ module SupportTableData
19
22
 
20
23
  included do
21
24
  # Internal variables used for memoization.
22
- @mutex = Mutex.new
25
+ @support_table_mutex = Mutex.new
23
26
  @support_table_data_files = []
24
27
  @support_table_attribute_helpers = {}
25
28
  @support_table_instance_names = {}
@@ -67,8 +70,8 @@ module SupportTableData
67
70
  # Get the YARD documentation mode for this model. One of:
68
71
  #
69
72
  # * `:full` - emit a verbose comment block per generated method (default)
70
- # * `:compact` - emit shared @!macro definitions plus a short
71
- # @!method/@!macro pair per generated method
73
+ # * `:compact` - emit shared @!macro definitions plus a short comment
74
+ # block per generated method that expands them
72
75
  # * `:none` - generate no YARD docs for this model; the rake task will
73
76
  # strip any existing generated YARD docs
74
77
  #
@@ -97,55 +100,84 @@ module SupportTableData
97
100
  # files will be deleted. Use with caution.
98
101
  # @return [Array<Hash>] List of saved changes for each record that was created or modified.
99
102
  def sync_table_data!(delete_missing: false)
100
- return unless table_exists?
101
-
102
- canonical_data = support_table_data.each_with_object({}) do |attributes, hash|
103
- hash[attributes[support_table_key_attribute].to_s] = attributes
104
- end
105
- records = where(support_table_key_attribute => canonical_data.keys)
106
- changes = []
107
-
108
- begin
109
- ActiveSupport::Notifications.instrument("support_table_data.sync", class: self) do
110
- synced_ids = []
111
-
112
- transaction do
113
- records.each do |record|
114
- key = record[support_table_key_attribute].to_s
115
- attributes = canonical_data.delete(key)
116
- attributes&.each do |name, value|
117
- record.send(:"#{name}=", value) if record.respond_to?(:"#{name}=", true)
118
- end
119
- if support_table_record_changed?(record)
120
- changes << record.changes
121
- record.save!
122
- end
123
-
124
- synced_ids << record.id if attributes
125
- end
103
+ return [] unless table_exists?
104
+
105
+ retried = false
106
+
107
+ # The instrumentation wraps the retry so that one call emits one event even if the
108
+ # sync has to be attempted twice.
109
+ ActiveSupport::Notifications.instrument("support_table_data.sync", class: self) do
110
+ canonical_data = support_table_data.each_with_object({}) do |attributes, hash|
111
+ hash[attributes[support_table_key_attribute].to_s] = attributes
112
+ end
113
+
114
+ if canonical_data.include?("")
115
+ raise ArgumentError.new("Cannot sync #{name} because the data files contain a row with no value for the key attribute #{support_table_key_attribute}")
116
+ end
126
117
 
127
- canonical_data.each_value do |attributes|
128
- class_name = attributes[inheritance_column]
129
- klass = class_name ? sti_class_for(class_name) : self
130
- record = klass.new
131
- attributes.each do |name, value|
132
- record.send(:"#{name}=", value) if record.respond_to?(:"#{name}=", true)
133
- end
118
+ if delete_missing && canonical_data.empty? && exists?
119
+ raise ArgumentError.new("Refusing to sync #{name} with delete_missing enabled because the data files contain no rows; this would delete every row in the table")
120
+ end
121
+
122
+ # Rows are matched on the key attribute alone, so the lookup must not be scoped
123
+ # to this class's single table inheritance type. A subclass syncing rows from
124
+ # inherited data files has to find them no matter what type they currently have
125
+ # or it would insert duplicates.
126
+ scope = finder_needs_type_condition? ? base_class : self
127
+ records = scope.where(support_table_key_attribute => canonical_data.keys)
128
+
129
+ # New rows default to the type of the class whose data files define them so that
130
+ # a subclass syncing inherited files does not create them with its own type.
131
+ record_classes = support_table_record_classes
132
+
133
+ changes = []
134
+ synced_ids = []
135
+
136
+ transaction do
137
+ records.each do |record|
138
+ key = record[support_table_key_attribute].to_s
139
+ attributes = canonical_data.delete(key)
140
+ attributes&.each do |name, value|
141
+ record.send(:"#{name}=", value) if record.respond_to?(:"#{name}=", true)
142
+ end
143
+ if support_table_record_changed?(record)
134
144
  changes << record.changes
135
145
  record.save!
136
- synced_ids << record.id
137
146
  end
138
147
 
139
- if delete_missing
140
- where.not(primary_key => synced_ids).destroy_all
148
+ synced_ids << record.id if attributes
149
+ end
150
+
151
+ canonical_data.each_value do |attributes|
152
+ class_name = attributes[inheritance_column]
153
+ klass = if class_name
154
+ sti_class_for(class_name)
155
+ else
156
+ record_classes[attributes[support_table_key_attribute].to_s] || self
141
157
  end
158
+ record = klass.new
159
+ attributes.each do |name, value|
160
+ record.send(:"#{name}=", value) if record.respond_to?(:"#{name}=", true)
161
+ end
162
+ changes << record.changes
163
+ record.save!
164
+ synced_ids << record.id
142
165
  end
166
+
167
+ delete_missing_records(where.not(primary_key => synced_ids)) if delete_missing
143
168
  end
169
+
170
+ changes
144
171
  rescue ActiveRecord::RecordInvalid => e
145
172
  raise SupportTableData::ValidationError.new(e.record)
146
- end
173
+ rescue ActiveRecord::RecordNotUnique
174
+ # A concurrent sync from another process may have inserted the same rows.
175
+ # The transaction was rolled back, so retry once to pick up those rows.
176
+ raise if retried
147
177
 
148
- changes
178
+ retried = true
179
+ retry
180
+ end
149
181
  end
150
182
 
151
183
  # Add a data file that contains the support table data. This method can be called multiple times to
@@ -156,10 +188,12 @@ module SupportTableData
156
188
  # this model or the global directory set with SupportTableData.data_directory.
157
189
  # @return [void]
158
190
  def add_support_table_data(data_file_path)
159
- root_dir = (support_table_data_directory || SupportTableData.data_directory || Dir.pwd)
160
- @mutex.synchronize do
161
- @support_table_data_files += [File.expand_path(data_file_path, root_dir)]
162
- @support_table_instance_keys = nil
191
+ root_dir = support_table_data_directory || SupportTableData.data_directory || Dir.pwd
192
+ support_table_mutex.synchronize do
193
+ # Only the files added directly to this class are stored on it. Files added to a
194
+ # base class are composed in at read time so a single table inheritance subclass
195
+ # sees files the base class adds later, no matter the order the classes set up in.
196
+ @support_table_data_files = (@support_table_data_files || []) + [File.expand_path(data_file_path, root_dir)]
163
197
  end
164
198
  define_support_table_named_instances
165
199
  end
@@ -172,8 +206,15 @@ module SupportTableData
172
206
  # @param attributes [String, Symbol] The names of the attributes to add helper methods for.
173
207
  # @return [void]
174
208
  def named_instance_attribute_helpers(*attributes)
175
- @mutex.synchronize do
209
+ support_table_mutex.synchronize do
210
+ # Single table inheritance subclasses read the map from their base class. Copy it
211
+ # (including the lists of method names that have been defined) the first time a
212
+ # subclass registers its own helpers so the two classes don't mutate each other's state.
213
+ @support_table_attribute_helpers ||= support_table_attribute_helpers_map.transform_values(&:dup)
214
+
176
215
  attributes.flatten.collect(&:to_s).each do |attribute|
216
+ next if @support_table_attribute_helpers.include?(attribute)
217
+
177
218
  @support_table_attribute_helpers = @support_table_attribute_helpers.merge(attribute => [])
178
219
  end
179
220
  end
@@ -185,59 +226,37 @@ module SupportTableData
185
226
  #
186
227
  # @return [Array<String>] List of attribute names.
187
228
  def support_table_attribute_helpers
188
- @support_table_attribute_helpers.keys
229
+ support_table_attribute_helpers_map.keys
189
230
  end
190
231
 
191
232
  # Get the data for the support table from the data files.
192
233
  #
193
234
  # @return [Array<Hash>] List of attributes for all records in the data files.
194
235
  def support_table_data
195
- data = {}
196
- @support_table_data_files.each do |data_file_path|
197
- file_data = support_table_parse_data_file(data_file_path)
198
- file_data = file_data.values if file_data.is_a?(Hash)
199
- file_data = Array(file_data).flatten
200
- file_data.each do |attributes|
201
- key_value = attributes[support_table_key_attribute].to_s
202
- existing = data[key_value]
203
- if existing
204
- existing.merge!(attributes)
205
- else
206
- data[key_value] = attributes
207
- end
208
- end
209
- end
236
+ support_table_data_for_files(support_table_data_files)
237
+ end
210
238
 
211
- data.values
239
+ # Get the data for the support table from a specific list of data files.
240
+ #
241
+ # @param data_files [Array<String>] The paths of the data files to read.
242
+ # @return [Array<Hash>] List of attributes for all records in the data files.
243
+ # @api private
244
+ def support_table_data_for_files(data_files)
245
+ support_table_merged_records(data_files).first
212
246
  end
213
247
 
214
248
  # Get the data for a named instances from the data files.
215
249
  #
216
250
  # @return [Hasn] Hash of named instance attributes.
217
251
  def named_instance_data(name)
218
- data = {}
219
- name = name.to_s
220
-
221
- @support_table_data_files.each do |data_file_path|
222
- file_data = support_table_parse_data_file(data_file_path)
223
- next unless file_data.is_a?(Hash)
224
-
225
- file_data.each do |instance_name, attributes|
226
- next unless name == instance_name.to_s
227
- next unless attributes.is_a?(Hash)
228
-
229
- data.merge!(attributes)
230
- end
231
- end
232
-
233
- data
252
+ support_table_merged_records(support_table_data_files).last[name.to_s] || {}
234
253
  end
235
254
 
236
255
  # Get the names of all named instances.
237
256
  #
238
257
  # @return [Array<String>] List of all instance names.
239
258
  def instance_names
240
- @support_table_instance_names.keys
259
+ support_table_instance_names_map.keys
241
260
  end
242
261
 
243
262
  # Load a named instance from the database.
@@ -247,36 +266,50 @@ module SupportTableData
247
266
  # @raise [ActiveRecord::RecordNotFound] If the instance does not exist.
248
267
  def named_instance(instance_name)
249
268
  instance_name = instance_name.to_s
250
- find_by!(support_table_key_attribute => @support_table_instance_names[instance_name])
269
+ instances = support_table_instance_names_map
270
+ unless instances.include?(instance_name)
271
+ raise ActiveRecord::RecordNotFound.new("Couldn't find #{name} named instance #{instance_name.inspect}")
272
+ end
273
+
274
+ find_by!(support_table_key_attribute => instances[instance_name])
251
275
  end
252
276
 
253
- # Get the key values for all instances loaded from the data files.
277
+ # Get the key values for all instances loaded from the data files. Data files added to
278
+ # single table inheritance subclasses are included since those rows live in this table too.
254
279
  #
255
280
  # @return [Array] List of all the key attribute values.
256
281
  def instance_keys
257
- if @support_table_instance_keys.nil?
282
+ support_table_cached_data_value(:@support_table_instance_keys, support_table_data_files_with_descendants) do |data_files|
258
283
  values = []
259
- support_table_data.each do |attributes|
284
+ support_table_data_for_files(data_files).each do |attributes|
260
285
  key_value = attributes[support_table_key_attribute]
261
286
  instance = new
262
287
  instance.send(:"#{support_table_key_attribute}=", key_value)
263
288
  values << instance.send(support_table_key_attribute)
264
289
  end
265
- @support_table_instance_keys = values.uniq
290
+ values.uniq
266
291
  end
267
- @support_table_instance_keys
268
292
  end
269
293
 
270
- # Return true if the instance has data being managed from a data file.
294
+ # Return true if the instance has data being managed from a data file. Instances are matched
295
+ # on the key attribute only. Single table inheritance types are intentionally not considered
296
+ # when matching since syncing also matches existing rows on just the key attribute and will
297
+ # overwrite a row regardless of the type it currently has. Data files added to single table
298
+ # inheritance subclasses are included since those rows live in this table too.
271
299
  #
272
300
  # @return [Boolean]
273
301
  def protected_instance?(instance)
274
- unless defined?(@protected_keys)
275
- keys = support_table_data.collect { |attributes| attributes[support_table_key_attribute].to_s }
276
- @protected_keys = keys
277
- end
302
+ key = instance[support_table_key_attribute].to_s
303
+ return true if support_table_protected_keys.include?(key)
304
+
305
+ # The instance may be a single table inheritance subclass that had not been loaded yet
306
+ # when the keys for this class were calculated. Loading a row instantiates it as its own
307
+ # subclass, so the subclass can be asked directly rather than relying on it having been
308
+ # eager loaded before this point.
309
+ instance_class = instance.class
310
+ return false if instance_class == self || !instance_class.include?(SupportTableData)
278
311
 
279
- @protected_keys.include?(instance[support_table_key_attribute].to_s)
312
+ instance_class.send(:support_table_protected_keys).include?(key)
280
313
  end
281
314
 
282
315
  # Explicitly define other support tables that this model depends on. A support table depends
@@ -290,22 +323,130 @@ module SupportTableData
290
323
  # @param class_names [String] List of class names that this support table depends on.
291
324
  # @return [void]
292
325
  def support_table_dependency(*class_names)
293
- @support_table_dependencies += class_names.flatten.collect(&:to_s)
326
+ support_table_mutex.synchronize do
327
+ @support_table_dependencies = support_table_dependency_names + class_names.flatten.collect(&:to_s)
328
+ end
294
329
  end
295
330
 
296
331
  private
297
332
 
298
- def define_support_table_named_instances
299
- @support_table_data_files.each do |file_path|
300
- data = support_table_parse_data_file(file_path)
301
- next unless data.is_a?(Hash)
333
+ # Destroy the rows in a relation that are not managed from a data file.
334
+ #
335
+ # Rows managed by data files added to single table inheritance subclasses live in this
336
+ # table, but they are synced by the subclass, so they must not be deleted here. Loading a
337
+ # row instantiates it as its own subclass, which loads that class if it hasn't been loaded
338
+ # yet, so `protected_instance?` can consult the subclass directly without having to eager
339
+ # load the entire application first.
340
+ #
341
+ # @param relation [ActiveRecord::Relation] The rows that are candidates for deletion.
342
+ # @return [void]
343
+ def delete_missing_records(relation)
344
+ relation.find_each do |record|
345
+ next if protected_instance?(record)
346
+
347
+ record.destroy
348
+ end
349
+ end
350
+
351
+ # The key attribute values of every row managed from this class' data files, including
352
+ # data files added to single table inheritance subclasses that have already been loaded.
353
+ #
354
+ # @return [Array<String>]
355
+ def support_table_protected_keys
356
+ support_table_cached_data_value(:@support_table_protected_keys, support_table_data_files_with_descendants) do |data_files|
357
+ support_table_data_for_files(data_files).collect { |attributes| attributes[support_table_key_attribute].to_s }
358
+ end
359
+ end
360
+
361
+ # Parse the data files and merge the records they define. Files are processed in
362
+ # order so that later files take precedence no matter how each file is structured.
363
+ # Records are matched both by their instance name and by their key attribute value.
364
+ # Entry names that begin with an underscore do not define named instances; their
365
+ # values hold anonymous records identified only by the key attribute.
366
+ #
367
+ # @param data_files [Array<String>] The paths of the data files to read.
368
+ # @return [Array(Array<Hash>, Hash<String, Hash>)] The ordered list of merged records
369
+ # and the named instance records by name.
370
+ def support_table_merged_records(data_files)
371
+ records = []
372
+ named_records = {}
373
+ keyed_records = {}
374
+
375
+ merge_record = lambda do |attributes, instance_name|
376
+ record = named_records[instance_name] if instance_name
377
+ if record.nil? && (instance_name.nil? || attributes.include?(support_table_key_attribute))
378
+ record = keyed_records[attributes[support_table_key_attribute].to_s]
379
+ end
380
+
381
+ if record
382
+ record.merge!(attributes)
383
+ else
384
+ record = attributes.dup
385
+ records << record
386
+ end
302
387
 
303
- data.each do |name, attributes|
304
- @mutex.synchronize do
305
- define_support_table_named_instance_methods(name, attributes)
388
+ named_records[instance_name] = record if instance_name
389
+ keyed_records[record[support_table_key_attribute].to_s] = record
390
+ end
391
+
392
+ data_files.each do |data_file_path|
393
+ file_data = support_table_parse_data_file(data_file_path)
394
+
395
+ unless file_data.is_a?(Hash)
396
+ Array(file_data).flatten.each { |attributes| merge_record.call(attributes, nil) }
397
+ next
398
+ end
399
+
400
+ file_data.each do |instance_name, attributes|
401
+ instance_name = instance_name.to_s
402
+ if instance_name.start_with?("_")
403
+ anonymous_records = attributes.is_a?(Hash) ? [attributes] : Array(attributes).flatten
404
+ anonymous_records.each { |record_attributes| merge_record.call(record_attributes, nil) }
405
+ elsif attributes.is_a?(Hash)
406
+ merge_record.call(attributes, instance_name)
407
+ else
408
+ raise ArgumentError.new("Cannot define named instance #{instance_name} on #{name}; value must be a Hash")
306
409
  end
307
410
  end
308
411
  end
412
+
413
+ [records, named_records]
414
+ end
415
+
416
+ # The classes in the hierarchy that added their own data files, listed base class
417
+ # first, along with the file paths each one added.
418
+ #
419
+ # @return [Array<Array(Class, Array<String>)>]
420
+ def support_table_data_file_owners
421
+ owners = superclass.include?(SupportTableData) ? superclass.send(:support_table_data_file_owners) : []
422
+ own_files = @support_table_data_files
423
+ owners += [[self, own_files]] if own_files && !own_files.empty?
424
+ owners
425
+ end
426
+
427
+ # Map each record's key attribute value to the class in the hierarchy whose own data
428
+ # files define it. Used to determine the single table inheritance type for new rows
429
+ # that do not specify one in the data files.
430
+ #
431
+ # @return [Hash<String, Class>]
432
+ def support_table_record_classes
433
+ record_classes = {}
434
+ support_table_data_file_owners.each do |owner, files|
435
+ next if owner == self
436
+
437
+ support_table_data_for_files(files).each do |attributes|
438
+ record_classes[attributes[support_table_key_attribute].to_s] ||= owner
439
+ end
440
+ end
441
+ record_classes
442
+ end
443
+
444
+ def define_support_table_named_instances
445
+ support_table_merged_records(support_table_data_files).last.each do |name, attributes|
446
+ support_table_mutex.synchronize do
447
+ define_support_table_named_instance_methods(name, attributes)
448
+ end
449
+ end
309
450
  end
310
451
 
311
452
  def define_support_table_named_instance_methods(name, attributes)
@@ -313,64 +454,101 @@ module SupportTableData
313
454
  return if method_name.start_with?("_")
314
455
 
315
456
  unless attributes.is_a?(Hash)
316
- raise ArgumentError.new("Cannot define named instance #{method_name} on #{name}; value must be a Hash")
457
+ raise ArgumentError.new("Cannot define named instance #{method_name} on #{self.name}; value must be a Hash")
317
458
  end
318
459
 
319
460
  unless method_name.match?(/\A[a-z][a-z0-9_]+\z/)
320
- raise ArgumentError.new("Cannot define named instance #{method_name} on #{name}; name contains illegal characters")
461
+ raise ArgumentError.new("Cannot define named instance #{method_name} on #{self.name}; name contains illegal characters")
321
462
  end
322
463
 
323
464
  key_value = attributes[support_table_key_attribute]
465
+ instance_names_map = support_table_instance_names_map
324
466
 
325
- unless @support_table_instance_names.include?(method_name)
467
+ if instance_names_map.include?(method_name)
468
+ if instance_names_map[method_name] != key_value
469
+ define_support_table_instance_helper(method_name, support_table_key_attribute, key_value, redefine: true)
470
+ define_support_table_predicates_helper("#{method_name}?", support_table_key_attribute, key_value, redefine: true)
471
+ @support_table_instance_names = (@support_table_instance_names || {}).merge(method_name => key_value)
472
+ end
473
+ else
326
474
  define_support_table_instance_helper(method_name, support_table_key_attribute, key_value)
327
475
  define_support_table_predicates_helper("#{method_name}?", support_table_key_attribute, key_value)
328
- @support_table_instance_names = @support_table_instance_names.merge(method_name => key_value)
476
+ @support_table_instance_names = (@support_table_instance_names || {}).merge(method_name => key_value)
329
477
  end
330
478
 
331
- @support_table_attribute_helpers.each do |attribute_name, defined_methods|
479
+ support_table_attribute_helpers_map.each do |attribute_name, defined_methods|
332
480
  attribute_method_name = "#{method_name}_#{attribute_name}"
333
- next if defined_methods.include?(attribute_method_name)
334
-
335
- define_support_table_instance_attribute_helper(attribute_method_name, attributes[attribute_name])
336
- defined_methods << attribute_method_name
481
+ if defined_methods.include?(attribute_method_name)
482
+ define_support_table_instance_attribute_helper(attribute_method_name, attributes[attribute_name], redefine: true)
483
+ else
484
+ define_support_table_instance_attribute_helper(attribute_method_name, attributes[attribute_name])
485
+ defined_methods << attribute_method_name
486
+ end
337
487
  end
338
488
  end
339
489
 
340
- def define_support_table_instance_helper(method_name, attribute_name, attribute_value)
341
- if respond_to?(method_name, true)
490
+ # Values from the data files are captured in the method closures rather than being
491
+ # interpolated into the method body as literals. Not every value that can appear in a
492
+ # data file has an `inspect` representation that is valid Ruby source (`Date` and `Time`
493
+ # are the notable ones), so interpolating them would raise a SyntaxError when the method
494
+ # is defined.
495
+ def define_support_table_instance_helper(method_name, attribute_name, attribute_value, redefine: false)
496
+ if redefine
497
+ singleton_class.silence_redefinition_of_method(method_name)
498
+ elsif respond_to?(method_name, true)
342
499
  raise ArgumentError.new("Could not define support table helper method #{name}.#{method_name} because it is already a defined method")
343
500
  end
344
501
 
345
- class_eval <<~RUBY, __FILE__, __LINE__ + 1
346
- def self.#{method_name}
347
- find_by!(#{attribute_name}: #{attribute_value.inspect})
348
- end
349
- RUBY
502
+ attribute_name = attribute_name.to_s
503
+ singleton_class.send(:define_method, method_name) do
504
+ find_by!(attribute_name => attribute_value)
505
+ end
350
506
  end
351
507
 
352
- def define_support_table_instance_attribute_helper(method_name, attribute_value)
353
- if respond_to?(method_name, true)
508
+ def define_support_table_instance_attribute_helper(method_name, attribute_value, redefine: false)
509
+ if redefine
510
+ singleton_class.silence_redefinition_of_method(method_name)
511
+ elsif respond_to?(method_name, true)
354
512
  raise ArgumentError.new("Could not define support table helper method #{name}.#{method_name} because it is already a defined method")
355
513
  end
356
514
 
357
- class_eval <<~RUBY, __FILE__, __LINE__ + 1
358
- def self.#{method_name}
359
- #{attribute_value.inspect}.freeze
360
- end
361
- RUBY
515
+ # The value is returned directly on every call, so it is copied and frozen to keep
516
+ # callers from mutating the data shared by all of them.
517
+ value = support_table_deep_freeze(attribute_value.dup)
518
+ singleton_class.send(:define_method, method_name) { value }
362
519
  end
363
520
 
364
- def define_support_table_predicates_helper(method_name, attribute_name, attribute_value)
365
- if method_defined?(method_name) || private_method_defined?(method_name)
521
+ def define_support_table_predicates_helper(method_name, attribute_name, attribute_value, redefine: false)
522
+ if redefine
523
+ silence_redefinition_of_method(method_name)
524
+ elsif method_defined?(method_name) || private_method_defined?(method_name)
366
525
  raise ArgumentError.new("Could not define support table helper method #{name}##{method_name} because it is already a defined method")
367
526
  end
368
527
 
369
- class_eval <<~RUBY, __FILE__, __LINE__ + 1
370
- def #{method_name}
371
- #{attribute_name} == #{attribute_value.inspect}
528
+ attribute_name = attribute_name.to_s
529
+ # The value has to be cast to the attribute type before it can be compared to the value
530
+ # read off the record. The cast is done lazily and memoized per class because the type
531
+ # requires a database connection to resolve, which is not necessarily available when the
532
+ # model class is being loaded. The map is shared by every instance, so it needs to be
533
+ # thread safe on Ruby implementations without a global interpreter lock.
534
+ cast_values = Concurrent::Map.new
535
+ define_method(method_name) do
536
+ klass = self.class
537
+ cast_value = cast_values.fetch_or_store(klass) do
538
+ klass.type_for_attribute(attribute_name).cast(attribute_value)
372
539
  end
373
- RUBY
540
+ send(attribute_name) == cast_value
541
+ end
542
+ end
543
+
544
+ def support_table_deep_freeze(value)
545
+ case value
546
+ when Hash
547
+ value.each_value { |element| support_table_deep_freeze(element) }
548
+ when Array
549
+ value.each { |element| support_table_deep_freeze(element) }
550
+ end
551
+ value.freeze
374
552
  end
375
553
 
376
554
  def support_table_parse_data_file(file_path)
@@ -390,7 +568,8 @@ module SupportTableData
390
568
  end
391
569
  else
392
570
  require "yaml" unless defined?(YAML)
393
- data = YAML.safe_load(file_data)
571
+ require "date" unless defined?(Date)
572
+ data = YAML.safe_load(file_data, permitted_classes: [Date, Time], aliases: true)
394
573
  end
395
574
 
396
575
  data
@@ -399,7 +578,7 @@ module SupportTableData
399
578
  def support_table_record_changed?(record, seen = Set.new)
400
579
  return true if record.changed?
401
580
 
402
- seen << self
581
+ seen << record
403
582
  record.class.reflect_on_all_associations.detect do |reflection|
404
583
  next false if reflection.belongs_to?
405
584
  next false unless reflection.options[:autosave]
@@ -409,6 +588,80 @@ module SupportTableData
409
588
  end
410
589
  end
411
590
  end
591
+
592
+ # Memoize a value calculated from the data files in an instance variable on this class.
593
+ # The list of data files used to calculate the value is cached along with it so that the
594
+ # value is recalculated whenever the list changes. This keeps the value from going stale
595
+ # when a data file is added after it was first calculated, including when the file is added
596
+ # to a base class after a single table inheritance subclass has cached its own copy or when
597
+ # a subclass that adds its own data files is loaded lazily.
598
+ #
599
+ # @param variable_name [Symbol] The name of the instance variable to memoize the value in.
600
+ # @param data_files [Array<String>] The data files the value is calculated from. These are
601
+ # yielded to the block and cached with the value so it can be invalidated.
602
+ # @return [Object] The cached value.
603
+ def support_table_cached_data_value(variable_name, data_files)
604
+ cached = instance_variable_get(variable_name)
605
+ return cached.last if cached && cached.first == data_files
606
+
607
+ support_table_mutex.synchronize do
608
+ cached = instance_variable_get(variable_name)
609
+ unless cached && cached.first == data_files
610
+ cached = [data_files.dup.freeze, yield(data_files)].freeze
611
+ instance_variable_set(variable_name, cached)
612
+ end
613
+ end
614
+
615
+ cached.last
616
+ end
617
+
618
+ # Get the list of data files for this class along with any added to single table inheritance
619
+ # subclasses. Rows from a subclass' data files live in the same table, so they need to be
620
+ # included when determining which rows in the table are managed from data files.
621
+ #
622
+ # Note that this can only detect subclasses that have already been loaded by the application.
623
+ #
624
+ # @return [Array<String>] List of data file paths.
625
+ def support_table_data_files_with_descendants
626
+ files = support_table_data_files
627
+
628
+ descendants.each do |subclass|
629
+ next unless subclass.include?(SupportTableData)
630
+
631
+ files |= subclass.send(:support_table_data_files)
632
+ end
633
+
634
+ files
635
+ end
636
+
637
+ # The class level state used by the concern is stored in instance variables on the
638
+ # class where the concern was included. These readers compose in or fall back to the
639
+ # superclass state so that single table inheritance subclasses share the state defined
640
+ # on their base class rather than crashing on uninitialized instance variables.
641
+
642
+ def support_table_mutex
643
+ @support_table_mutex || (superclass.include?(SupportTableData) ? superclass.send(:support_table_mutex) : nil)
644
+ end
645
+
646
+ def support_table_data_files
647
+ inherited = superclass.include?(SupportTableData) ? superclass.send(:support_table_data_files) : []
648
+ own = @support_table_data_files || []
649
+ inherited.empty? ? own : inherited + own
650
+ end
651
+
652
+ def support_table_instance_names_map
653
+ inherited = superclass.include?(SupportTableData) ? superclass.send(:support_table_instance_names_map) : {}
654
+ own = @support_table_instance_names || {}
655
+ inherited.empty? ? own : inherited.merge(own)
656
+ end
657
+
658
+ def support_table_attribute_helpers_map
659
+ @support_table_attribute_helpers || (superclass.include?(SupportTableData) ? superclass.send(:support_table_attribute_helpers_map) : {})
660
+ end
661
+
662
+ def support_table_dependency_names
663
+ @support_table_dependencies || (superclass.include?(SupportTableData) ? superclass.send(:support_table_dependency_names) : [])
664
+ end
412
665
  end
413
666
 
414
667
  class << self
@@ -481,7 +734,7 @@ module SupportTableData
481
734
  if SupportTableData.data_directory && File.exist?(SupportTableData.data_directory) && File.directory?(SupportTableData.data_directory)
482
735
  Dir.glob(File.join(SupportTableData.data_directory, "**", "*")).sort.each do |file_name|
483
736
  file_name = file_name.delete_prefix("#{SupportTableData.data_directory}#{File::SEPARATOR}")
484
- class_name = file_name.sub(/\.[^.]*/, "").singularize.camelize
737
+ class_name = file_name.sub(/\.[^.]*\z/, "").singularize.camelize
485
738
  class_name.safe_constantize
486
739
  end
487
740
  end
@@ -514,7 +767,7 @@ module SupportTableData
514
767
  #
515
768
  # @return [Array<Class>]
516
769
  def support_table_dependencies(klass)
517
- dependencies = klass.instance_variable_get(:@support_table_dependencies).collect(&:constantize)
770
+ dependencies = klass.send(:support_table_dependency_names).collect(&:constantize)
518
771
 
519
772
  klass.reflections.values.each do |reflection|
520
773
  next if reflection.polymorphic?
@@ -523,8 +776,8 @@ module SupportTableData
523
776
  next unless reflection.belongs_to? || reflection.through_reflection?
524
777
  next if dependencies.include?(reflection.klass)
525
778
 
526
- explicit_dependencies = reflection.klass.instance_variable_get(:@support_table_dependencies)
527
- next if explicit_dependencies&.include?(klass.name)
779
+ explicit_dependencies = reflection.klass.send(:support_table_dependency_names)
780
+ next if explicit_dependencies.include?(klass.name)
528
781
 
529
782
  dependencies << reflection.klass
530
783
  rescue => e
@@ -80,8 +80,8 @@ namespace :support_table_data do
80
80
  SupportTableData::Tasks::Utils.eager_load!
81
81
  SupportTableData::Tasks::Utils.support_table_rbs_files(args[:file_path]).each do |rbs_file|
82
82
  next if rbs_file.up_to_date?
83
+ next unless rbs_file.write!
83
84
 
84
- rbs_file.write!
85
85
  puts "Wrote RBS signatures for #{rbs_file.klass.name} to #{rbs_file.path}."
86
86
  end
87
87
  end
@@ -35,9 +35,7 @@ Gem::Specification.new do |spec|
35
35
 
36
36
  spec.require_paths = ["lib"]
37
37
 
38
- spec.required_ruby_version = ">= 2.5"
38
+ spec.required_ruby_version = ">= 2.6"
39
39
 
40
40
  spec.add_dependency "activerecord", ">= 6"
41
-
42
- spec.add_development_dependency "bundler"
43
41
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: support_table_data
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.0
4
+ version: 1.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand
@@ -23,20 +23,6 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '6'
26
- - !ruby/object:Gem::Dependency
27
- name: bundler
28
- requirement: !ruby/object:Gem::Requirement
29
- requirements:
30
- - - ">="
31
- - !ruby/object:Gem::Version
32
- version: '0'
33
- type: :development
34
- prerelease: false
35
- version_requirements: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - ">="
38
- - !ruby/object:Gem::Version
39
- version: '0'
40
26
  email:
41
27
  - bbdurand@gmail.com
42
28
  executables: []
@@ -75,14 +61,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
75
61
  requirements:
76
62
  - - ">="
77
63
  - !ruby/object:Gem::Version
78
- version: '2.5'
64
+ version: '2.6'
79
65
  required_rubygems_version: !ruby/object:Gem::Requirement
80
66
  requirements:
81
67
  - - ">="
82
68
  - !ruby/object:Gem::Version
83
69
  version: '0'
84
70
  requirements: []
85
- rubygems_version: 3.6.9
71
+ rubygems_version: 4.0.3
86
72
  specification_version: 4
87
73
  summary: Extension for ActiveRecord models to manage synchronizing data in support/lookup
88
74
  tables across environments. Also provides the ability to directly reference and