oaken 0.2.0 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 160e746d1605f0e227c7084ce39ee6e05837cfe4288de735b4407c39a45d4fbe
4
- data.tar.gz: 9510c077a7f0f7bad0ebc56bfb6306cf2465253a04d6c19f28009a3552a47719
3
+ metadata.gz: f7a49242a569f0460aab42af0102a0aeec30095909c6362310b45e9ce0638c43
4
+ data.tar.gz: 218c22c32d91b4deaf4f3524f4a19daea964df126c3018b048a2a1119c679297
5
5
  SHA512:
6
- metadata.gz: 393bd015fadfb6e806a49f43be92fbf764d89c15577f91963361cef6d8221ba8cbd2992c0edba6b38920831dbe3299482b12c3c46e1b2f5b593617ce8c697ccf
7
- data.tar.gz: 3e6480a0ef119fa5929f44a6481359d7247ff4da4b4fcbc45225c90e98ad1c0897857f63d29c685b2a6af70f663724606360bb9487802fc9ac5995b8930ead9f
6
+ metadata.gz: 40f50f45dd4bfc45b326aacc111792b30a178446e3f51b1db9b0e9c0f95f91d6b3269a28fc3aaf8d797d5def0c6a6f1dad0ae6d61e4d1fe44aed7b994fb66dda
7
+ data.tar.gz: 9c21c825239bf7bb910632abe86d6672f63dcf9fe50d0cdf42270f03aed09ff9cad3268e6e5ec70957717996193791a43c67d2cab803ad4e16efce313f29fed6
data/README.md CHANGED
@@ -1,22 +1,119 @@
1
1
  # Oaken
2
2
 
3
- The gem we're building in the Open Source Retreat https://kaspthrb.gumroad.com/l/open-source-retreat
3
+ Oaken is an alternative to fixtures and/or factories to manage your development, test and some production data using data scripts.
4
4
 
5
- ## Installation
5
+ ## Setup
6
+
7
+ ### Starting in development
8
+
9
+ You can set it up in `db/seeds.rb`, like this:
10
+
11
+ ```ruby
12
+ Oaken.prepare do
13
+ seed :accounts, :data
14
+ end
15
+ ```
16
+
17
+ This will look for deeply nested files to load in `db/seeds` and `db/seeds/#{Rails.env}` within the `accounts` and `data` directories.
18
+
19
+ Here's what they could look like.
20
+
21
+ ```ruby
22
+ # db/seeds/accounts/kaspers_donuts.rb
23
+ donuts = accounts.create :kaspers_donuts, name: "Kasper's Donuts"
24
+
25
+ kasper = users.create :kasper, name: "Kasper", accounts: [donuts]
26
+ coworker = users.create :coworker, name: "Coworker", accounts: [donuts]
27
+
28
+ menu = menus.create account: donuts
29
+ plain_donut = menu_items.create menu: menu, name: "Plain", price_cents: 10_00
30
+ sprinkled_donut = menu_items.create menu: menu, name: "Sprinkled", price_cents: 10_10
31
+
32
+ supporter = users.create name: "Super Supporter"
33
+ orders.insert_all [user_id: supporter.id, item_id: plain_donut.id] * 10
34
+
35
+ orders.insert_all \
36
+ 10.times.map { { user_id: users.create(name: "Customer #{_1}").id, item_id: menu.items.sample.id } }
37
+ ```
38
+
39
+ ```ruby
40
+ # db/seeds/data/plans.rb
41
+ plans.insert :basic, title: "Basic", price_cents: 10_00
42
+ ```
43
+
44
+ Seed files will generally use `create` and/or `insert`. Passing a symbol to name the record is useful when reusing the data in tests.
45
+
46
+ Now you can run `bin/rails db:seed` — plus Oaken skips executing a seed file if it knows the file hasn't been changed since the last seeding. Speedy!
47
+
48
+ ### Interlude: Directory Naming Conventions
49
+
50
+ Oaken has some chosen directory conventions to help strengthen your understanding of your object graph:
51
+
52
+ - Have a directory for your top-level model, like `Account`, `Team`, `Organization`, that's why we have `db/seeds/accounts` above.
53
+ - `db/seeds/data` for any data tables, like the plans a SaaS app has.
54
+ - `db/seeds/tests/cases` for any specific cases that are only used in some tests, like `pagination.rb`.
55
+
56
+ ### Reusing data in tests
57
+
58
+ With the setup above, Oaken can reuse the same data in tests like so:
6
59
 
7
- TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
60
+ ```ruby
61
+ # test/test_helper.rb
62
+ class ActiveSupport::TestCase
63
+ include Oaken.seeds
64
+
65
+ # Override Minitest::Test#run to wrap each test in a transaction.
66
+ def run
67
+ result = nil
68
+ ActiveRecord::Base.transaction(requires_new: true) do
69
+ result = super
70
+ raise ActiveRecord::Rollback
71
+ end
72
+ result
73
+ end
74
+ end
75
+ ```
76
+
77
+ Now tests have access to `accounts.kaspers_donuts` and `users.kasper` etc. that were setup in the data scripts.
78
+
79
+ You can also load a specific seed, like this:
80
+
81
+ ```ruby
82
+ class PaginationTest < ActionDispatch::IntegrationTest
83
+ seed "cases/pagination"
84
+ end
85
+ ```
86
+
87
+ ### Resetting cache
88
+
89
+ Oaken is still early days, so you may need to reset the cache that skips seed files. Pass `OAKEN_RESET` to clear it:
90
+
91
+ ```sh
92
+ OAKEN_RESET=1 bin/rails db:seed
93
+ OAKEN_RESET=1 bin/rails test
94
+ ```
95
+
96
+ ### Fixtures Converter
97
+
98
+ You can convert your Rails fixtures to Oaken's seeds by running:
99
+
100
+ $ bin/rails generate oaken:convert:fixtures
101
+
102
+ This will convert anything in test/fixtures to db/seeds. E.g. `test/fixtures/users.yml` becomes `db/seeds/users.rb`.
103
+
104
+ ## Installation
8
105
 
9
106
  Install the gem and add to the application's Gemfile by executing:
10
107
 
11
- $ bundle add UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG
108
+ $ bundle add oaken
12
109
 
13
110
  If bundler is not being used to manage dependencies, install the gem by executing:
14
111
 
15
- $ gem install UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG
112
+ $ gem install oaken
16
113
 
17
114
  ## Development
18
115
 
19
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `bin/test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
116
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `cd test/dummy` and `bin/rails test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
20
117
 
21
118
  To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
22
119
 
@@ -30,4 +127,21 @@ The gem is available as open source under the terms of the [MIT License](https:/
30
127
 
31
128
  ## Code of Conduct
32
129
 
33
- Everyone interacting in the Oaken project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/kaspth/oaken/blob/main/CODE_OF_CONDUCT.md).
130
+ Everyone interacting in the Oaken project's codebases, issue trackers, chat rooms, and mailing lists is expected to follow the [code of conduct](https://github.com/kaspth/oaken/blob/main/CODE_OF_CONDUCT.md).
131
+
132
+ ## Support
133
+
134
+ Initial development is supported in part by:
135
+
136
+ <a href="https://arrows.to">
137
+ <img src="https://user-images.githubusercontent.com/56947/258236465-06c692a7-738e-44bd-914e-fecc697317ce.png" />
138
+ </a>
139
+
140
+ And by:
141
+
142
+ - [Alexandre Ruban](https://github.com/alexandreruban)
143
+ - [Lars Kronfält](https://github.com/larkro)
144
+ - [Manuel Costa Reis](https://github.com/manuelfcreis)
145
+ - [Thomas Cannon](https://github.com/tcannonfodder)
146
+
147
+ As a sponsor you're welcome to submit a pull request to add your own name here.
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require "yaml"
5
+
6
+ module Oaken::Convert; end
7
+ class Oaken::Convert::Fixture
8
+ attr_reader :model_name, :name
9
+
10
+ def initialize(model_name, name, attributes)
11
+ @model_name, @name, @attributes = model_name.tr("/", "_"), name, attributes
12
+ @plural = @model_name
13
+ @singular = @model_name.singularize
14
+ end
15
+
16
+ def extract_dependents(fixtures)
17
+ @dependents = fixtures.select { _1.reference(plural, singular) == name }
18
+ fixtures.replace fixtures - dependents
19
+
20
+ dependents.each { _1.extract_dependents fixtures }
21
+ end
22
+
23
+ def reference(plural, singular)
24
+ @referenced = [plural, :plural] if attributes[plural]
25
+ @referenced = [singular, :singular] if attributes[singular]
26
+ attributes[@referenced&.first]
27
+ end
28
+
29
+ def render(delimiter: "\n")
30
+ [render_self, dependents&.map { _1.render delimiter: nil }].join(delimiter)
31
+ end
32
+
33
+ private
34
+ attr_reader :attributes, :dependents
35
+ attr_reader :plural, :singular
36
+
37
+ def render_self
38
+ "#{model_name}.create :#{name}, #{convert_hash(attributes)}\n".tap do
39
+ _1.prepend "#{name} = " if dependents&.any?
40
+ end
41
+ end
42
+
43
+ def convert_hash(hash)
44
+ hash.map { |k, v| "#{k}: #{recursive_convert(v, key: k)}" }.join(", ")
45
+ end
46
+
47
+ def recursive_convert(input, key: nil)
48
+ case input
49
+ when Hash then "{ #{convert_hash(input)} }"
50
+ when Array then input.map { recursive_convert _1 }.join(", ")
51
+ when Integer then input
52
+ else
53
+ if key == @referenced&.first
54
+ @referenced.last == :plural ? "[#{input}]" : input
55
+ else
56
+ "\"#{input}\""
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ class Oaken::Convert::FixturesGenerator < Rails::Generators::Base
63
+ desc "Converts fixtures to Oaken seeds in db/seeds/test"
64
+ source_root File.expand_path("templates", __dir__)
65
+
66
+ class_option :root_model, required: true
67
+ class_option :keeps, type: :boolean, default: true
68
+
69
+ def prepare
70
+ @root_model = ActiveModel::Name.new(options[:root_model].constantize)
71
+ empty_directory_with_keep_file "db/seeds/data"
72
+ empty_directory_with_keep_file "db/seeds/test/cases"
73
+ end
74
+
75
+ def parse
76
+ @fixtures = Dir.glob("test/fixtures/**/*.yml").to_h do |path|
77
+ model_name = path.delete_prefix("test/fixtures/").chomp(".yml")
78
+ [model_name, YAML.load_file(path).presence&.map { Oaken::Convert::Fixture.new(model_name, _1, _2) }]
79
+ rescue Psych::SyntaxError
80
+ say "Skipped #{path} due to ERB content or other YAML parsing issues.", :yellow
81
+ end.tap(&:compact_blank!)
82
+ end
83
+
84
+ def prepend_prepare_to_seeds
85
+ namespaces = @fixtures.keys.filter_map { _1.classify if _1.include?("/") }.uniq.sort
86
+
87
+ code = +"Oaken.prepare do\n"
88
+ code << " register #{namespaces.join(", ")}\n\n" if namespaces.any?
89
+ code << " load :#{@root_model.plural}, :data\n"
90
+ code << "end\n"
91
+
92
+ inject_into_file "db/seeds.rb", code, before: /\A/
93
+ end
94
+
95
+ def convert_all
96
+ roots = @fixtures.delete(@root_model.collection)
97
+ @fixtures = @fixtures.values.flatten
98
+
99
+ roots.each do |fixture|
100
+ fixture.extract_dependents @fixtures
101
+ create_file "db/seeds/test/#{@root_model.plural}/#{fixture.name}.rb", fixture.render.chomp
102
+ end
103
+
104
+ @fixtures.group_by(&:model_name).each do |model_name, fixtures|
105
+ create_file "db/seeds/test/data/#{model_name}.rb", fixtures.map(&:render).join.chomp
106
+ end
107
+ end
108
+
109
+ private
110
+ def empty_directory_with_keep_file(name)
111
+ empty_directory name
112
+ create_file "#{name}/.keep" if options[:keeps]
113
+ end
114
+ end
@@ -0,0 +1,60 @@
1
+ require "digest/md5"
2
+ require "pstore"
3
+
4
+ class Oaken::Entry < DelegateClass(PStore)
5
+ def self.store_accessor(name)
6
+ define_method(name) { self[name] } and define_method("#{name}=") { |value| self[name] = value }
7
+ end
8
+ store_accessor :checksum
9
+ store_accessor :readers
10
+
11
+ def self.within(directory)
12
+ Pathname.glob("#{directory}{,/**/*}.{rb,sql}").sort.map { new _1 }
13
+ end
14
+
15
+ def initialize(pathname)
16
+ @file, @pathname = pathname.to_s, pathname
17
+ @computed_checksum = Digest::MD5.hexdigest(@pathname.read)
18
+
19
+ prepared_store_path = Oaken.store_path.join(pathname).tap { _1.dirname.mkpath }
20
+ super PStore.new(prepared_store_path)
21
+ end
22
+
23
+ def load_onto(seeds)
24
+ transaction do
25
+ if replay?
26
+ puts "Replaying #{@file}…"
27
+ readers.each do |key, *args|
28
+ define_reader(seeds.send(key), *args)
29
+ end
30
+ else
31
+ reset
32
+
33
+ case @pathname.extname
34
+ in ".rb" then seeds.class_eval @pathname.read, @file
35
+ in ".sql" then ActiveRecord::Base.connection.execute @pathname.read
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ def transaction(&block)
42
+ super do
43
+ Oaken.transaction(&block)
44
+ end
45
+ end
46
+
47
+ def replay?
48
+ checksum == @computed_checksum
49
+ end
50
+
51
+ def reset
52
+ self.checksum = @computed_checksum
53
+ self.readers = Set.new
54
+ end
55
+
56
+ def define_reader(stored, name, id, lineno)
57
+ stored.instance_eval "def #{name}; find #{id}; end", @file, lineno
58
+ readers << [stored.key, name, id, lineno]
59
+ end
60
+ end
@@ -0,0 +1,25 @@
1
+ class Oaken::Railtie < Rails::Railtie
2
+ initializer "oaken.defaults" do
3
+ Oaken.lookup_paths << "db/seeds/#{Rails.env}"
4
+ Oaken.store_path = Oaken.store_path.join(Rails.env)
5
+ end
6
+
7
+ rake_tasks do
8
+ namespace :oaken do
9
+ task("reset") { Oaken.store_path.rmtree }
10
+ task("reset:all") { Oaken.store_path.dirname.rmtree }
11
+
12
+ task "reset:include_test" do
13
+ # Some db: tasks in development also manipulate the test database.
14
+ Oaken.store_path.sub("development", "test").rmtree if Rails.env.development?
15
+ end
16
+ end
17
+
18
+ task "db:drop" => ["oaken:reset", "oaken:reset:include_test"]
19
+ task "db:purge" => ["oaken:reset", "oaken:reset:include_test"]
20
+ task "db:purge:all" => ["oaken:reset:all"]
21
+
22
+ # db:seed:replant runs trunacte_all, after trial-and-error we need to hook into that and not the replant task.
23
+ task "db:truncate_all" => "oaken:purge"
24
+ end
25
+ end
@@ -0,0 +1,41 @@
1
+ module Oaken::Seeds
2
+ extend self
3
+
4
+ def self.respond_to_missing?(name, ...)
5
+ Oaken.inflector.classify(name).safe_constantize || super
6
+ end
7
+
8
+ def self.method_missing(meth, ...)
9
+ name = meth.to_s
10
+ if type = Oaken.inflector.classify(name).safe_constantize
11
+ register type, name
12
+ public_send(name, ...)
13
+ else
14
+ super
15
+ end
16
+ end
17
+
18
+ def self.register(type, key = nil)
19
+ stored = provider.new(type, key) and define_method(stored.key) { stored }
20
+ end
21
+ def self.provider = Oaken::Stored::ActiveRecord
22
+
23
+ singleton_class.attr_reader :loader
24
+ delegate :entry, to: :loader
25
+
26
+ module Loading
27
+ def seed(*directories)
28
+ Oaken.lookup_paths.each do |path|
29
+ directories.each do |directory|
30
+ @loader = Oaken::Loader.new Pathname(path).join(directory.to_s)
31
+ @loader.load_onto Oaken::Seeds
32
+ end
33
+ end
34
+ end
35
+ end
36
+ extend Loading
37
+
38
+ def self.included(klass)
39
+ klass.extend Loading
40
+ end
41
+ end
@@ -0,0 +1,40 @@
1
+ class Oaken::Stored::ActiveRecord < Struct.new(:type, :key)
2
+ def initialize(type, key = nil)
3
+ super(type, key || Oaken.inflector.tableize(type.name))
4
+ @attributes = {}
5
+ end
6
+ delegate :transaction, to: :type # For multi-db setups to help open a transaction on secondary connections.
7
+ delegate :find, :insert_all, :pluck, to: :type
8
+
9
+ def defaults(**attributes)
10
+ @attributes = @attributes.merge(attributes)
11
+ @attributes
12
+ end
13
+
14
+ def create(reader = nil, **attributes)
15
+ lineno = caller_locations(1, 1).first.lineno
16
+
17
+ attributes = @attributes.merge(attributes)
18
+ attributes.transform_values! { _1.respond_to?(:call) ? _1.call : _1 }
19
+
20
+ type.create!(**attributes).tap do |record|
21
+ define_reader reader, record.id, lineno if reader
22
+ end
23
+ end
24
+
25
+ def insert(reader = nil, **attributes)
26
+ lineno = caller_locations(1, 1).first.lineno
27
+
28
+ attributes = @attributes.merge(attributes)
29
+ attributes.transform_values! { _1.respond_to?(:call) ? _1.call : _1 }
30
+
31
+ type.new(attributes).validate!
32
+ type.insert(attributes).tap do
33
+ define_reader reader, type.where(attributes).pick(:id), lineno if reader
34
+ end
35
+ end
36
+
37
+ def define_reader(...)
38
+ Oaken::Seeds.entry.define_reader(self, ...)
39
+ end
40
+ end
data/lib/oaken/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Oaken
4
- VERSION = "0.2.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/oaken.rb CHANGED
@@ -1,8 +1,69 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "oaken/version"
3
+ require "oaken/version"
4
+ require "pathname"
4
5
 
5
6
  module Oaken
6
7
  class Error < StandardError; end
7
- # Your code goes here...
8
+
9
+ autoload :Seeds, "oaken/seeds"
10
+ autoload :Entry, "oaken/entry"
11
+
12
+ module Stored
13
+ autoload :ActiveRecord, "oaken/stored/active_record"
14
+ end
15
+
16
+ class Inflector
17
+ def tableize(string)
18
+ string.gsub(/(?<=[a-z])(?=[A-Z])/, "_").gsub("::", "_").tap(&:downcase!) << "s"
19
+ end
20
+
21
+ def classify(string)
22
+ string.chomp("s").gsub(/_([a-z])/) { $1.upcase }.sub(/^\w/, &:upcase)
23
+ end
24
+ end
25
+
26
+ singleton_class.attr_accessor :inflector
27
+ @inflector = Inflector.new
28
+
29
+ singleton_class.attr_reader :lookup_paths
30
+ @lookup_paths = ["db/seeds"]
31
+
32
+ singleton_class.attr_accessor :store_path
33
+ @store_path = Pathname.new "tmp/oaken/store"
34
+
35
+ class Loader
36
+ attr_reader :entry
37
+
38
+ def initialize(path)
39
+ @entries, @entry = Entry.within(path), nil
40
+ end
41
+
42
+ def load_onto(seeds)
43
+ @entries.each do |entry|
44
+ @entry = entry
45
+ @entry.load_onto seeds
46
+ end
47
+ end
48
+ end
49
+
50
+ def self.transaction(&block)
51
+ ActiveRecord::Base.transaction(&block)
52
+ end
53
+
54
+ def self.prepare(&block)
55
+ store_path.rmtree if ENV["OAKEN_RESET"]
56
+ Seeds.instance_eval(&block)
57
+ Seeds
58
+ end
59
+
60
+ def self.seeds
61
+ unless defined?(@loaded)
62
+ @loaded = true
63
+ Rails.application.load_seed
64
+ end
65
+ Seeds
66
+ end
8
67
  end
68
+
69
+ require_relative "oaken/railtie" if defined?(Rails::Railtie)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: oaken
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kasper Timm Hansen
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-07-20 00:00:00.000000000 Z
11
+ date: 2023-11-16 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description:
14
14
  email:
@@ -22,14 +22,19 @@ files:
22
22
  - LICENSE.txt
23
23
  - README.md
24
24
  - Rakefile
25
+ - lib/generators/oaken/convert/fixtures_generator.rb
25
26
  - lib/oaken.rb
27
+ - lib/oaken/entry.rb
28
+ - lib/oaken/railtie.rb
29
+ - lib/oaken/seeds.rb
30
+ - lib/oaken/stored/active_record.rb
26
31
  - lib/oaken/version.rb
27
- homepage: https://kaspthrb.gumroad.com/l/open-source-retreat-summer-2023
32
+ homepage: https://github.com/kaspth/oaken
28
33
  licenses:
29
34
  - MIT
30
35
  metadata:
31
36
  allowed_push_host: https://rubygems.org
32
- homepage_uri: https://kaspthrb.gumroad.com/l/open-source-retreat-summer-2023
37
+ homepage_uri: https://github.com/kaspth/oaken
33
38
  source_code_uri: https://github.com/kaspth/oaken
34
39
  changelog_uri: https://github.com/kaspth/oaken/blob/main/CHANGELOG.md
35
40
  post_install_message:
@@ -47,7 +52,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
47
52
  - !ruby/object:Gem::Version
48
53
  version: '0'
49
54
  requirements: []
50
- rubygems_version: 3.4.17
55
+ rubygems_version: 3.4.19
51
56
  signing_key:
52
57
  specification_version: 4
53
58
  summary: Oaken aims to blend your Fixtures/Factories and levels up your database seeds.