foundries 0.1.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 +7 -0
- data/CHANGELOG.md +8 -0
- data/LICENSE +21 -0
- data/README.md +249 -0
- data/Rakefile +17 -0
- data/lib/foundries/base.rb +235 -0
- data/lib/foundries/blueprint.rb +286 -0
- data/lib/foundries/similarity/comparator.rb +49 -0
- data/lib/foundries/similarity/recorder.rb +36 -0
- data/lib/foundries/similarity/structure_tree.rb +74 -0
- data/lib/foundries/similarity.rb +32 -0
- data/lib/foundries/snapshot/adapter.rb +18 -0
- data/lib/foundries/snapshot/adapters/postgres_adapter.rb +68 -0
- data/lib/foundries/snapshot/adapters/sqlite_adapter.rb +53 -0
- data/lib/foundries/snapshot/fingerprint.rb +29 -0
- data/lib/foundries/snapshot/store.rb +72 -0
- data/lib/foundries/snapshot.rb +42 -0
- data/lib/foundries/version.rb +6 -0
- data/lib/foundries.rb +12 -0
- metadata +93 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Foundries
|
|
4
|
+
# Blueprint is the base class for individual factory wrappers within a Foundry.
|
|
5
|
+
#
|
|
6
|
+
# Each Blueprint wraps one or more factory_bot factories and knows how to:
|
|
7
|
+
# - Create records using factory_bot
|
|
8
|
+
# - Track created records in a collection
|
|
9
|
+
# - Navigate parent-child relationships
|
|
10
|
+
# - Find existing records before creating duplicates
|
|
11
|
+
#
|
|
12
|
+
# Subclass Blueprint and use the class-level DSL to declare behavior:
|
|
13
|
+
#
|
|
14
|
+
# class UserBlueprint < Foundries::Blueprint
|
|
15
|
+
# handles :user, :admin
|
|
16
|
+
# factory :user
|
|
17
|
+
# collection :users
|
|
18
|
+
# parent :none
|
|
19
|
+
# permitted_attrs %i[name email]
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
class Blueprint
|
|
23
|
+
include FactoryBot::Syntax::Methods
|
|
24
|
+
|
|
25
|
+
attr_reader :foundry
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
def handles(*methods)
|
|
29
|
+
@handled_methods ||= []
|
|
30
|
+
@handled_methods.concat(methods)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def handled_methods
|
|
34
|
+
@handled_methods || []
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Declare which factory_bot factory this blueprint uses.
|
|
38
|
+
# If not set, inferred from the class name.
|
|
39
|
+
def factory(name = nil)
|
|
40
|
+
if name
|
|
41
|
+
@factory_name = name
|
|
42
|
+
else
|
|
43
|
+
@factory_name || inferred_factory_name
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def factory_name
|
|
48
|
+
factory
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Declare the collection name for tracking created records.
|
|
52
|
+
# This also defines `#collection` and `#record_class` instance methods.
|
|
53
|
+
def collection(method_name = nil)
|
|
54
|
+
return @collection_name unless method_name
|
|
55
|
+
|
|
56
|
+
@collection_name = method_name
|
|
57
|
+
|
|
58
|
+
define_method(:collection) do
|
|
59
|
+
foundry.send(:"#{method_name}_collection")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
define_method(:record_class) do
|
|
63
|
+
method_name.to_s.classify.constantize
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
attr_reader :collection_name
|
|
68
|
+
|
|
69
|
+
# Declare the foreign key used to link to the parent.
|
|
70
|
+
def parent_key(key = nil)
|
|
71
|
+
return @parent_key_name unless key
|
|
72
|
+
|
|
73
|
+
@parent_key_name = key
|
|
74
|
+
define_method(:parent_key) { key }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Declare how to find the parent record from `current` state.
|
|
78
|
+
#
|
|
79
|
+
# parent :none - no parent relationship
|
|
80
|
+
# parent :self - self-referential (e.g. nested categories)
|
|
81
|
+
# parent :competency - reads current.competency
|
|
82
|
+
#
|
|
83
|
+
def parent(method_name = nil)
|
|
84
|
+
return @parent_method unless method_name
|
|
85
|
+
|
|
86
|
+
@parent_method = method_name
|
|
87
|
+
|
|
88
|
+
if method_name == :none
|
|
89
|
+
define_method(:same_parent?) { |_| true }
|
|
90
|
+
elsif method_name == :self
|
|
91
|
+
define_method(:same_parent?) { |_| true }
|
|
92
|
+
define_method(:parent) do
|
|
93
|
+
current.send(current_accessor)
|
|
94
|
+
end
|
|
95
|
+
else
|
|
96
|
+
define_method(:parent) do
|
|
97
|
+
current.send(method_name)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
define_singleton_method(:parent_accessor) do
|
|
101
|
+
method_name
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Declare which attributes are allowed through to factory_bot.
|
|
107
|
+
def permitted_attrs(attr_list)
|
|
108
|
+
define_method(:permitted_attrs) do |attrs|
|
|
109
|
+
keys = attr_list.dup
|
|
110
|
+
keys << parent_key if parent_key
|
|
111
|
+
attrs.slice(*keys)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Declare nested attributes (for accepts_nested_attributes_for).
|
|
116
|
+
def nested_attrs(hash)
|
|
117
|
+
nested_object_name, attr_names = hash.shift
|
|
118
|
+
|
|
119
|
+
define_method(:nested_attrs) do |attrs|
|
|
120
|
+
attrs_to_nest = attrs.slice(*attr_names)
|
|
121
|
+
key = :"#{nested_object_name}_attributes"
|
|
122
|
+
{key => attrs_to_nest}
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Load state from an existing object back into a foundry.
|
|
127
|
+
def load_state_from(object, foundry)
|
|
128
|
+
return unless respond_to?(:parent_accessor)
|
|
129
|
+
|
|
130
|
+
parent_object = object.send(parent_accessor)
|
|
131
|
+
foundry.load_existing_objects(parent_object)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def inferred_factory_name
|
|
137
|
+
name&.demodulize&.delete_suffix("Blueprint")&.underscore&.to_sym
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def self.new(...)
|
|
142
|
+
instance = super
|
|
143
|
+
foundry = instance.foundry
|
|
144
|
+
recorder = foundry&.instance_variable_get(:@_similarity_recorder)
|
|
145
|
+
instance._wrap_for_similarity_recording!(recorder) if recorder
|
|
146
|
+
instance
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def initialize(foundry)
|
|
150
|
+
@foundry = foundry
|
|
151
|
+
@attrs = {}
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def _wrap_for_similarity_recording!(recorder)
|
|
155
|
+
methods_to_wrap = self.class.public_instance_methods(false).select do |m|
|
|
156
|
+
self.class.instance_method(m).parameters.any? { |type, _| type == :block }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
methods_to_wrap.each do |method_name|
|
|
160
|
+
original = method(method_name)
|
|
161
|
+
define_singleton_method(method_name) do |*args, **kwargs, &block|
|
|
162
|
+
recorder.record(method_name.to_s, has_block: !block.nil?) do
|
|
163
|
+
original.call(*args, **kwargs, &block)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
delegate :current=, :current, :update_current, :execute_and_restore_state,
|
|
170
|
+
to: :foundry
|
|
171
|
+
|
|
172
|
+
def assume_trait?(val)
|
|
173
|
+
val.is_a?(Symbol) || val.is_a?(Array)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def inspect
|
|
177
|
+
self.class.name
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def parent_key
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def parent
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Saves current state, yields, then restores state.
|
|
189
|
+
# Use this when entering a nested block to scope context.
|
|
190
|
+
def update_state_for_block(object, &block)
|
|
191
|
+
execute_and_restore_state do
|
|
192
|
+
update_current(object)
|
|
193
|
+
current.resource = object
|
|
194
|
+
instance_exec(&block)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Find a record in the collection by name, falling back to the database.
|
|
199
|
+
def find(name, col_name: "name")
|
|
200
|
+
raise "#find called with nil :name, for col_name: #{col_name}." unless name
|
|
201
|
+
|
|
202
|
+
found_record = collection.detect do |object|
|
|
203
|
+
object.send(col_name).casecmp?(name) && same_parent?(object)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
found_record ||
|
|
207
|
+
record_class.find_by(col_name => name)&.tap { |rec| collection << rec }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Find a record in the collection by arbitrary criteria, falling back to the database.
|
|
211
|
+
def find_by(criteria = {})
|
|
212
|
+
found_record = collection.detect do |object|
|
|
213
|
+
criteria.all? { |attr, value| object.send(attr) == value }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
return found_record if found_record
|
|
217
|
+
|
|
218
|
+
record_class.find_by(criteria)&.tap { |record| collection << record }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def same_parent?(object)
|
|
222
|
+
return true unless parent
|
|
223
|
+
|
|
224
|
+
object.send(parent_key) == parent_id
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def parent_id
|
|
228
|
+
parent&.id
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def current_accessor
|
|
232
|
+
self.class.name.demodulize.underscore.delete_suffix("_blueprint")
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def find_or_create_object
|
|
236
|
+
send(:"#{mode}_object")
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def mode
|
|
240
|
+
current.resource.nil? ? :find : :create
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def reset_attrs_and_type
|
|
244
|
+
@type = nil
|
|
245
|
+
reset_attrs
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def reset_attrs
|
|
249
|
+
@attrs = {}
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Delegate unknown methods to the foundry so that all blueprint methods
|
|
253
|
+
# are available in nested blocks. Also supports dynamic find_<klass>_by.
|
|
254
|
+
def method_missing(name, *args, **kwargs, &block)
|
|
255
|
+
if (match = missing_find_by_request?(name))
|
|
256
|
+
klass_name = match.named_captures["klass"]
|
|
257
|
+
return collection_find_by(klass_name, args)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
if foundry.respond_to?(name)
|
|
261
|
+
return foundry.send(name, *args, **kwargs, &block)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
super
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def respond_to_missing?(name, include_private = false)
|
|
268
|
+
missing_find_by_request?(name) || foundry.respond_to?(name) || super
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
private
|
|
272
|
+
|
|
273
|
+
def missing_find_by_request?(method_name)
|
|
274
|
+
method_name.match(/^find_(?<klass>.*)_by$/)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def collection_find_by(klass_name, args)
|
|
278
|
+
attrs = args.first
|
|
279
|
+
target_collection_name = "#{klass_name.pluralize}_collection"
|
|
280
|
+
objects = foundry.send(target_collection_name)
|
|
281
|
+
objects.detect do |object|
|
|
282
|
+
attrs.all? { |(attr, value)| object.send(attr) == value }
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Foundries
|
|
4
|
+
module Similarity
|
|
5
|
+
module Comparator
|
|
6
|
+
def self.compare(new_key, new_tree, registry)
|
|
7
|
+
warnings = []
|
|
8
|
+
|
|
9
|
+
registry.each do |existing_key, existing_tree|
|
|
10
|
+
next if existing_key == new_key
|
|
11
|
+
pair_key = [new_key, existing_key].sort
|
|
12
|
+
|
|
13
|
+
if new_tree == existing_tree
|
|
14
|
+
warnings << {
|
|
15
|
+
pair: pair_key,
|
|
16
|
+
message: "[Foundries] Preset :#{preset_name(new_key)} and " \
|
|
17
|
+
":#{preset_name(existing_key)} have identical structure " \
|
|
18
|
+
"(#{display_tree(new_tree)})"
|
|
19
|
+
}
|
|
20
|
+
elsif existing_tree.contains?(new_tree)
|
|
21
|
+
warnings << {
|
|
22
|
+
pair: pair_key,
|
|
23
|
+
message: "[Foundries] Preset :#{preset_name(new_key)} is " \
|
|
24
|
+
"structurally contained within :#{preset_name(existing_key)}"
|
|
25
|
+
}
|
|
26
|
+
elsif new_tree.contains?(existing_tree)
|
|
27
|
+
warnings << {
|
|
28
|
+
pair: pair_key,
|
|
29
|
+
message: "[Foundries] Preset :#{preset_name(existing_key)} is " \
|
|
30
|
+
"structurally contained within :#{preset_name(new_key)}"
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
warnings
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.preset_name(key)
|
|
39
|
+
key.to_s.split(".").last
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.display_tree(tree)
|
|
43
|
+
tree.children.map(&:to_s).join(", ")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private_class_method :preset_name, :display_tree
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "structure_tree"
|
|
4
|
+
|
|
5
|
+
module Foundries
|
|
6
|
+
module Similarity
|
|
7
|
+
class Recorder
|
|
8
|
+
def initialize
|
|
9
|
+
@root = StructureTree.root(children: [])
|
|
10
|
+
@stack = [@root]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def record(method_name, has_block:)
|
|
14
|
+
node = StructureTree.new(method_name)
|
|
15
|
+
current_parent.children << node
|
|
16
|
+
if has_block
|
|
17
|
+
@stack.push(node)
|
|
18
|
+
yield
|
|
19
|
+
@stack.pop
|
|
20
|
+
elsif block_given?
|
|
21
|
+
yield
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def normalized_tree
|
|
26
|
+
@root.normalize
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def current_parent
|
|
32
|
+
@stack.last
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Foundries
|
|
4
|
+
module Similarity
|
|
5
|
+
class StructureTree
|
|
6
|
+
attr_reader :name, :children
|
|
7
|
+
|
|
8
|
+
def initialize(name, children: [])
|
|
9
|
+
@name = name.to_s
|
|
10
|
+
@children = children
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.root(children:)
|
|
14
|
+
new("__root__", children: children)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def normalize
|
|
18
|
+
normalized_children = children.map(&:normalize)
|
|
19
|
+
collapsed = normalized_children.flat_map { |child| child.collapse_into(name) }
|
|
20
|
+
deduped = collapsed
|
|
21
|
+
.group_by(&:name)
|
|
22
|
+
.map { |_name, group| group.max_by(&:descendant_count) }
|
|
23
|
+
.sort_by(&:name)
|
|
24
|
+
self.class.new(name, children: deduped)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def collapse_into(parent_name)
|
|
28
|
+
if name == parent_name && !children.empty?
|
|
29
|
+
children
|
|
30
|
+
else
|
|
31
|
+
[self]
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def descendant_count
|
|
36
|
+
children.sum { |c| 1 + c.descendant_count }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def ==(other)
|
|
40
|
+
other.is_a?(self.class) &&
|
|
41
|
+
name == other.name &&
|
|
42
|
+
children == other.children
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
alias_method :eql?, :==
|
|
46
|
+
|
|
47
|
+
def hash
|
|
48
|
+
[name, children].hash
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def contains?(other)
|
|
52
|
+
return true if self == other
|
|
53
|
+
|
|
54
|
+
embeds?(other) || children.any? { |child| child.contains?(other) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def embeds?(other)
|
|
58
|
+
return false unless name == other.name
|
|
59
|
+
|
|
60
|
+
other.children.all? do |other_child|
|
|
61
|
+
children.any? { |child| child.embeds?(other_child) }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def to_s
|
|
66
|
+
if children.empty?
|
|
67
|
+
name
|
|
68
|
+
else
|
|
69
|
+
"#{name} > [#{children.map(&:to_s).join(", ")}]"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "similarity/structure_tree"
|
|
4
|
+
require_relative "similarity/recorder"
|
|
5
|
+
require_relative "similarity/comparator"
|
|
6
|
+
|
|
7
|
+
module Foundries
|
|
8
|
+
module Similarity
|
|
9
|
+
class << self
|
|
10
|
+
attr_writer :enabled
|
|
11
|
+
|
|
12
|
+
def enabled?
|
|
13
|
+
return @enabled unless @enabled.nil?
|
|
14
|
+
ENV["FOUNDRIES_SIMILARITY"] == "1"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def registry
|
|
18
|
+
@registry ||= {}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def warned_pairs
|
|
22
|
+
@warned_pairs ||= Set.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def reset!
|
|
26
|
+
@registry = {}
|
|
27
|
+
@warned_pairs = Set.new
|
|
28
|
+
@enabled = nil
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Foundries
|
|
4
|
+
module Snapshot
|
|
5
|
+
module Adapter
|
|
6
|
+
def self.for(connection)
|
|
7
|
+
case connection.adapter_name
|
|
8
|
+
when /postgresql/i
|
|
9
|
+
Adapters::PostgresAdapter.new(connection)
|
|
10
|
+
when /sqlite/i
|
|
11
|
+
Adapters::SqliteAdapter.new(connection)
|
|
12
|
+
else
|
|
13
|
+
raise "Unsupported adapter: #{connection.adapter_name}"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Foundries
|
|
4
|
+
module Snapshot
|
|
5
|
+
module Adapters
|
|
6
|
+
class PostgresAdapter
|
|
7
|
+
def initialize(connection)
|
|
8
|
+
@connection = connection
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def table_names
|
|
12
|
+
@connection.tables - %w[schema_migrations ar_internal_metadata]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def empty?(table_name)
|
|
16
|
+
@connection.select_value("SELECT NOT EXISTS (SELECT 1 FROM #{quoted(table_name)})")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def capture(table_name, io)
|
|
20
|
+
raw = @connection.raw_connection
|
|
21
|
+
raw.copy_data("COPY #{quoted(table_name)} TO STDOUT") do
|
|
22
|
+
while (row = raw.get_copy_data)
|
|
23
|
+
io.write(row)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def restore(table_name, io)
|
|
29
|
+
raw = @connection.raw_connection
|
|
30
|
+
raw.copy_data("COPY #{quoted(table_name)} FROM STDIN") do
|
|
31
|
+
io.each_line do |line|
|
|
32
|
+
raw.put_copy_data(line)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def disable_referential_integrity
|
|
38
|
+
@connection.execute("SET session_replication_role = replica")
|
|
39
|
+
yield
|
|
40
|
+
ensure
|
|
41
|
+
@connection.execute("SET session_replication_role = DEFAULT")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def reset_sequence(table_name)
|
|
45
|
+
pk = @connection.primary_key(table_name)
|
|
46
|
+
return unless pk
|
|
47
|
+
|
|
48
|
+
# Check for sequence first — UUID PKs have no sequence
|
|
49
|
+
seq = @connection.select_value(
|
|
50
|
+
"SELECT pg_get_serial_sequence(#{@connection.quote(table_name)}, #{@connection.quote(pk)})"
|
|
51
|
+
)
|
|
52
|
+
return unless seq
|
|
53
|
+
|
|
54
|
+
max_id = @connection.select_value(
|
|
55
|
+
"SELECT COALESCE(MAX(#{@connection.quote_column_name(pk)}), 0) FROM #{quoted(table_name)}"
|
|
56
|
+
)
|
|
57
|
+
@connection.execute("SELECT setval(#{@connection.quote(seq)}, #{max_id})")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def quoted(table_name)
|
|
63
|
+
@connection.quote_table_name(table_name)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Foundries
|
|
4
|
+
module Snapshot
|
|
5
|
+
module Adapters
|
|
6
|
+
class SqliteAdapter
|
|
7
|
+
def initialize(connection)
|
|
8
|
+
@connection = connection
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def table_names
|
|
12
|
+
@connection.tables - %w[schema_migrations ar_internal_metadata]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def empty?(table_name)
|
|
16
|
+
@connection.select_value("SELECT COUNT(*) FROM #{quoted(table_name)}") == 0
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def capture(table_name, io)
|
|
20
|
+
rows = @connection.select_all("SELECT * FROM #{quoted(table_name)}")
|
|
21
|
+
rows.each do |row|
|
|
22
|
+
values = row.values.map { |v| @connection.quote(v) }.join(", ")
|
|
23
|
+
columns = row.keys.map { |k| @connection.quote_column_name(k) }.join(", ")
|
|
24
|
+
io.puts "INSERT INTO #{quoted(table_name)} (#{columns}) VALUES (#{values});"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def restore(table_name, io)
|
|
29
|
+
io.each_line do |line|
|
|
30
|
+
@connection.execute(line.strip) unless line.strip.empty?
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def disable_referential_integrity
|
|
35
|
+
@connection.execute("PRAGMA defer_foreign_keys = ON")
|
|
36
|
+
yield
|
|
37
|
+
ensure
|
|
38
|
+
@connection.execute("PRAGMA defer_foreign_keys = OFF")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def reset_sequence(table_name)
|
|
42
|
+
# SQLite auto-increments handle this naturally
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def quoted(table_name)
|
|
48
|
+
@connection.quote_table_name(table_name)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Foundries
|
|
4
|
+
module Snapshot
|
|
5
|
+
class Fingerprint
|
|
6
|
+
def initialize(connection, source_paths: [])
|
|
7
|
+
@connection = connection
|
|
8
|
+
@source_paths = Array(source_paths)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def current
|
|
12
|
+
digest = Digest::MD5.new
|
|
13
|
+
digest.update(schema_version)
|
|
14
|
+
@source_paths.sort.each do |path|
|
|
15
|
+
digest.update(File.read(path)) if File.exist?(path)
|
|
16
|
+
end
|
|
17
|
+
digest.hexdigest
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def schema_version
|
|
23
|
+
@connection.select_value(
|
|
24
|
+
"SELECT MAX(version) FROM schema_migrations"
|
|
25
|
+
).to_s
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|