factory_hoist 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/LICENSE.txt +21 -0
- data/README.md +125 -0
- data/Rakefile +8 -0
- data/bench/fast_build.rb +37 -0
- data/bench/phase0_synthetic.rb +108 -0
- data/docs/adversarial-audit.md +50 -0
- data/docs/phase0-synthetic.md +19 -0
- data/exe/factory_hoist +15 -0
- data/lib/factory_hoist/advisor.rb +67 -0
- data/lib/factory_hoist/bulk_writer.rb +44 -0
- data/lib/factory_hoist/compatibility.rb +18 -0
- data/lib/factory_hoist/configuration.rb +14 -0
- data/lib/factory_hoist/database_snapshot.rb +54 -0
- data/lib/factory_hoist/deep_copy.rb +11 -0
- data/lib/factory_hoist/definition.rb +27 -0
- data/lib/factory_hoist/fast_build.rb +211 -0
- data/lib/factory_hoist/minitest.rb +78 -0
- data/lib/factory_hoist/parallel_database.rb +87 -0
- data/lib/factory_hoist/pcg32.rb +76 -0
- data/lib/factory_hoist/rspec.rb +37 -0
- data/lib/factory_hoist/runtime.rb +326 -0
- data/lib/factory_hoist/scheduler.rb +133 -0
- data/lib/factory_hoist/stats.rb +52 -0
- data/lib/factory_hoist/transaction.rb +90 -0
- data/lib/factory_hoist/version.rb +5 -0
- data/lib/factory_hoist.rb +151 -0
- data/script/verify_postgresql +102 -0
- data/script/verify_postgresql_clone +61 -0
- metadata +130 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "factory_bot"
|
|
4
|
+
require "minitest"
|
|
5
|
+
require "monitor"
|
|
6
|
+
require "rspec/core"
|
|
7
|
+
require_relative "factory_hoist/configuration"
|
|
8
|
+
require_relative "factory_hoist/pcg32"
|
|
9
|
+
require_relative "factory_hoist/runtime"
|
|
10
|
+
require_relative "factory_hoist/stats"
|
|
11
|
+
require_relative "factory_hoist/version"
|
|
12
|
+
|
|
13
|
+
module FactoryHoist
|
|
14
|
+
RANDOM_MONITOR = Monitor.new
|
|
15
|
+
|
|
16
|
+
class Error < StandardError; end
|
|
17
|
+
class BulkWriteError < Error; end
|
|
18
|
+
class DuplicateHoistError < Error; end
|
|
19
|
+
class FactoryUnavailableError < Error; end
|
|
20
|
+
class MaterializationError < Error; end
|
|
21
|
+
class SharedDataMutationError < Error; end
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
attr_writer :configuration
|
|
25
|
+
|
|
26
|
+
def configuration
|
|
27
|
+
@configuration ||= Configuration.new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def configure
|
|
31
|
+
yield(configuration)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def reset!
|
|
35
|
+
@configuration = Configuration.new
|
|
36
|
+
Thread.current[:factory_hoist_random] = nil
|
|
37
|
+
stats.reset!
|
|
38
|
+
Runtime.reset!
|
|
39
|
+
FastBuild.reset! if defined?(FastBuild)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def build(name, *traits, **attributes, &block)
|
|
43
|
+
result = if defined?(::Faker::Config)
|
|
44
|
+
with_random_source(random) { build_factory(name, traits, attributes) }
|
|
45
|
+
else
|
|
46
|
+
build_factory(name, traits, attributes)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
result.tap { |record| block&.call(record) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def create(name, *traits, **attributes, &block)
|
|
53
|
+
run_factory(:create, name, traits, attributes).tap { |record| block&.call(record) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def build_list(name, count, *traits, **attributes, &block)
|
|
57
|
+
Array.new(count) do |index|
|
|
58
|
+
build(name, *traits, **attributes).tap { |record| block&.call(record, index) }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def create_list(name, count, *traits, **attributes, &block)
|
|
63
|
+
Array.new(count) do |index|
|
|
64
|
+
create(name, *traits, **attributes).tap { |record| block&.call(record, index) }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def unsafe_bulk_insert(name, count, *traits, **attributes)
|
|
69
|
+
require_relative "factory_hoist/bulk_writer"
|
|
70
|
+
BulkWriter.call(name, count, traits, attributes)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def clone_database(source:, target:, adapter:)
|
|
74
|
+
require_relative "factory_hoist/parallel_database"
|
|
75
|
+
ParallelDatabase.clone(source: source, target: target, adapter: adapter)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def advise(*paths, io: $stdout)
|
|
79
|
+
require_relative "factory_hoist/advisor"
|
|
80
|
+
Advisor.new(paths.empty? ? ["spec"] : paths).print(io)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def stats
|
|
84
|
+
@stats ||= Stats.new
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def random
|
|
88
|
+
Thread.current[:factory_hoist_random] ||= PCG32.new(configuration.suite_seed)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def with_seed(seed)
|
|
92
|
+
with_random_source(PCG32.new(seed)) { yield }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def build_factory(name, traits, attributes)
|
|
98
|
+
adapter = configuration.factory_adapter
|
|
99
|
+
return adapter.call(:build, name, traits, attributes) if adapter
|
|
100
|
+
|
|
101
|
+
require_relative "factory_hoist/fast_build" unless defined?(::FactoryHoist::FastBuild)
|
|
102
|
+
result = FastBuild.call(name, traits, attributes)
|
|
103
|
+
return result unless result.equal?(FastBuild::FALLBACK)
|
|
104
|
+
|
|
105
|
+
call_factory_bot(:build, name, traits, attributes)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def with_random_source(source)
|
|
109
|
+
faker_config = ::Faker::Config if defined?(::Faker::Config)
|
|
110
|
+
if RANDOM_MONITOR.mon_owned? && Thread.current[:factory_hoist_random].equal?(source) &&
|
|
111
|
+
(!faker_config || faker_config.random.equal?(source))
|
|
112
|
+
return yield
|
|
113
|
+
end
|
|
114
|
+
return scope_random_source(source) { yield } unless faker_config
|
|
115
|
+
|
|
116
|
+
RANDOM_MONITOR.synchronize { scope_random_source(source) { yield } }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def scope_random_source(source)
|
|
120
|
+
previous = Thread.current[:factory_hoist_random]
|
|
121
|
+
Thread.current[:factory_hoist_random] = source
|
|
122
|
+
faker_config = ::Faker::Config if defined?(::Faker::Config)
|
|
123
|
+
previous_faker = faker_config.random if faker_config
|
|
124
|
+
faker_config.random = source if faker_config
|
|
125
|
+
yield
|
|
126
|
+
ensure
|
|
127
|
+
faker_config.random = previous_faker if faker_config
|
|
128
|
+
Thread.current[:factory_hoist_random] = previous
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def run_factory(strategy, name, traits, attributes)
|
|
132
|
+
adapter = configuration.factory_adapter
|
|
133
|
+
adapter ||= default_factory_adapter
|
|
134
|
+
adapter.call(strategy, name, traits, attributes)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def default_factory_adapter
|
|
138
|
+
return method(:call_factory_bot) if defined?(::FactoryBot)
|
|
139
|
+
|
|
140
|
+
raise FactoryUnavailableError,
|
|
141
|
+
"configure FactoryHoist.configuration.factory_adapter or require factory_bot"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def call_factory_bot(strategy, name, traits, attributes)
|
|
145
|
+
::FactoryBot.public_send(strategy, name, *traits, **attributes)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
require_relative "factory_hoist/rspec"
|
|
151
|
+
require_relative "factory_hoist/minitest"
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "active_record"
|
|
6
|
+
require "factory_hoist"
|
|
7
|
+
|
|
8
|
+
ActiveRecord::Base.establish_connection(
|
|
9
|
+
ENV.fetch("DATABASE_URL", "postgresql:///postgres")
|
|
10
|
+
)
|
|
11
|
+
connection = ActiveRecord::Base.connection
|
|
12
|
+
connection.execute <<~SQL
|
|
13
|
+
CREATE TEMP TABLE factory_hoist_subxid_check (
|
|
14
|
+
id bigserial PRIMARY KEY,
|
|
15
|
+
name text NOT NULL
|
|
16
|
+
) ON COMMIT PRESERVE ROWS
|
|
17
|
+
SQL
|
|
18
|
+
|
|
19
|
+
model = Class.new(ActiveRecord::Base) do
|
|
20
|
+
self.table_name = "factory_hoist_subxid_check"
|
|
21
|
+
end
|
|
22
|
+
FactoryHoist.reset!
|
|
23
|
+
FactoryHoist.configuration.subxid_budget = 60
|
|
24
|
+
FactoryHoist.configuration.factory_adapter = lambda do |_strategy, _name, _traits, attributes|
|
|
25
|
+
model.create!({name: "hoisted"}.merge(attributes))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
definition = FactoryHoist::Definition.new(:record, :record, [], {}, nil, "postgres check")
|
|
29
|
+
session = FactoryHoist::Runtime::Session.new
|
|
30
|
+
group = Object.new
|
|
31
|
+
transaction_ids = []
|
|
32
|
+
subtrans_reads = lambda do
|
|
33
|
+
connection.select_value("SELECT blks_read FROM pg_stat_slru WHERE name = 'Subtrans'").to_i
|
|
34
|
+
end
|
|
35
|
+
before_reads = subtrans_reads.call
|
|
36
|
+
checkpoint = connection.select_value("SELECT next_xid::text FROM pg_control_checkpoint()")
|
|
37
|
+
|
|
38
|
+
session.enter(group, {record: definition})
|
|
39
|
+
61.times do
|
|
40
|
+
example = Object.new
|
|
41
|
+
example.define_singleton_method(:run) do
|
|
42
|
+
connection.execute("INSERT INTO factory_hoist_subxid_check(name) VALUES ('example')")
|
|
43
|
+
transaction_ids << connection.select_value("SELECT txid_current_if_assigned()")
|
|
44
|
+
raise "backend xid is not visible" unless connection.select_value(<<~SQL)
|
|
45
|
+
SELECT backend_xid::text FROM pg_stat_activity WHERE pid = pg_backend_pid()
|
|
46
|
+
SQL
|
|
47
|
+
end
|
|
48
|
+
session.around_example(example)
|
|
49
|
+
end
|
|
50
|
+
session.leave(group)
|
|
51
|
+
|
|
52
|
+
raise "outer transaction was not rebuilt" unless transaction_ids.uniq.size == 2
|
|
53
|
+
raise "unexpected subtrans disk read" unless subtrans_reads.call == before_reads
|
|
54
|
+
raise "hoisted rows leaked" unless model.count.zero?
|
|
55
|
+
raise "pg_control_checkpoint unavailable" unless checkpoint
|
|
56
|
+
|
|
57
|
+
FactoryHoist.configuration.subxid_budget = 1
|
|
58
|
+
context_group = Object.new
|
|
59
|
+
session.enter(context_group, {record: definition}, materialize: false)
|
|
60
|
+
model.create!(name: "context setup")
|
|
61
|
+
session.materialize(context_group)
|
|
62
|
+
2.times do
|
|
63
|
+
example = Object.new
|
|
64
|
+
example.define_singleton_method(:run) do
|
|
65
|
+
raise "context setup was lost during rebuild" unless model.count == 2
|
|
66
|
+
end
|
|
67
|
+
session.around_example(example)
|
|
68
|
+
end
|
|
69
|
+
session.leave(context_group)
|
|
70
|
+
raise "context setup leaked" unless model.count.zero?
|
|
71
|
+
|
|
72
|
+
FactoryBot.define do
|
|
73
|
+
factory :factory_hoist_postgres_bulk_check, class: model do
|
|
74
|
+
name { "bulk" }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
connection.transaction(requires_new: true) do
|
|
78
|
+
begin
|
|
79
|
+
FactoryHoist.unsafe_bulk_insert(:factory_hoist_postgres_bulk_check, 2, name: nil)
|
|
80
|
+
rescue FactoryHoist::BulkWriteError => error
|
|
81
|
+
raise "bulk failure location missing" unless error.message.include?("row 0")
|
|
82
|
+
else
|
|
83
|
+
raise "invalid bulk insert unexpectedly succeeded"
|
|
84
|
+
end
|
|
85
|
+
model.create!(name: "transaction remains usable")
|
|
86
|
+
raise ActiveRecord::Rollback
|
|
87
|
+
end
|
|
88
|
+
raise "bulk verification leaked rows" unless model.count.zero?
|
|
89
|
+
|
|
90
|
+
FactoryHoist.configuration.factory_adapter = lambda do |_strategy, _name, _traits, attributes|
|
|
91
|
+
model.create!({name: "reset"}.merge(attributes))
|
|
92
|
+
end
|
|
93
|
+
reset_definition = FactoryHoist::Definition.new(:record, :record, [], {}, nil, "reset check")
|
|
94
|
+
FactoryHoist::Runtime.current.enter(Object.new, {record: reset_definition})
|
|
95
|
+
raise "reset setup failed" unless model.count == 1
|
|
96
|
+
|
|
97
|
+
FactoryHoist.reset!
|
|
98
|
+
|
|
99
|
+
raise "reset left a transaction open" if connection.transaction_open?
|
|
100
|
+
raise "reset leaked rows" unless model.count.zero?
|
|
101
|
+
|
|
102
|
+
puts "PostgreSQL verified: subxid budget, context state, bulk failure recovery, and reset cleanup"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "pg"
|
|
6
|
+
require "factory_hoist"
|
|
7
|
+
|
|
8
|
+
suffix = "#{Process.pid}_#{Time.now.to_i}"
|
|
9
|
+
source = "factory_hoist_source_#{suffix}"
|
|
10
|
+
target = "factory_hoist_worker_#{suffix}"
|
|
11
|
+
begin
|
|
12
|
+
admin = PG.connect(dbname: "postgres")
|
|
13
|
+
admin.exec("CREATE DATABASE #{admin.quote_ident(source)}")
|
|
14
|
+
seed = PG.connect(dbname: source)
|
|
15
|
+
seed.exec("CREATE TABLE proof(value text NOT NULL)")
|
|
16
|
+
seed.exec("INSERT INTO proof VALUES ('cloned')")
|
|
17
|
+
|
|
18
|
+
begin
|
|
19
|
+
FactoryHoist.clone_database(
|
|
20
|
+
source: "postgresql:///#{source}",
|
|
21
|
+
target: target,
|
|
22
|
+
adapter: :postgresql
|
|
23
|
+
)
|
|
24
|
+
rescue FactoryHoist::Error => error
|
|
25
|
+
raise unless error.message.include?("active connection")
|
|
26
|
+
else
|
|
27
|
+
raise "clone accepted an active source connection"
|
|
28
|
+
end
|
|
29
|
+
seed.close
|
|
30
|
+
|
|
31
|
+
FactoryHoist.clone_database(
|
|
32
|
+
source: "postgresql:///#{source}",
|
|
33
|
+
target: target,
|
|
34
|
+
adapter: :postgresql
|
|
35
|
+
)
|
|
36
|
+
worker = PG.connect(dbname: target)
|
|
37
|
+
raise "template data missing" unless worker.exec("SELECT value FROM proof").getvalue(0, 0) == "cloned"
|
|
38
|
+
worker.close
|
|
39
|
+
|
|
40
|
+
begin
|
|
41
|
+
FactoryHoist.clone_database(
|
|
42
|
+
source: "postgresql:///#{source}",
|
|
43
|
+
target: target,
|
|
44
|
+
adapter: :postgresql
|
|
45
|
+
)
|
|
46
|
+
rescue FactoryHoist::Error => error
|
|
47
|
+
raise unless error.message.include?("already exists")
|
|
48
|
+
else
|
|
49
|
+
raise "clone overwrote an existing target"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
puts "PostgreSQL template clone and refusal paths verified: #{source} -> #{target}"
|
|
53
|
+
ensure
|
|
54
|
+
worker&.close unless worker&.finished?
|
|
55
|
+
seed&.close unless seed&.finished?
|
|
56
|
+
if admin
|
|
57
|
+
admin.exec("DROP DATABASE IF EXISTS #{admin.quote_ident(target)} WITH (FORCE)") if target
|
|
58
|
+
admin.exec("DROP DATABASE IF EXISTS #{admin.quote_ident(source)} WITH (FORCE)") if source
|
|
59
|
+
admin.close
|
|
60
|
+
end
|
|
61
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: factory_hoist
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Yudai Takada
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: factory_bot
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '6.5'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '7'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '6.5'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '7'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: minitest
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '5'
|
|
39
|
+
- - "<"
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '7'
|
|
42
|
+
type: :runtime
|
|
43
|
+
prerelease: false
|
|
44
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '5'
|
|
49
|
+
- - "<"
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '7'
|
|
52
|
+
- !ruby/object:Gem::Dependency
|
|
53
|
+
name: rspec-core
|
|
54
|
+
requirement: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: '3.12'
|
|
59
|
+
- - "<"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '4'
|
|
62
|
+
type: :runtime
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '3.12'
|
|
69
|
+
- - "<"
|
|
70
|
+
- !ruby/object:Gem::Version
|
|
71
|
+
version: '4'
|
|
72
|
+
description: Hoists FactoryBot records to RSpec group boundaries while isolating each
|
|
73
|
+
example.
|
|
74
|
+
email:
|
|
75
|
+
- t.yudai92@gmail.com
|
|
76
|
+
executables:
|
|
77
|
+
- factory_hoist
|
|
78
|
+
extensions: []
|
|
79
|
+
extra_rdoc_files: []
|
|
80
|
+
files:
|
|
81
|
+
- LICENSE.txt
|
|
82
|
+
- README.md
|
|
83
|
+
- Rakefile
|
|
84
|
+
- bench/fast_build.rb
|
|
85
|
+
- bench/phase0_synthetic.rb
|
|
86
|
+
- docs/adversarial-audit.md
|
|
87
|
+
- docs/phase0-synthetic.md
|
|
88
|
+
- exe/factory_hoist
|
|
89
|
+
- lib/factory_hoist.rb
|
|
90
|
+
- lib/factory_hoist/advisor.rb
|
|
91
|
+
- lib/factory_hoist/bulk_writer.rb
|
|
92
|
+
- lib/factory_hoist/compatibility.rb
|
|
93
|
+
- lib/factory_hoist/configuration.rb
|
|
94
|
+
- lib/factory_hoist/database_snapshot.rb
|
|
95
|
+
- lib/factory_hoist/deep_copy.rb
|
|
96
|
+
- lib/factory_hoist/definition.rb
|
|
97
|
+
- lib/factory_hoist/fast_build.rb
|
|
98
|
+
- lib/factory_hoist/minitest.rb
|
|
99
|
+
- lib/factory_hoist/parallel_database.rb
|
|
100
|
+
- lib/factory_hoist/pcg32.rb
|
|
101
|
+
- lib/factory_hoist/rspec.rb
|
|
102
|
+
- lib/factory_hoist/runtime.rb
|
|
103
|
+
- lib/factory_hoist/scheduler.rb
|
|
104
|
+
- lib/factory_hoist/stats.rb
|
|
105
|
+
- lib/factory_hoist/transaction.rb
|
|
106
|
+
- lib/factory_hoist/version.rb
|
|
107
|
+
- script/verify_postgresql
|
|
108
|
+
- script/verify_postgresql_clone
|
|
109
|
+
licenses:
|
|
110
|
+
- MIT
|
|
111
|
+
metadata:
|
|
112
|
+
rubygems_mfa_required: 'true'
|
|
113
|
+
rdoc_options: []
|
|
114
|
+
require_paths:
|
|
115
|
+
- lib
|
|
116
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
117
|
+
requirements:
|
|
118
|
+
- - ">="
|
|
119
|
+
- !ruby/object:Gem::Version
|
|
120
|
+
version: 3.2.0
|
|
121
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
122
|
+
requirements:
|
|
123
|
+
- - ">="
|
|
124
|
+
- !ruby/object:Gem::Version
|
|
125
|
+
version: '0'
|
|
126
|
+
requirements: []
|
|
127
|
+
rubygems_version: 4.0.19
|
|
128
|
+
specification_version: 4
|
|
129
|
+
summary: Share factory data safely across RSpec examples
|
|
130
|
+
test_files: []
|