oaken 0.9.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 26963660acac39f907c83e52191ce7e99e005e140df3bacf6ff70d7e02d17f49
4
- data.tar.gz: 95227e2756e40db548630ac8e667192a8bb3d00230f1d8218dd595a68c6f2907
3
+ metadata.gz: db5000f2e4a4967a798e13a086a117bc52c4f0d17c8db2fc62050bf268754b8a
4
+ data.tar.gz: e81fc5bf8328905ebf5bdc73a37ca25b8e76c1902aacb40eaefa8df5141fc576
5
5
  SHA512:
6
- metadata.gz: b61959db0e0a87609dbfb209c846a7696f051bd214cc125678499936c4879399eef9a37acdb0d65eeb1296b34d7c13142dd04bbc6ad23cb2137bcbefdf82a74f
7
- data.tar.gz: 968f223df5ad49644a47c3fe3eeca04a62d51aeb0d9060ec3c04245428857ef6adc72fee5784c47570778ae95684b6347d80cdbc15d7877971240ece1d1281f0
6
+ metadata.gz: b970b6299f3d3555b074d78208e695f2abf971665b7a7e8aeca298f938dfc7be7262b0bb3f4d4ed2baf691570fef21e1efe1c7f7c89417c6346a4c25b5c31eda
7
+ data.tar.gz: f3fcd7ce30daf87d6c0add0636aff6a196e6624746fc4bce8fd420cf9bbbcbf0ca386cb00c54b6a99f05dcee44ff69e6fa3fd58bdb9731d0fa6d41193acb6496
data/README.md CHANGED
@@ -2,6 +2,48 @@
2
2
 
3
3
  Oaken is fixtures + factories + seeds for your Rails development & test environments.
4
4
 
5
+ You may want to head straight to the [examples](examples) directory.
6
+
7
+ ## Benefits
8
+
9
+ - In development:
10
+ - Add conventions for `db/seeds.rb` that feel like Rails.
11
+ - Grouping by scenario: choose how to best group your data.
12
+ - Reveal your Domain Model: describe your object graph sequentially, helping developers see how your app works.
13
+ - Ruby-based recipe-like data scripts: setup accounts first, then attach users, then hang other models off of those.
14
+ - Raise modelling problems early: Oaken can help expose when something feels awkward or off to give you design clues.
15
+
16
+ - In tests, reuse development seed data:
17
+ - Tests mirror what developers have already seen in their dev browser.
18
+ - Easier developer onboarding by building a proper mental model faster.
19
+ - Less code that's easier to maintain with more visibility into your object graph.
20
+
21
+ ### Benefits over factories
22
+
23
+ - Fast tests! Seed shared records once and reuse them across tests, transactions rollback changes.
24
+ - One team saw a 3x speed increase over factories.
25
+ - Another shaved off 5min on their CI time with less than a days work, and they only scratched the surface.
26
+
27
+ - Visibility into your system: factories optimize for isolation, which make it easy to get started, but becomes more complex over time.
28
+ - Save 20 lines of setup per test case.
29
+ - Your mileage will vary, but this happened for a team that replaced factories with Oaken.
30
+ - You do DRY up your tests more for non-trivial systems because you write the Oaken seed for common cases once, rather than per-test/file.
31
+
32
+ - You can name things: don't lose hours debugging factory-based tests because everything's anonymous, like I have.
33
+ - Less mental load: factories' structure requires you to put things together in your head. How many models and associations does one factory create?
34
+ - DRY setup:
35
+
36
+ Also worth noting that you
37
+
38
+ ### Benefits over fixtures
39
+
40
+ - Still fast tests! Oaken inserts data before tests run & wraps tests in transactions, just like fixtures.
41
+ - No more YAML: use Ruby to lay out your object graph sequentially with better organization. You can even sketch it out in a `console`
42
+ - Break out edge cases or complex scenarios: split data for edge cases or tricky test setups into their own cases, so you don't end up with a 500-line fixture file peppered with warts.
43
+ - Way easier to reason about your object graph: no more chasing 10 files for 10 associations.
44
+ - Less mental load: fixtures' structure requires you to put things together in your head.
45
+ - Way condensed data setup: Oaken can dramatically cut down on some fixture files by letting you use helpers
46
+
5
47
  ## Oaken is like fixtures, without the nightmare UX
6
48
 
7
49
  Oaken takes inspiration from Rails' fixtures' approach of storytelling about your app's object graph, but replaces the nightmare YAML-based UX with Ruby-based data scripts. This makes data much easier to reason about & connect the dots.
@@ -176,27 +218,27 @@ So if you call `Oaken.loader.seed :accounts`, we'll look within `db/seeds/` and
176
218
  > [!TIP]
177
219
  > Putting a file in the top-level `db/seeds` versus `db/seeds/development` or `db/seeds/test` means it's shared in both environments. See below for more tips.
178
220
 
179
- Any directories and/or single-file matches are loaded in the order they're specified. So `loader.seed :setup, :accounts` would first load setup and then accounts.
221
+ Any directories and/or single-file matches are loaded in the order they're specified. So `loader.seed :data, :accounts` would first load data and then accounts.
180
222
 
181
223
  > [!IMPORTANT]
182
224
  > Understanding and making effective use of Oaken's directory loading will pay dividends for your usage. You generally want to have 1 top-level directive `seed` call to dictate how seeding happens in e.g. `db/seeds.rb` and then let individual seed files load in no specified order within that.
183
225
 
184
226
  #### Using the `setup` phase
185
227
 
186
- When you call `Oaken.loader.seed` we'll also call `seed :setup` behind the scenes, though we'll only call this once. It's meant for common setup, like `defaults` and helpers.
228
+ When you call `Oaken.seed`/`Oaken.loader.seed` we'll also call `seed :setup` for you behind the scenes, though we'll only call this once. It's meant for common setup, like setting `defaults` and defining helpers.
187
229
 
188
230
  > [!IMPORTANT]
189
- > We recommend you don't use `create`/`upsert` directly in setup. Add the `defaults` and/or helpers that would be useful in the later seed files.
231
+ > Don't use `create`/`upsert` directly in setup. Add the `defaults` and/or helpers that would be useful in the later seed files.
190
232
 
191
233
  Here's some files you could add:
192
234
 
193
- - db/seeds/setup.rb — particularly useful as a starting point.
194
- - db/seeds/setup/defaults.rb — loader and type-specific defaults.
195
- - db/seeds/setup/defaults/*.rb — you could split out more specific files.
196
- - db/seeds/setup/users.rb — a type specific file for its defaults/helpers, doesn't have to just be users.
235
+ - `db/seeds/setup.rb` — particularly useful as a starting point.
236
+ - `db/seeds/setup/defaults.rb` — loader and type-specific defaults.
237
+ - `db/seeds/setup/defaults/*.rb` — you could split out more specific files.
238
+ - `db/seeds/setup/users.rb` — a type specific file for its defaults/helpers, doesn't have to just be users.
197
239
 
198
- - db/seeds/development/setup.rb — some defaults/helpers we only want in development.
199
- - db/seeds/test/setup.rb — some defaults/helpers we only want in test.
240
+ - `db/seeds/development/setup.rb` — some defaults/helpers we only want in development.
241
+ - `db/seeds/test/setup.rb` — some defaults/helpers we only want in test.
200
242
 
201
243
  > [!TIP]
202
244
  > Remember, since we're using `seed` internally you can nest as deeply as you want to structure however works best. There's tons of flexibility in the `**/*` glob pattern `seed` uses.
@@ -287,8 +329,8 @@ Call `loader.seed` and it'll follow the rules mentioned above:
287
329
 
288
330
  ```ruby
289
331
  # db/seeds.rb
290
- Oaken.loader.seed :setup, :accounts, :data
291
- Oaken.seed :setup, :accounts, :data # Or just this for short.
332
+ Oaken.loader.seed :data, :accounts
333
+ Oaken.seed :data, :accounts # Or just this for short.
292
334
  ```
293
335
 
294
336
  Both `bin/rails db:seed` and `bin/rails db:seed:replant` work as usual.
@@ -298,7 +340,7 @@ Both `bin/rails db:seed` and `bin/rails db:seed:replant` work as usual.
298
340
  If you're in the `bin/rails console`, you can invoke the same `seed` method as in `db/seeds.rb`.
299
341
 
300
342
  ```ruby
301
- Oaken.seed :setup, "cases/pagination"
343
+ Oaken.seed "cases/pagination"
302
344
  ```
303
345
 
304
346
  This is useful if you're working on hammering out a single seed script.
@@ -329,15 +371,20 @@ We've got full support for Rails' test parallelization out of the box.
329
371
 
330
372
  Oaken's data scripts are composed of table name looking methods corresponding to Active Record classes, which you can enhance with `defaults` and helper methods, then eventually calling `create` or `upsert` on them.
331
373
 
374
+ #### Loading within the `context` module
375
+
376
+ Oaken loads every seed file within the context of its `context` module. You can see it with `Oaken.loader.context`, or `Oaken.context` for short.
377
+
332
378
  #### Automatic & manual registry
333
379
 
334
380
  > [!IMPORTANT]
335
- > Ok, this bit is probably the most complex in Oaken. You can see the implementation in `Oaken::Seeds#method_missing` and then `Oaken::Loader::Type`.
381
+ > Ok, this bit is probably the most complex part in Oaken. You can see the implementation in `Oaken::Seeds#method_missing` and then `Oaken::Loader::Type`.
336
382
 
337
383
  When you reference e.g. `accounts` we'll hit `Oaken::Seeds#method_missing` hook and:
338
384
 
339
385
  - locate a class using `loader.locate`, hitting `Oaken::Loader::Type.locate`.
340
386
  - If there's a match, call `loader.register Account, as: :accounts`.
387
+ - `loader.register` defines the `accounts` method on the `Oaken.loader.context` module, pointing to an instance of `Oaken::Stored::ActiveRecord`.
341
388
 
342
389
  We'll respect namespaces up to 3 levels deep, so we'll try to match:
343
390
 
@@ -380,6 +427,22 @@ Typically used for data tables, like so:
380
427
  plans.upsert :basic, unique_by: :title, title: "Basic", price_cents: 10_00
381
428
  ```
382
429
 
430
+ #### `new`/`build`
431
+
432
+ We've also got `new`/`build` in case you:
433
+
434
+ - have a record that needs slightly more complex setup so you can't do `create`/`upsert`.
435
+ - want a record that's not in the database during testing.
436
+
437
+ Will have defaults applied via `attributes_for` internally.
438
+
439
+ ```ruby
440
+ test "some test" do
441
+ user = users.new name: "Someone"
442
+ user = users.build name: "Someone"
443
+ end
444
+ ```
445
+
383
446
  #### Using `defaults`
384
447
 
385
448
  You can set `defaults` that're applied on `create`/`upsert`, like this:
@@ -401,10 +464,42 @@ users.create # `name` comes from `loader.defaults`.
401
464
  > [!TIP]
402
465
  > It's best to be explicit in your dataset to tie things together with actual names, to make your object graph more cohesive. However, sometimes attributes can be filled in with [Faker](https://github.com/faker-ruby/faker) if they're not part of the "story".
403
466
 
467
+ #### Using `proxy`
468
+
469
+ `proxy` lets you wrap and delegate scopes from the underlying record.
470
+
471
+ So if you have this Active Record:
472
+
473
+ ```ruby
474
+ class User < ApplicationRecord
475
+ enum :role, %w[admin mod plain].index_by(&:itself)
476
+
477
+ scope :cool, -> { where(cool: true) }
478
+ end
479
+ ```
480
+
481
+ You can then proxy the scopes and use them like this:
482
+
483
+ ```ruby
484
+ users.proxy :admin, :mod, :plain
485
+ users.proxy :cool
486
+
487
+ users.create # Has `role: "plain"`, assuming it's the default role.
488
+ users.admin.create # Has `role: "admin"`
489
+ users.mod.create # Has `role: "mod"`
490
+ users.cool.create # Has `cool: true`
491
+
492
+ # Chaining also works:
493
+ users.cool.admin.create # Has `cool: true, role: "admin"`
494
+ ```
495
+
404
496
  #### Defining helpers
405
497
 
406
498
  Oaken uses Ruby's [`singleton_methods`](https://rubyapi.org/3.4/o/object#method-i-singleton_methods) for helpers because it costs us 0 lines of code to write and maintain.
407
499
 
500
+ > [!NOTE]
501
+ > It's still early days for these kind of helpers, so I'm still finding out what's possible with them. I'd love to know how you're using them on the Discussions tab.
502
+
408
503
  In plain Ruby, they look like this:
409
504
 
410
505
  ```ruby
@@ -430,6 +525,8 @@ test "we definitely need this" do
430
525
  end
431
526
  ```
432
527
 
528
+ ##### Providing `unique_by` everywhere
529
+
433
530
  Here's how you can provide a default `unique_by:` on all `users`:
434
531
 
435
532
  ```ruby
@@ -439,8 +536,66 @@ def users.create(label = nil, unique_by: :email_address, **) = super
439
536
 
440
537
  You could use this to provide `FactoryBot`-like helpers. Maybe adding a `factory` method?
441
538
 
442
- > [!NOTE]
443
- > It's still early days for these kind of helpers, so I'm still finding out what's possible with them. I'd love to know how you're using them on the Discussions tab.
539
+ ##### Accessing other seeds via `context`
540
+
541
+ You can access other seeds from within a helper by going through `Oaken.loader.context`/`Oaken.context`. We've got a shorthand so you can just write `context`, like this:
542
+
543
+ ```ruby
544
+ # Set up a helper to ensure when we create an account we also have a default admin user.
545
+ def accounts.bootstrap(name:, **)
546
+ create(name:, **).tap do
547
+ context.users.admin.create account: _1, name: "Primary Admin"
548
+ end
549
+ end
550
+ ```
551
+
552
+ #### Using `with` to group setup
553
+
554
+ `with` allows you to group similar `create`/`upsert` calls & apply scoped defaults.
555
+
556
+ ##### `with` during setup
557
+
558
+ During seeding setup, use `with` in the block form to group `create`/`upsert` calls, typically by an association you want to highlight.
559
+
560
+ In this example, we're grouping menu items by their menu. We could write out each menu item `create` one by one and pass the menus explicitly just fine.
561
+
562
+ However, grouping by the menu gets us an extra level of indentation to help reveal our intent.
563
+
564
+ ```ruby
565
+ menu_items.with menu: menus.basic do
566
+ it.create :plain_donut, name: "Plain Donut"
567
+ it.create name: "Another Basic Donut"
568
+ # More `create` calls, which automatically go on the basic menu.
569
+ end
570
+
571
+ menu_items.with menu: menus.premium do
572
+ it.create :premium_donut, name: "Premium Donut"
573
+ # Other premium menu items.
574
+ end
575
+ ```
576
+
577
+ ##### `with` in tests
578
+
579
+ In tests `with` is also useful in the non-block form to apply more explicit scoped defaults used throughout the tests:
580
+
581
+ ```ruby
582
+ setup do
583
+ @menu_items = menu_items.with menu: accounts.kaspers_donuts.menus.first, description: "Indulgent & delicious."
584
+ end
585
+
586
+ test "something" do
587
+ @menu_items.create # The menu item is created with the defaults above.
588
+ @menu_items.create menu: menus.premium # You can still override defaults like usual.
589
+ end
590
+ ```
591
+
592
+ ##### How `with` scoping works
593
+
594
+ To make this easier to understand, we'll use a general `menu_items` object and then a scoped `basic_items = menu_items.with menu: menus.basic` object.
595
+
596
+ - Labels: go to the general object, `basic_items.create :plain_donut` will be reachable via `menu_items.plain_donut`.
597
+ - Defaults: only stay on the `with` object, so `menu_items.create` won't set `menu: menus.basic`, but `basic_items.create` will.
598
+ - Helper methods: any helper methods defined on `menu_items` can be called on `basic_items`. We recommend only defining helper methods on the general `menu_items` object.
444
599
 
445
600
  ## Migration
446
601
 
@@ -490,7 +645,7 @@ Set Oaken up for your tests like the setup section mentions, and then only add a
490
645
  ```ruby
491
646
  # db/seeds.rb
492
647
  if Rails.env.test?
493
- Oaken.loader.seed :setup, :accounts
648
+ Oaken.seed :accounts
494
649
  return
495
650
  end
496
651
  ```
@@ -533,6 +688,10 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
533
688
 
534
689
  Bug reports and pull requests are welcome on GitHub at https://github.com/kaspth/oaken. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/kaspth/oaken/blob/main/CODE_OF_CONDUCT.md).
535
690
 
691
+ ## Bug Report Template
692
+
693
+ When reporting bugs, please use our bug report template at [examples/bug_report_template.rb](examples/bug_report_template.rb)
694
+
536
695
  ## License
537
696
 
538
697
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,47 @@
1
+ require "bundler/inline"; gemfile do
2
+ source "https://rubygems.org"
3
+
4
+ gem "debug"
5
+
6
+ gem "activerecord", require: "active_record"
7
+ gem "sqlite3"
8
+
9
+ gem "oaken" #, "0.9.1" # TODO: Lock to the version you're using.
10
+ end
11
+
12
+ require "active_support/testing/autorun"
13
+
14
+ ActiveRecord::Base.logger = Logger.new(STDOUT)
15
+
16
+ class ApplicationRecord < ActiveRecord::Base
17
+ primary_abstract_class
18
+
19
+ establish_connection adapter: "sqlite3", database: ":memory:"
20
+ singleton_class.delegate :create_table, to: :lease_connection
21
+ end
22
+
23
+ class User < ApplicationRecord
24
+ create_table :users do |t|
25
+ t.string :name, null: false
26
+ end
27
+ end
28
+
29
+ # TODO: Put extra models/tables here as needed. Feel free to update User too.
30
+
31
+ ApplicationRecord.subclasses.each { Oaken.register _1 }
32
+
33
+ class BugTest < ActiveSupport::TestCase
34
+ include Oaken.context
35
+
36
+ setup do
37
+ @user = users.create name: "Someone"
38
+ end
39
+
40
+ test "it fails if I try this" do
41
+ # debugger # Uncomment or place elsewhere to use debug.rb's debugger
42
+ assert_equal "Someone", @user.name
43
+ end
44
+
45
+ test "yet somehow it passes if I do this" do
46
+ end
47
+ end
@@ -0,0 +1,141 @@
1
+ require "bundler/inline"; gemfile do
2
+ source "https://rubygems.org"
3
+
4
+ gem "debug"
5
+
6
+ gem "activerecord", require: "active_record"
7
+ gem "sqlite3"
8
+ gem "bcrypt"
9
+
10
+ gem "oaken", path: ".."
11
+ end
12
+
13
+ require "active_support/testing/autorun"
14
+
15
+ ActiveRecord::Base.logger = Logger.new(STDOUT)
16
+
17
+ class ApplicationRecord < ActiveRecord::Base
18
+ primary_abstract_class
19
+
20
+ establish_connection adapter: "sqlite3", database: ":memory:"
21
+ singleton_class.delegate :create_table, to: :lease_connection
22
+ end
23
+
24
+ class Account < ApplicationRecord
25
+ create_table :accounts do |t|
26
+ t.string :name, null: false
27
+ t.timestamps
28
+ end
29
+ end
30
+
31
+ class User < ApplicationRecord
32
+ create_table :users do |t|
33
+ t.string :name, null: false
34
+ t.string :email_address, null: false
35
+ t.string :password_digest, null: false
36
+ t.string :role, default: "plain", null: false
37
+ t.timestamps
38
+
39
+ t.index :email_address, unique: true
40
+ end
41
+
42
+ has_secure_password
43
+
44
+ enum :role, %w[admin mod plain].index_by(&:itself)
45
+
46
+ has_many :administratorships
47
+ has_many :accounts, through: :administratorships
48
+ end
49
+
50
+ class Administratorship < ApplicationRecord
51
+ create_table :administratorships do |t|
52
+ t.references :account, null: false, index: true
53
+ t.references :user, null: false, index: true
54
+ t.timestamps
55
+ end
56
+
57
+ belongs_to :account
58
+ belongs_to :user
59
+ end
60
+
61
+ class Menu < ApplicationRecord
62
+ create_table :menus do |t|
63
+ t.references :account, null: false, index: true
64
+ t.timestamps
65
+ end
66
+
67
+ belongs_to :account
68
+ end
69
+
70
+ class Menu::Item < ApplicationRecord
71
+ create_table :menu_items do |t|
72
+ t.references :menu, null: false, index: true
73
+ t.string :name, null: false
74
+ t.integer :price_cents, null: false
75
+ t.timestamps
76
+ end
77
+
78
+ belongs_to :menu
79
+ end
80
+
81
+ # We have to override this for the single file script here, but you don't need this in apps.
82
+ class Oaken::Loader
83
+ def definition_location = caller_locations(3, 1)&.first
84
+ end
85
+
86
+ # Simulate db/seeds.rb
87
+ # Oaken.seed :accounts # Will load `db/seeds/{,<Rails.env>/}accounts**/*.rb`
88
+
89
+ # Simulate db/seeds/setup.rb
90
+ # In Rails apps `Oaken.context.class_eval` is automatic.
91
+ Oaken.context.class_eval do
92
+ # Set general defaults across models. Only the models with these columns will use this default, e.g. `menu_items#price_cents`.
93
+ loader.defaults price_cents: 10_00
94
+
95
+ # Use the decorative `section` method to carve up files.
96
+ section users do
97
+ # password/password_confirmation are virtual columns generated via `has_secure_password`
98
+ users.defaults password: "password123456", password_confirmation: "password123456"
99
+
100
+ # Expose the `enum :role` scopes, so `users.admin.create` will set `role: "admin"`.
101
+ users.proxy :admin, :mod, :plain
102
+
103
+ # This defined helper method uses Ruby's built-in `singleton_methods`. Not to be conflated with `include Singleton`.
104
+ # So we're overriding the built-in create method to mark users as unique by their `email_address`.
105
+ def users.create(label = nil, unique_by: :email_address, **) = super
106
+ end
107
+ end
108
+
109
+ # Simulate an db/seeds/accounts/kaspers_donuts.rb file.
110
+ # In Rails apps `Oaken.context.class_eval` is automatic.
111
+ Oaken.context.class_eval do
112
+ account = accounts.create :kaspers_donuts, name: "Kasper's Donuts"
113
+
114
+ users.with accounts: [account] do
115
+ kasper = it.admin.create :kasper, name: "Kasper", email_address: "kasper@example.com"
116
+ coworker = it.mod.create :coworker, name: "Coworker", email_address: "coworker@example.com"
117
+ end
118
+
119
+ menu = menus.create(:basic, account:)
120
+ plain_donut = menu_items.create menu:, name: "Plain" # Gets `price_cents: 10_00` from `loader.defaults`
121
+ sprinkled_donut = menu_items.create menu:, name: "Sprinkled", price_cents: 20_00
122
+ end
123
+
124
+ class OakenTest < ActiveSupport::TestCase
125
+ include Oaken.context # In real Rails apps you should include `Oaken.test_setup` instead.
126
+
127
+ setup do
128
+ @user = users.kasper
129
+ @menu_items = menu_items.with menu: menus.basic, name: "High Price", price_cents: 20_00
130
+ end
131
+
132
+ test "access seeded records" do
133
+ assert_equal "Kasper", @user.name
134
+
135
+ @menu_items.create.tap do |item|
136
+ assert_equal "High Price", item.name
137
+ assert_equal 20_00, item.price_cents
138
+ assert_equal menus.basic, item.menu
139
+ end
140
+ end
141
+ end
data/lib/oaken/loader.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/core_ext/hash/indifferent_access"
4
+
3
5
  class Oaken::Loader
4
6
  autoload :Type, "oaken/loader/type"
5
7
 
@@ -81,16 +83,17 @@ class Oaken::Loader
81
83
  Pathname.glob lookup_paths.map { File.join _1, "#{identifier}{,/**/*}.rb" }
82
84
  end
83
85
 
84
- def definition_location
85
- # The first line referencing LABEL happens to be the line in the seed file.
86
- caller_locations(3, 6).find { _1.base_label == LABEL }
86
+ def definition_location(start_frame:)
87
+ # Locate first frame not in `setup` phase. Rely on caller adjusting frame start to skip over their stack levels.
88
+ # Won't skip over helpers defined in non-setup seed files, though that's probably ok.
89
+ caller_locations(1 + start_frame, 4).find { !setup_files.member? _1.path }
87
90
  end
88
91
 
89
92
  private
93
+ def setup_files = @setup_files ||= setup.map(&:to_s).to_set
90
94
  def setup = @setup ||= glob(:setup).each { load_one _1 }
91
95
 
92
96
  def load_one(path)
93
97
  context.class_eval path.read, path.to_s
94
98
  end
95
- LABEL = instance_method(:load_one).name.to_s
96
99
  end
@@ -1,11 +1,19 @@
1
1
  class Oaken::Stored::ActiveRecord
2
+ attr_reader :loader, :type
3
+ delegate :context, to: :loader
4
+
5
+ delegate :transaction, to: :type # For multi-db setups to help open a transaction on secondary connections.
6
+ delegate :find, :insert_all, :pluck, to: :type
7
+
8
+ attr_reader :labels
9
+
2
10
  def initialize(loader, type)
3
11
  @loader, @type = loader, type
4
12
  @attributes = loader.defaults_for(*type.column_names)
13
+
14
+ @labels = {}
15
+ @label_target = singleton_class # Capture target so labels in `with` calls go to our original instance.
5
16
  end
6
- attr_reader :type
7
- delegate :transaction, to: :type # For multi-db setups to help open a transaction on secondary connections.
8
- delegate :find, :insert_all, :pluck, to: :type
9
17
 
10
18
  # Create a record in the database with the passed `attributes`.
11
19
  def create(label = nil, unique_by: nil, **attributes)
@@ -29,6 +37,12 @@ class Oaken::Stored::ActiveRecord
29
37
  record
30
38
  end
31
39
 
40
+ # Build a new record with the passed `attributes`.
41
+ def new(**attributes)
42
+ type.new(**attributes_for(**attributes))
43
+ end
44
+ alias_method :build, :new
45
+
32
46
  # Build attributes used for `create`/`upsert`, applying loader and per-type `defaults`.
33
47
  #
34
48
  # loader.defaults name: -> { "Global" }, email_address: -> { … }
@@ -39,6 +53,54 @@ class Oaken::Stored::ActiveRecord
39
53
  @attributes.merge(attributes).transform_values! { _1.respond_to?(:call) ? _1.call : _1 }
40
54
  end
41
55
 
56
+ # `with` allows you to group similar `create`/`upsert` calls & apply scoped defaults.
57
+ #
58
+ # ### `with` during setup
59
+ #
60
+ # During seeding setup, use `with` in the block form to group `create`/`upsert` calls, typically by an association you want to highlight.
61
+ #
62
+ # In this example, we're grouping menu items by their menu. We could write out each menu item `create` one by one and pass the menus explicitly just fine.
63
+ #
64
+ # However, grouping by the menu gets us an extra level of indentation to help reveal our intent.
65
+ #
66
+ # menu_items.with menu: menus.basic do
67
+ # it.create :plain_donut, name: "Plain Donut"
68
+ # it.create name: "Another Basic Donut"
69
+ # # More `create` calls, which automatically go on the basic menu.
70
+ # end
71
+ #
72
+ # menu_items.with menu: menus.premium do
73
+ # it.create :premium_donut, name: "Premium Donut"
74
+ # # Other premium menu items.
75
+ # end
76
+ #
77
+ # ### `with` in tests
78
+ #
79
+ # In tests `with` is also useful in the non-block form to apply more explicit scoped defaults used throughout the tests:
80
+ #
81
+ # setup do
82
+ # @menu_items = menu_items.with menu: accounts.kaspers_donuts.menus.first, description: "Indulgent & delicious."
83
+ # end
84
+ #
85
+ # test "something" do
86
+ # @menu_items.create # The menu item is created with the defaults above.
87
+ # @menu_items.create menu: menus.premium # You can still override defaults like usual.
88
+ # end
89
+ #
90
+ # ### How `with` scoping works
91
+ #
92
+ # To make this easier to understand we'll use a general `menu_items` object and then a scoped `basic_items = menu_items.with menu: menus.basic` object.
93
+ #
94
+ # - Labels: go to the general object, `basic_items.create :plain_donut` will be reachable via `menu_items.plain_donut`.
95
+ # - Defaults: only stay on the `with` object, so `menu_items.create` won't set `menu: menus.basic`, but `basic_items.create` will.
96
+ # - Helper methods: any helper methods defined on `menu_items` can be called on `basic_items`. We recommend only defining helper methods on the general `menu_items` object.
97
+ def with(**defaults)
98
+ clone.tap do
99
+ _1.defaults(**defaults) unless defaults.empty?
100
+ yield _1 if block_given?
101
+ end
102
+ end
103
+
42
104
  # Set defaults for all types:
43
105
  #
44
106
  # loader.defaults name: -> { "Global" }, email_address: -> { … }
@@ -49,6 +111,36 @@ class Oaken::Stored::ActiveRecord
49
111
  # users.create # => Uses the users' default `name` and the loader `email_address`
50
112
  def defaults(**attributes) = @attributes = @attributes.merge(attributes)
51
113
 
114
+ # `proxy` lets you wrap and delegate scopes from the underlying record.
115
+ #
116
+ # So if you have this Active Record:
117
+ #
118
+ # class User < ApplicationRecord
119
+ # enum :role, %w[admin mod plain].index_by(&:itself)
120
+ # scope :cool, -> { where(cool: true) }
121
+ # end
122
+ #
123
+ # You can then proxy the scopes and use them like this:
124
+ #
125
+ # users.proxy :admin, :mod, :plain
126
+ # users.proxy :cool
127
+ #
128
+ # users.create # Has `role: "plain"`, assuming it's the default role.
129
+ # users.admin.create # Has `role: "admin"`
130
+ # users.mod.create # Has `role: "mod"`
131
+ # users.cool.create # Has `cool: true`
132
+ #
133
+ # # Chaining also works:
134
+ # users.cool.admin.create # Has `cool: true, role: "admin"`
135
+ def proxy(*names) = names.each do |name|
136
+ define_singleton_method(name) { clone.rebind(type.public_send(name)) }
137
+ end
138
+
139
+ protected def rebind(type)
140
+ @type = type
141
+ self
142
+ end
143
+
52
144
  # Expose a record instance that's setup outside of using `create`/`upsert`. Like this:
53
145
  #
54
146
  # users.label someone: User.create!(name: "Someone")
@@ -62,12 +154,17 @@ class Oaken::Stored::ActiveRecord
62
154
  # users.label someone:, someone_else:
63
155
  #
64
156
  # Note: `users.method(:someone).source_location` also points back to the file and line of the `label` call.
65
- def label(**labels) = labels.each { |label, record| _label label, record.id }
157
+ def label(**labels) = labels.each { |label, record| _label label, record.id, location_frame_skip: 2 }
158
+
159
+ private def _label(name, id, location_frame_skip: 0)
160
+ labels.fetch name do
161
+ loc = loader.definition_location(start_frame: 4 + location_frame_skip) or
162
+ raise "Oaken can't find a source_location for this label: #{name}. Please open an issue and share what you're trying and a backtrace if you can."
66
163
 
67
- private def _label(name, id)
68
- location = @loader.definition_location or
69
- raise ArgumentError, "you can only define labelled records outside of tests"
164
+ label_target.class_eval "def #{name} = find(labels.fetch(__method__))", loc.path, loc.lineno
165
+ end
70
166
 
71
- class_eval "def #{name} = find(#{id.inspect})", location.path, location.lineno
167
+ labels[name] = id
72
168
  end
169
+ private attr_reader :label_target
73
170
  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.9.1"
4
+ VERSION = "1.0.0"
5
5
  end
data/lib/oaken.rb CHANGED
@@ -3,6 +3,8 @@
3
3
  require "oaken/version"
4
4
  require "pathname"
5
5
 
6
+ require "active_support/core_ext/module/delegation"
7
+
6
8
  module Oaken
7
9
  class Error < StandardError; end
8
10
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: oaken
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.1
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kasper Timm Hansen
@@ -20,6 +20,8 @@ files:
20
20
  - LICENSE.txt
21
21
  - README.md
22
22
  - Rakefile
23
+ - examples/bug_report_template.rb
24
+ - examples/donuts.rb
23
25
  - lib/generators/oaken/convert/fixtures_generator.rb
24
26
  - lib/oaken.rb
25
27
  - lib/oaken/loader.rb
@@ -45,7 +47,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
45
47
  requirements:
46
48
  - - ">="
47
49
  - !ruby/object:Gem::Version
48
- version: 3.0.0
50
+ version: '3.2'
49
51
  required_rubygems_version: !ruby/object:Gem::Requirement
50
52
  requirements:
51
53
  - - ">="