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 +4 -4
- data/CHANGELOG.md +35 -0
- data/README.md +9 -1
- data/VERSION +1 -1
- data/lib/support_table_data/documentation/rbs_doc.rb +1 -1
- data/lib/support_table_data/documentation/source_file.rb +15 -4
- data/lib/support_table_data/documentation/type_inference.rb +15 -13
- data/lib/support_table_data/documentation/yard_doc.rb +60 -43
- data/lib/support_table_data/railtie.rb +1 -1
- data/lib/support_table_data/tasks/utils.rb +24 -13
- data/lib/support_table_data.rb +388 -135
- data/lib/tasks/support_table_data.rake +1 -1
- data/support_table_data.gemspec +1 -3
- metadata +3 -17
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7e8cc498db34d48c4547f0c076553eb1c087b198d9b76d467558bcfd9a5249f7
|
|
4
|
+
data.tar.gz: 38b94d4df77e5b75ed7b24c0e21f129d37f1a3a07cf6cc6ce515c537743cc45b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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.
|
|
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,
|
|
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}
|
|
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.
|
|
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
|
-
|
|
67
|
-
|
|
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
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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
|
|
16
|
-
#
|
|
17
|
-
#
|
|
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
|
|
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,
|
|
23
|
-
return nil unless klass.respond_to?(
|
|
21
|
+
def value_type(klass, name, attribute_name)
|
|
22
|
+
return nil unless klass.respond_to?(:named_instance_data)
|
|
24
23
|
|
|
25
|
-
klass.
|
|
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
|
|
20
|
-
#
|
|
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 <<
|
|
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] #{
|
|
147
|
-
# Find
|
|
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] #{
|
|
155
|
-
# Check if this record is
|
|
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
|
|
161
|
-
end
|
|
151
|
+
macros = [finder_macro, "", predicate_macro]
|
|
162
152
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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
|
|
data/lib/support_table_data.rb
CHANGED
|
@@ -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
|
-
@
|
|
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
|
-
#
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
282
|
+
support_table_cached_data_value(:@support_table_instance_keys, support_table_data_files_with_descendants) do |data_files|
|
|
258
283
|
values = []
|
|
259
|
-
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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
|
-
|
|
479
|
+
support_table_attribute_helpers_map.each do |attribute_name, defined_methods|
|
|
332
480
|
attribute_method_name = "#{method_name}_#{attribute_name}"
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
341
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 <<
|
|
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(/\.[^.]
|
|
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.
|
|
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.
|
|
527
|
-
next if explicit_dependencies
|
|
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
|
data/support_table_data.gemspec
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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:
|
|
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
|