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 +4 -4
- data/README.md +175 -16
- data/examples/bug_report_template.rb +47 -0
- data/examples/donuts.rb +141 -0
- data/lib/oaken/loader.rb +7 -4
- data/lib/oaken/stored/active_record.rb +105 -8
- data/lib/oaken/version.rb +1 -1
- data/lib/oaken.rb +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: db5000f2e4a4967a798e13a086a117bc52c4f0d17c8db2fc62050bf268754b8a
|
|
4
|
+
data.tar.gz: e81fc5bf8328905ebf5bdc73a37ca25b8e76c1902aacb40eaefa8df5141fc576
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 :
|
|
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
|
-
>
|
|
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 :
|
|
291
|
-
Oaken.seed :
|
|
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
|
|
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
|
-
|
|
443
|
-
|
|
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.
|
|
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
|
data/examples/donuts.rb
ADDED
|
@@ -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
|
-
#
|
|
86
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
167
|
+
labels[name] = id
|
|
72
168
|
end
|
|
169
|
+
private attr_reader :label_target
|
|
73
170
|
end
|
data/lib/oaken/version.rb
CHANGED
data/lib/oaken.rb
CHANGED
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.
|
|
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.
|
|
50
|
+
version: '3.2'
|
|
49
51
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
50
52
|
requirements:
|
|
51
53
|
- - ">="
|