oaken 0.7.1 → 0.9.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: 5de0c10ab76ddbee3960eda8a9cbf2c8a5b9e1065ded625761c3333a5e3682da
4
- data.tar.gz: 42f27cb3adcc4c4d73d8267dcf7f1e30e0923b2773626001e4689fa166cd132d
3
+ metadata.gz: 1eea7421c7a71e2be111c2a82448345d9913830db2405c9903f1e57bf4371e79
4
+ data.tar.gz: 185532dbf42d282773eed6fef3a1ec139f81d9fdc37490c6a3b69b80e88bd862
5
5
  SHA512:
6
- metadata.gz: f933b1ae38aa3b27cd9682282e64fa7e13f1818fa9133c90023d2d0addc5ed3f8f38fc9cac51e55b6b572db7b928fd1890ad4f25881f0c5c7391888dc42e12cc
7
- data.tar.gz: 5cddcfc3d65ed8299bb15be1c716e0165bac2f35c4a885f7a80ae0eab1b83085b3883f8edd62df55cba182957a667186b5f6778b3a47d2debc15e4a465666956
6
+ metadata.gz: abe8c9e3be55d73978fc6cebec2668c2c2b372999bf65cb76f1e8232f742375ae0c6268ce76d9ba31ef3761f650a67d928b9338c4d0bb3d676f338b837b8d1e9
7
+ data.tar.gz: 5a91439c28f1abd37ae78c9eec667f56b84162dbdd1078370c10df4eab911c36fc0d448e7390d2242d420eb259d0a3bd0a1a012e7125efb2cec01b9fb303ba58
data/README.md CHANGED
@@ -1,103 +1,312 @@
1
1
  # Oaken
2
2
 
3
- Oaken is a new take on development and test data management for your Rails app. It blends the stability and storytelling from Fixtures with the dynamicness of FactoryBot/Fabricator.
3
+ Oaken is fixtures + factories + seeds for your Rails development & test environments.
4
+
5
+ ## Oaken is like fixtures, without the nightmare UX
6
+
7
+ 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.
8
+
9
+ In Oaken, you start by creating a root-level model, typically an Account, Team, or Organization etc. to group everything on, in a scenario. From your root-level model, you then start building your object graph by mirroring how your app works.
10
+
11
+ So what comes next in your account flow? Maybe it's creating Users on the account.
12
+
13
+ You can go further if you need to. Is the Account about selling something, like donuts? Maybe you add a menu and some items.
14
+
15
+ It'll look like this:
16
+
17
+ ```ruby
18
+ account = accounts.create :kaspers_donuts, name: "Kasper's Donuts"
19
+
20
+ kasper = users.create :kasper, name: "Kasper", email_address: "kasper@example.com", accounts: [account]
21
+ coworker = users.create :coworker, name: "Coworker", email_address: "coworker@example.com", accounts: [account]
22
+
23
+ menu = menus.create(account:)
24
+ plain_donut = menu_items.create menu:, name: "Plain", price_cents: 10_00
25
+ sprinkled_donut = menu_items.create menu:, name: "Sprinkled", price_cents: 10_10
26
+ ```
27
+
28
+ > [!NOTE]
29
+ > `create` takes an optional symbol label. This makes the record accessible in tests, e.g. `users.create :kasper` lets tests do `setup { @kasper = users.kasper }`.
30
+
31
+ With fixtures, this would be 4 different files in `test/fixtures` for `accounts`, `users`, `menus`, and `menu_items`. It would be ~20 lines of YAML versus ~6 lines of Ruby for this data.
32
+
33
+ Another issue in fixture files, is that objects from different scenarios are all mixed together making it hard to get a picture of what's going on — even in small apps.
34
+
35
+ Fixtures also require you to label every record and make them unique throughout your dataset — you have to be careful not to create clashes. This gets difficult to manage quickly and requires diligence on a team that's trying to ship.
36
+
37
+ However, often the fact that a record is associated onto another is enough. So in Oaken, we let you skip naming every record. Notice how the `menus.create` & `menu_items.create` calls don't pass symbol labels. You can still get at them in tests though if you really need to with `accounts.kaspers_donuts.menus.first.menu_items.first`.
38
+
39
+ <details>
40
+ <summary>See the fixtures version</summary>
41
+
42
+ ```yaml
43
+ # test/fixtures/accounts.yml
44
+ kaspers_donuts:
45
+ name: Kasper's Donuts
46
+
47
+ # test/fixtures/users.yml
48
+ kasper:
49
+ name: "Kasper"
50
+ email_address: "kasper@example.com"
51
+ accounts: kaspers_donuts
52
+
53
+ coworker:
54
+ name: "Coworker"
55
+ email_address: "coworker@example.com"
56
+ accounts: kaspers_donuts
57
+
58
+ # test/fixtures/menus.yml
59
+ basic:
60
+ account: kaspers_donuts
61
+
62
+ # test/fixtures/menu/items.yml
63
+ plain_donut:
64
+ menu: basic
65
+ name: Plain
66
+ price_cents: 10_00
67
+
68
+ sprinkled_donut:
69
+ menu: basic
70
+ name: Sprinkled
71
+ price_cents: 10_10
72
+ ```
73
+
74
+ </details>
75
+
76
+ ### Oaken is like fixtures, we seed data before tests run
77
+
78
+ The reason you go through all the trouble of massaging your fixture files is to have a stable named dataset across test runs that's relatively quick to load — so the database call cost is amortized across tests.
79
+
80
+ Oaken mirrors this approach giving you stability in your dataset and the relative quickness to insert the data.
81
+
82
+ For instance, if you have 10 tests that each need the same 2 records, Oaken puts them in the database once before tests run, same as fixtures.
83
+
84
+ The tradeoff is that if you run just 1 test we'll still seed those 2 same records, but we'll also seed any other record you've added to the shared dataset that might not be needed in those tests.
85
+
86
+ We rely on Rails' tests being wrapped in transactions so any changes are rolled back after the test case run.
87
+
88
+ > [!NOTE]
89
+ > It can be a good idea to structure your object graph so you won't need database records for your tests — reality can sometimes be far from that ideal state though. Oaken aims to make your present reality easier and something you can improve.
90
+
91
+ ## Oaken is unlike factories, focusing on shared datasets
92
+
93
+ Factories can let you start an app easier. It's just this one factory for now, ok, easy enough.
94
+
95
+ Over time, however, many teams find their factory based test suite slows to a crawl. Suddenly one factory ends up pulling in the rest of the app.
96
+
97
+ Factories end up requiring a lot of diligence and passing just the right things in just-so to make managable.
98
+
99
+ Oaken does away with this. See the sections on the fixtures comparisons above for how.
100
+
101
+ > [!WARNING]
102
+ > Full Disclaimer: while I have worked on systems using factories, I overall don't get it and the fixtures approach makes more sense to me despite the UX issues. I'm trying to support a partial factories approach here in Oaken, see the below section for that, and I'm open to ideas here.
103
+
104
+ > [!TIP]
105
+ > Oaken is compatible with FactoryBot and Fabrication, and they should be able to work together. I consider it a bug if there's compatibility issues, so please open an issue here if you find something.
106
+
107
+ ### Oaken is like factories, with dynamic defaults & helper methods
108
+
109
+ See the sections on defaults & helpers below.
110
+
111
+ The aim for Oaken is to have most of the feature set of factories for a fraction of the implementation complexity.
112
+
113
+ ## Oaken gives db/seeds.rb superpowers
114
+
115
+ Oaken upgrades seeds in `db/seeds.rb`, so you can put together scenarios & reuse the development data in tests.
116
+
117
+ This way, the data you see in your browser, is the same data you work with in tests to make your object graph easier to get — especially for people new to your codebase.
118
+
119
+ So you get a cohesive & stable dataset with a story like fixtures & their fast loading. But you also get the dynamics of FactoryBot/Fabrication as well without making tons of one-off records to handle each case.
120
+
121
+ The end result is you end up writing less data back & forth to the database because you aren’t cobbling stuff together.
4
122
 
5
123
  > But seriously; Oaken is one of the single greatest tools I've added to my belt in the past year
6
- >
124
+ >
7
125
  > It's made cross-environment shared data, data prepping for demos, edge-case tests, and overall development much more reliable & shareable across a team
8
126
  > [@tcannonfodder](https://github.com/tcannonfodder)
9
127
 
10
- Fixtures are stable & help you build a story of how your app and its object graph exists along with edge cases, but the UX is unfortunately a nightmare.
11
- To trace N associations, you have to open and read N different files — there's no way to group by scenario.
128
+ ## Design goals
129
+
130
+ ### Consistent data & constrained Ruby
12
131
 
13
- FactoryBot is spray & pray. You basically say “screw it, just give me the bare minimum I need to run this test”, which slows everything down because there’s no cohesion; and the Factories are always suspect in terms of completeness. Sure, I got the test to pass by wiring these 5 Factories together but did I miss something?
132
+ We're using `accounts.create` and such instead of `Account.create!` to help enforce consistency & constrain your Ruby usage. This also allows for extra features like `defaults` and helpers that take way less to implement.
14
133
 
15
- Oaken instead upgrades seeds in `db/seeds.rb`, so that you can put together scenarios & also reuse the development data in tests. That way the data you see in your development browser, is the same data you work with in tests to tie it more together — especially for people who are new to your codebase.
134
+ ### Pick up in 1 hour or less
16
135
 
17
- So you get the stability of named keys, a cohesive dataset, and a story like Fixtures. But the dynamics of FactoryBot as well. And unlike FactoryBot, you’re not making tons of one-off records to handle each case.
136
+ We don't want to be a costly DSL that takes ages to learn and relearn when you come back to it.
18
137
 
19
- While Fixtures and FactoryBot both load data & truncate in tests, the end result is you end up writing less data back & forth to the database because you aren’t cobbling stuff together.
138
+ We're aiming for a time-to-understand of less than an hour. Same goes for the internals, if you dive in, it should ideally take less than 1 hour to comprehend most of it.
139
+
140
+ ### Similar ideas to Pkl
141
+
142
+ We share similar [sentiments to the Pkl configuration language](https://pkl-lang.org/main/current/introduction/comparison.html). You may find the ideas helpful before using Oaken.
143
+
144
+ Oddly enough Oaken came out before Pkl, I just read the ideas here and went "yes, exactly!"
20
145
 
21
146
  ## Setup
22
147
 
23
- ### Starting in development
148
+ ### Loading directories/files
149
+
150
+ By default, `Oaken.loader` returns an `Oaken::Loader` instance to handle loading seed files.
151
+
152
+ You can load a seed directory via `Oaken.loader.seed`. You can also load a file, it'll technically just be a match that happens to only hit one file.
153
+
154
+ So if you call `Oaken.loader.seed :accounts`, we'll look within `db/seeds/` and `db/seeds/#{Rails.env}/` and match `accounts{,**/*}.rb`. So these files would be found:
155
+
156
+ - accounts.rb
157
+ - accounts/kaspers_donuts.rb
158
+ - accounts/kaspers_donuts/deeply/nested/path.rb
159
+ - accounts/demo.rb
160
+ - and so on.
161
+
162
+ > [!TIP]
163
+ > You can call `Oaken.loader.glob` with a single identifier to see what files we'll match. > Some samples: `Oaken.loader.glob :accounts`, `Oaken.loader.glob "cases/pagination"`.
164
+
165
+ > [!TIP]
166
+ > 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.
167
+
168
+ 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.
169
+
170
+ > [!IMPORTANT]
171
+ > 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.
24
172
 
25
- You can set it up in `db/seeds.rb`, like this:
173
+ #### Using the `setup` phase
174
+
175
+ 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.
176
+
177
+ > [!IMPORTANT]
178
+ > 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.
179
+
180
+ Here's some files you could add:
181
+
182
+ - db/seeds/setup.rb — particularly useful as a starting point.
183
+ - db/seeds/setup/defaults.rb — loader and type-specific defaults.
184
+ - db/seeds/setup/defaults/*.rb — you could split out more specific files.
185
+ - db/seeds/setup/users.rb — a type specific file for its defaults/helpers, doesn't have to just be users.
186
+
187
+ - db/seeds/development/setup.rb — some defaults/helpers we only want in development.
188
+ - db/seeds/test/setup.rb — some defaults/helpers we only want in test.
189
+
190
+ > [!TIP]
191
+ > 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.
192
+
193
+ #### Directory recommendations & file tips
194
+
195
+ Oaken has some directory recommendations to help strengthen your understanding of your object graph:
196
+
197
+ - `db/seeds/data` for any data tables, like the plans a SaaS app has.
198
+ - Group scenarios around your top-level root model, like `Account`, `Team`, or `Organization` and have a `db/seeds/accounts` directory.
199
+ - `db/seeds/cases` for any specific cases, like pagination.
200
+
201
+ If you follow all these conventions you could do this:
26
202
 
27
203
  ```ruby
28
- Oaken.prepare do
29
- seed :accounts, :data
30
- end
204
+ Oaken.loader.seed :data, :accounts, :cases
31
205
  ```
32
206
 
33
- This will look for deeply nested files to load in `db/seeds` and `db/seeds/#{Rails.env}` within the `accounts` and `data` directories.
207
+ And here's some potential file suggestions you could take advantage of:
34
208
 
35
- Here's what they could look like:
209
+ - db/seeds/data/plans.rb put your SaaS plans in here.
210
+ - db/seeds/test/data/plans.rb — some test specific plans, in case we need them.
36
211
 
37
- ```ruby
38
- # db/seeds/accounts/kaspers_donuts.rb
39
- donuts = accounts.create :kaspers_donuts, name: "Kasper's Donuts"
212
+ - db/seeds/cases/pagination.rb — group the seed code for generating pagination data here. NOTE: this could reference an account setup earlier.
213
+ - db/seeds/test/cases/*.rb — any test specific cases.
214
+
215
+ > [!TIP]
216
+ > We're letting Oaken's loading do all the hard work here, we're just staging the loading phases by specifying the top-level order.
217
+
218
+ ##### Loading specific cases in tests only
40
219
 
41
- kasper = users.create :kasper, name: "Kasper", accounts: [donuts]
42
- coworker = users.create :coworker, name: "Coworker", accounts: [donuts]
220
+ For the cases part, you may want to tweak it a bit more.
43
221
 
44
- menu = menus.create account: donuts
45
- plain_donut = menu_items.create menu: menu, name: "Plain", price_cents: 10_00
46
- sprinkled_donut = menu_items.create menu: menu, name: "Sprinkled", price_cents: 10_10
222
+ You could add any definitely shared cases in `db/seeds/cases`. Say you have a `db/seeds/cases/pagination.rb` case that can be shared between development and test.
47
223
 
48
- supporter = users.create name: "Super Supporter"
49
- orders.insert_all [user_id: supporter.id, item_id: plain_donut.id] * 10
224
+ If not, you can add environment specific ones in `db/seeds/development/cases/pagination.rb` and `db/seeds/test/cases/pagination.rb`.
50
225
 
51
- orders.insert_all \
52
- 10.times.map { { user_id: users.create(name: "Customer #{_1}").id, item_id: menu.items.sample.id } }
226
+ You could also avoid loading all the cases in the test environment like this:
227
+
228
+ ```ruby
229
+ Oaken.loader.seed :cases if Rails.env.development?
53
230
  ```
54
231
 
232
+ Now you can load specific seeds in tests, like this:
233
+
55
234
  ```ruby
56
- # db/seeds/data/plans.rb
57
- plans.upsert :basic, title: "Basic", price_cents: 10_00
235
+ class PaginationTest < ActionDispatch::IntegrationTest
236
+ setup { seed "cases/pagination" }
237
+ end
58
238
  ```
59
239
 
60
- Seed files will generally use `create` and/or `insert`. Passing a symbol to name the record is useful when reusing the data in tests.
240
+ And in RSpec:
241
+
242
+ ```ruby
243
+ RSpec.describe "Pagination", type: :feature do
244
+ before { seed "cases/pagination" }
245
+ end
246
+ ```
61
247
 
62
- Now you can run `bin/rails db:seed` and `bin/rails db:seed:replant`.
248
+ > [!NOTE]
249
+ > We're recommending having one-off seeds on an individual unit of work to help reinforce test isolation. Having some seed files be isolated also helps:
250
+ >
251
+ > - Reduce amount of junk data generated for unrelated tests
252
+ > - Make it easier to debug a particular test
253
+ > - Reduce test flakiness
254
+ > - Encourage writing seed files for specific edge-case scenarios
63
255
 
64
- ### Interlude: Directory Naming Conventions
256
+ #### Configuring loaders
65
257
 
66
- Oaken has some chosen directory conventions to help strengthen your understanding of your object graph:
258
+ You can customize the loading and loader as well:
67
259
 
68
- - Have a directory for your top-level model, like `Account`, `Team`, `Organization`, that's why we have `db/seeds/accounts` above.
69
- - `db/seeds/data` for any data tables, like the plans a SaaS app has.
70
- - `db/seeds/tests/cases` for any specific cases that are only used in some tests, like `pagination.rb`.
260
+ ```ruby
261
+ # config/initializers/oaken.rb
262
+ # Call `with` to build a new loader. Here we're just passing the default internal options:
263
+ loader = Oaken.loader.with(lookup_paths: "test/seeds") # Useful to pull from another directory, when migrating.
264
+ loader = Oaken.loader.with(locator: Oaken::Loader::Type, provider: Oaken::Stored::ActiveRecord, context: Oaken::Seeds)
71
265
 
72
- ### Using default attributes
266
+ Oaken.loader = loader # You can also replace Oaken's default loader.
267
+ ```
73
268
 
74
- You can set up default attributes that's applied to created/inserted records at different levels, like this:
269
+ > [!TIP]
270
+ > `Oaken` delegates `Oaken::Loader`'s public instance methods to `loader`,
271
+ > so `Oaken.seed` works and is really `Oaken.loader.seed`. Same goes for `Oaken.lookup_paths`, `Oaken.with`, `Oaken.glob` and more.
272
+
273
+ #### In db/seeds.rb
274
+
275
+ Call `loader.seed` and it'll follow the rules mentioned above:
75
276
 
76
277
  ```ruby
77
- Oaken.prepare do
78
- # Assign broad global defaults for every type.
79
- defaults name: -> { Faker::Name.name }, public_key: -> { SecureRandom.hex }
278
+ # db/seeds.rb
279
+ Oaken.loader.seed :setup, :accounts, :data
280
+ Oaken.seed :setup, :accounts, :data # Or just this for short.
281
+ ```
80
282
 
81
- # Assign a more specific default on one type, which overrides the global default above.
82
- accounts.defaults name: -> { Faker::Business.name }
83
- end
283
+ Both `bin/rails db:seed` and `bin/rails db:seed:replant` work as usual.
284
+
285
+ #### In the console
286
+
287
+ If you're in the `bin/rails console`, you can invoke the same `seed` method as in `db/seeds.rb`.
288
+
289
+ ```ruby
290
+ Oaken.seed :setup, "cases/pagination"
84
291
  ```
85
292
 
293
+ This is useful if you're working on hammering out a single seed script.
294
+
86
295
  > [!TIP]
87
- > `defaults` are particularly well suited for assigning generated data with [Faker](https://github.com/faker-ruby/faker).
296
+ > Oaken wraps each file load in an `ActiveRecord::Base.transaction` so any invalid data rolls back the whole file.
88
297
 
89
- ### Reusing data in tests
298
+ #### In tests & specs
90
299
 
91
- With the setup above, Oaken can reuse the same data in tests like this:
300
+ If you're using Rails' default minitest-based tests call this:
92
301
 
93
302
  ```ruby
94
303
  # test/test_helper.rb
95
304
  class ActiveSupport::TestCase
96
- include Oaken::TestSetup
305
+ include Oaken.loader.test_setup
97
306
  end
98
307
  ```
99
308
 
100
- Now tests have access to `accounts.kaspers_donuts` and `users.kasper` etc. that were setup in the data scripts.
309
+ We've got full support for Rails' test parallelization out of the box.
101
310
 
102
311
  > [!NOTE]
103
312
  > For RSpec, you can put this in `spec/rails_helper.rb`:
@@ -105,29 +314,193 @@ Now tests have access to `accounts.kaspers_donuts` and `users.kasper` etc. that
105
314
  > require "oaken/rspec_setup"
106
315
  > ```
107
316
 
108
- You can also load a specific seed, like this:
317
+ ### Writing Seed Data Scripts
318
+
319
+ 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.
320
+
321
+ #### Automatic & manual registry
322
+
323
+ > [!IMPORTANT]
324
+ > 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`.
325
+
326
+ When you reference e.g. `accounts` we'll hit `Oaken::Seeds#method_missing` hook and:
327
+
328
+ - locate a class using `loader.locate`, hitting `Oaken::Loader::Type.locate`.
329
+ - If there's a match, call `loader.register Account, as: :accounts`.
330
+
331
+ We'll respect namespaces up to 3 levels deep, so we'll try to match:
332
+
333
+ - `menu_items` to `Menu::Item` or `MenuItem`.
334
+ - `menu_item_details` to `Menu::Item::Detail`, `MenuItem::Detail`, `Menu::ItemDetail`, `MenuItemDetail`.
335
+ - The third level which is going to be 2 separators ("::" or "") to the power of 3 levels, in other words 8 possible constants.
336
+
337
+ You can skip this by calling `loader.register Menu::Item`, which we'll derive the method name via `name.tableize.tr("/", "_")` or you can call `register Menu::Item, as: :something_else` to have it however you want.
338
+
339
+ #### `create`
340
+
341
+ Internally, `create` calls `ActiveRecord::Base#create!` to fail early & prevent invalid records in your dataset. Runs create/save model callbacks.
109
342
 
110
343
  ```ruby
111
- class PaginationTest < ActionDispatch::IntegrationTest
112
- setup { seed "cases/pagination" }
344
+ users.create name: "Someone"
345
+ ```
346
+
347
+ Some records have uniqueness constraints, like a User's `email_address`, you can pass that via `unique_by`:
348
+
349
+ ```ruby
350
+ users.create unique_by: :email_address, name: "First", email_address: "someone@example.com"
351
+ users.create unique_by: :email_address, name: "Second", email_address: "someone@example.com"
352
+ ```
353
+
354
+ In the case of a uniqueness constraint clash, we'll `update!` the record, so here `name` is `"Second"`. Runs save/update model callbacks.
355
+
356
+ > [!IMPORTANT]
357
+ > We're trying to make `db:seed` rerunnable incrementally without needing to start from scratch. That's what the `update!` part is for. I'm still not entirely sure about it and I'm trying to figure out a better way to highlight what's going on to users.
358
+
359
+ #### `upsert`
360
+
361
+ Mirrors `ActiveRecord::Base#upsert`, allowing you to pass a `unique_by:` which must correspond to a unique database index. Does not run model callbacks.
362
+
363
+ We'll instantiate and `validate!` the record to help prevent bad data hitting the database.
364
+
365
+ Typically used for data tables, like so:
366
+
367
+ ```ruby
368
+ # db/seeds/data/plans.rb
369
+ plans.upsert :basic, unique_by: :title, title: "Basic", price_cents: 10_00
370
+ ```
371
+
372
+ #### Using `defaults`
373
+
374
+ You can set `defaults` that're applied on `create`/`upsert`, like this:
375
+
376
+ ```ruby
377
+ # Assign loader-level defaults that's applied to every type.
378
+ # Records only include defaults on attributes they have. So only records with a `public_key` attribute receive that and so on.
379
+ loader.defaults name: -> { Faker::Name.name }, public_key: -> { SecureRandom.hex }
380
+
381
+ # Assign specific defaults on one type, which overrides the loader `name` default from above.
382
+ accounts.defaults name: -> { Faker::Business.name }, status: :active
383
+
384
+ accounts.create # `name` comes from the `accounts.defaults` and `public_key` from `loader.defaults`.
385
+ accounts.upsert # Same.
386
+
387
+ users.create # `name` comes from `loader.defaults`.
388
+ ```
389
+
390
+ > [!TIP]
391
+ > 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".
392
+
393
+ #### Defining helpers
394
+
395
+ 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.
396
+
397
+ In plain Ruby, they look like this:
398
+
399
+ ```ruby
400
+ obj = Object.new
401
+ def obj.hello = :yo
402
+ obj.hello # => :yo
403
+ obj.singleton_methods # => [:hello]
404
+ ```
405
+
406
+ So you can do stuff like this on, say, a `users` instance:
407
+
408
+ ```ruby
409
+ # Notice how we're using the `labeled_email` helper to compose `create_labeled` too:
410
+ def users.create_labeled(label, email_address: labeled_email(label), **) = create(label, email_address:, **)
411
+ def users.labeled_email(label) = "#{label}@example.com" # You don't have to use endless methods, they're fun though.
412
+ ```
413
+
414
+ Now `create_labeled` & `labeled_email` are available everywhere the `users` instance is, in development and test!
415
+
416
+ ```ruby
417
+ test "we definitely need this" do
418
+ assert_equal "person@example.com", users.labeled_email(:person)
113
419
  end
114
420
  ```
115
421
 
422
+ Here's how you can provide a default `unique_by:` on all `users`:
423
+
424
+ ```ruby
425
+ # We override the built-in `create` to provide the default. Yes, `super` works on overriden methods!
426
+ def users.create(label = nil, unique_by: :email_address, **) = super
427
+ ```
428
+
429
+ You could use this to provide `FactoryBot`-like helpers. Maybe adding a `factory` method?
430
+
116
431
  > [!NOTE]
117
- > We're recommending having one-off seeds on an individual unit of work to help reinforce test isolation. Having some seed files be isolated also helps:
118
- >
119
- > - Reduce amount of junk data generated for unrelated tests
120
- > - Make it easier to debug a particular test
121
- > - Reduce test flakiness
122
- > - Encourage writing seed files for specific edge-case scenarios
432
+ > 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.
433
+
434
+ ## Migration
435
+
436
+ ### From fixtures
123
437
 
124
- ### Fixtures Converter
438
+ #### Converter
125
439
 
126
440
  You can convert your Rails fixtures to Oaken's seeds by running:
127
441
 
128
- $ bin/rails generate oaken:convert:fixtures
442
+ ```
443
+ bin/rails generate oaken:convert:fixtures
444
+ ```
445
+
446
+ This will convert anything in test/fixtures to db/seeds. E.g. `test/fixtures/users.yml` becomes `db/seeds/users.rb` and so on.
129
447
 
130
- This will convert anything in test/fixtures to db/seeds. E.g. `test/fixtures/users.yml` becomes `db/seeds/users.rb`.
448
+ #### Disable fixtures
449
+
450
+ IF you've fully converted to Oaken you may no longer want fixtures when running Rails' generators,
451
+ so you can disable generating them in `config/application.rb` like this:
452
+
453
+ ```ruby
454
+ module YourApp
455
+ class Application < Rails::Application
456
+ # We prefer Oaken to fixtures, so we disable them here.
457
+ config.app_generators { _1.test_framework _1.test_framework, fixture: false }
458
+ end
459
+ end
460
+ ```
461
+
462
+ The `test_framework` repeating is to preserve `:test_unit` or `:rspec` respectively.
463
+
464
+ > [!NOTE]
465
+ > If you're using `FactoryBot` as well, you don't need to do this since it already replaces fixtures for you.
466
+
467
+ ### From factories
468
+
469
+ If you've got a mostly working FactoryBot or Fabrication setup you may not want to muck with that too much.
470
+
471
+ However, you can grab some of the most shared records and shave off some significant runtime on your test suite.
472
+
473
+ <blockquote class="bluesky-embed" data-bluesky-uri="at://did:plc:ps3ygxhsn4khcrxeutdosdqk/app.bsky.feed.post/3lfb5zdb3p22z" data-bluesky-cid="bafyreiadxun7yqw4efafqzwzv3h4t4mbrex7onlnxobfejhbt6t44fojni" data-bluesky-embed-color-mode="system"><p lang="en">It&#x27;s @erikaxel.bsky.social&#x27;s team! They shaved 5.5 minutes off their test suite.
474
+
475
+ And that&#x27;s just the first batch integrating Oaken!<br><br><a href="https://bsky.app/profile/did:plc:ps3ygxhsn4khcrxeutdosdqk/post/3lfb5zdb3p22z?ref_src=embed">[image or embed]</a></p>&mdash; Kasper Timm Hansen (<a href="https://bsky.app/profile/did:plc:ps3ygxhsn4khcrxeutdosdqk?ref_src=embed">@kaspth.bsky.social</a>) <a href="https://bsky.app/profile/did:plc:ps3ygxhsn4khcrxeutdosdqk/post/3lfb5zdb3p22z?ref_src=embed">January 8, 2025 at 11:00 PM</a></blockquote>
476
+
477
+ Set Oaken up for your tests like the setup section mentions, and then only add a setup directory and scenarios around the root-level model like an Account. Like this:
478
+
479
+ ```ruby
480
+ # db/seeds.rb
481
+ if Rails.env.test?
482
+ Oaken.loader.seed :setup, :accounts
483
+ return
484
+ end
485
+ ```
486
+
487
+ Then define some very basic account setup like the very top of the README mentions.
488
+
489
+ Or maybe like this:
490
+
491
+ ```ruby
492
+ # db/seeds/test/accounts/basic.rb
493
+ accounts.create :basic, **FactoryBot.attributes_for(:account)
494
+
495
+ # Maybe some extra necessary records on the account here.
496
+ ```
497
+
498
+ Now tests can pass `account: accounts.basic` to other factories.
499
+
500
+ Do the very minimum and go slow. Pick records that you know are 100% safe to share.
501
+
502
+ > [!NOTE]
503
+ > I'd love to improve these migration notes. Please file an issue if something is confusing. I'd also love to hear your experience in general.
131
504
 
132
505
  ## Installation
133
506
 
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Oaken::Loader::Type < Struct.new(:name, :gsub)
4
+ def self.locate(name) = self.for(name.to_s).locate
5
+ def self.for(name) = new(name, name.classify.gsub(/(?<=[a-z])(?=[A-Z])/))
6
+
7
+ def locate
8
+ possible_consts.filter_map(&:safe_constantize).first
9
+ end
10
+
11
+ def possible_consts
12
+ separator_matrixes.fetch(gsub.count).map { |seps| gsub.with_index { seps[_2] } }
13
+ rescue KeyError
14
+ raise ArgumentError, "can't resolve #{name} to an object, please call register manually"
15
+ end
16
+
17
+ private
18
+ # TODO: Remove after dropping Ruby 3.1 support
19
+ if Enumerator.respond_to?(:product)
20
+ def self.product(...) = Enumerator.product(...)
21
+ else
22
+ def self.product(first = nil, *rest) = first&.product(*rest) || [[]]
23
+ end
24
+
25
+ separator_matrixes = (0..3).to_h { |size| [size, product(*[["::", ""]].*(size)).lazy] }
26
+ define_method(:separator_matrixes) { separator_matrixes }
27
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Oaken::Loader
4
+ class NoSeedsFoundError < ArgumentError; end
5
+
6
+ autoload :Type, "oaken/loader/type"
7
+
8
+ attr_reader :lookup_paths, :locator, :provider, :context
9
+ delegate :locate, to: :locator
10
+
11
+ def initialize(lookup_paths: nil, locator: Type, provider: Oaken::Stored::ActiveRecord, context: Oaken::Seeds)
12
+ @setup = nil
13
+ @lookup_paths, @locator, @provider, @context = Array(lookup_paths).dup, locator, provider, context
14
+ @defaults = {}.with_indifferent_access
15
+ end
16
+
17
+ # Instantiate a new loader with all its attributes and any specified `overrides`. See #new for defaults.
18
+ #
19
+ # Oaken.loader.with(root: "test/fixtures") # `root` returns "test/fixtures" here
20
+ def with(**overrides)
21
+ self.class.new(lookup_paths:, locator:, provider:, context:, **overrides)
22
+ end
23
+
24
+ # Allow assigning defaults across types.
25
+ def defaults(**defaults) = @defaults.merge!(**defaults)
26
+ def defaults_for(*keys) = @defaults.slice(*keys)
27
+
28
+ def test_setup
29
+ Oaken::TestSetup.new self
30
+ end
31
+
32
+ # Register a model class via `Oaken.loader.context`.
33
+ # Note: Oaken's auto-register means you don't need to call `register` often yourself.
34
+ #
35
+ # register Account
36
+ # register Account::Job
37
+ # register Account::Job::Task
38
+ #
39
+ # Oaken uses `name.tableize.tr("/", "_")` for the method names, so they're
40
+ # `accounts`, `account_jobs`, and `account_job_tasks`, respectively.
41
+ #
42
+ # You can also pass an explicit `as:` option:
43
+ #
44
+ # register User, as: :something_else
45
+ def register(type, as: nil)
46
+ stored = provider.new(self, type)
47
+ context.define_method(as || type.name.tableize.tr("/", "_")) { stored }
48
+ end
49
+
50
+ # Mirrors `bin/rails db:seed:replant`.
51
+ def replant_seed
52
+ ActiveRecord::Tasks::DatabaseTasks.truncate_all
53
+ load_seed
54
+ end
55
+
56
+ # Mirrors `bin/rails db:seed`.
57
+ def load_seed
58
+ Rails.application.load_seed
59
+ end
60
+
61
+ # Set up a general seed rule or perform a one-off seed for a test file.
62
+ #
63
+ # You can set up a general seed rule in `db/seeds.rb` like this:
64
+ #
65
+ # Oaken.seed :accounts # Seeds from `db/seeds/accounts{,/**/*}.rb` and `db/seeds/<Rails.env>/accounts{,/**/*}.rb`
66
+ #
67
+ # Then if you need a test specific scenario, we recommend putting them in `db/seeds/test/cases`.
68
+ #
69
+ # Say you have `db/seeds/test/cases/pagination.rb`, you can load it like this:
70
+ #
71
+ # # test/integration/pagination_test.rb
72
+ # class PaginationTest < ActionDispatch::IntegrationTest
73
+ # setup { seed "cases/pagination" }
74
+ # end
75
+ def seed(*identifiers)
76
+ setup
77
+
78
+ identifiers.flat_map { glob! _1 }.each { load_one _1 }
79
+ self
80
+ end
81
+
82
+ def glob(identifier)
83
+ Pathname.glob lookup_paths.map { File.join _1, "#{identifier}{,/**/*}.rb" }
84
+ end
85
+
86
+ def definition_location
87
+ # The first line referencing LABEL happens to be the line in the seed file.
88
+ caller_locations(3, 6).find { _1.base_label == LABEL }
89
+ end
90
+
91
+ private
92
+ def setup = @setup ||= glob(:setup).each { load_one _1 }
93
+
94
+ def glob!(identifier)
95
+ glob(identifier).then.find(&:any?) or raise NoSeedsFoundError, "found no seed files for #{identifier.inspect}"
96
+ end
97
+
98
+ def load_one(path)
99
+ context.class_eval path.read, path.to_s
100
+ end
101
+ LABEL = instance_method(:load_one).name.to_s
102
+ end
data/lib/oaken/railtie.rb CHANGED
@@ -1,5 +1,7 @@
1
- class Oaken::Railtie < Rails::Railtie
2
- initializer "oaken.lookup_paths" do
3
- Oaken.lookup_paths << "db/seeds/#{Rails.env}"
1
+ module Oaken
2
+ class Railtie < Rails::Railtie
3
+ initializer "oaken.lookup_paths" do
4
+ Oaken.lookup_paths << "db/seeds/#{Rails.env}"
5
+ end
4
6
  end
5
7
  end
@@ -1,10 +1,5 @@
1
1
  RSpec.configure do |config|
2
- config.include Oaken::Seeds
2
+ config.include Oaken.loader.context
3
3
  config.use_transactional_fixtures = true
4
-
5
- config.before :suite do
6
- # Mimic fixtures by truncating before inserting.
7
- ActiveRecord::Tasks::DatabaseTasks.truncate_all
8
- Oaken.load_seed
9
- end
4
+ config.before(:suite) { Oaken.loader.replant_seed }
10
5
  end
data/lib/oaken/seeds.rb CHANGED
@@ -1,110 +1,51 @@
1
1
  module Oaken::Seeds
2
- extend self
2
+ def self.loader = Oaken.loader
3
+ singleton_class.delegate :seed, :register, to: :loader
4
+ delegate :seed, to: self
3
5
 
4
- # Allow assigning defaults across different types.
5
- def self.defaults(**defaults) = attributes.merge!(**defaults)
6
- def self.defaults_for(*keys) = attributes.slice(*keys)
7
- def self.attributes = @attributes ||= {}.with_indifferent_access
6
+ extend self
8
7
 
9
- # Oaken's main auto-registering logic.
8
+ # Oaken's auto-registering logic.
10
9
  #
11
10
  # So when you first call e.g. `accounts.create`, we'll hit `method_missing` here
12
- # and automatically call `register Account`.
11
+ # and automatically call `register Account, as: :accounts`.
13
12
  #
14
- # We'll also match partial and full nested namespaces like in this order:
13
+ # We'll also match partial and full nested namespaces:
15
14
  #
16
15
  # accounts => Account
17
- # account_jobs => AccountJob | Account::Job
18
- # account_job_tasks => AccountJobTask | Account::JobTask | Account::Job::Task
16
+ # account_jobs => Account::Job | AccountJob
17
+ # account_job_tasks => Account::JobTask | Account::Job::Task | AccountJob::Task | AccountJobTask
19
18
  #
20
- # If you have classes that don't follow this naming convention, you must call `register` manually.
19
+ # If you have classes that don't follow these naming conventions, you must call `register` manually.
21
20
  def self.method_missing(meth, ...)
22
- name = meth.to_s.classify
23
- name = name.sub!(/(?<=[a-z])(?=[A-Z])/, "::") until name.nil? or type = name.safe_constantize
24
-
25
- if type
26
- register type
21
+ if type = loader.locate(meth)
22
+ register type, as: meth
27
23
  public_send(meth, ...)
28
24
  else
29
25
  super
30
26
  end
31
27
  end
32
- def self.respond_to_missing?(name, ...) = name.to_s.classify.safe_constantize || super
28
+ def self.respond_to_missing?(meth, ...) = loader.locate(meth) || super
33
29
 
34
- # Register a model class to be accessible as an instance method via `include Oaken::Seeds`.
35
- # Note: Oaken's auto-register via `method_missing` means it's less likely you need to call this manually.
30
+ # Purely for decorative purposes to carve up seed files.
36
31
  #
37
- # register Account, Account::Job, Account::Job::Task
32
+ # `section` is defined as `def section(*, **) = block_given? && yield`, so you can use
33
+ # all of Ruby's method signature flexibility to help communicate structure better.
38
34
  #
39
- # Oaken uses the `table_name` of the passed classes for the method names, e.g. here they'd be
40
- # `accounts`, `account_jobs`, and `account_job_tasks`, respectively.
41
- def self.register(*types)
42
- types.each do |type|
43
- stored = provider.new(type) and define_method(stored.key) { stored }
44
- end
45
- end
46
- def self.provider = Oaken::Stored::ActiveRecord
47
-
48
- class << self
49
- # Set up a general seed rule or perform a one-off seed for a test file.
50
- #
51
- # You can set up a general seed rule in `db/seeds.rb` like this:
52
- #
53
- # Oaken.prepare do
54
- # seed :accounts # Seeds from `db/seeds/accounts/**/*.rb` and `db/seeds/<Rails.env>/accounts/**/*.rb`
55
- # end
56
- #
57
- # Then if you need a test specific scenario, we recommend putting them in `db/seeds/test/cases`.
58
- #
59
- # Say you have `db/seeds/test/cases/pagination.rb`, you can load it like this:
60
- #
61
- # # test/integration/pagination_test.rb
62
- # class PaginationTest < ActionDispatch::IntegrationTest
63
- # setup { seed "cases/pagination" }
64
- # end
65
- def seed(*directories)
66
- Oaken.lookup_paths.product(directories).each do |path, directory|
67
- load_from Pathname(path).join(directory.to_s)
68
- end
69
- end
70
-
71
- private def load_from(path)
72
- @loader = Oaken::Loader.new path
73
- @loader.load_onto self
74
- ensure
75
- @loader = nil
76
- end
77
-
78
- # `section` is purely for decorative purposes to carve up `Oaken.prepare` and seed files.
79
- #
80
- # Oaken.prepare do
81
- # section :roots # Just the very few top-level models like Accounts and Users.
82
- # users.defaults email_address: -> { Faker::Internet.email }, webauthn_id: -> { SecureRandom.hex }
83
- #
84
- # section :stems # Models building on the roots.
85
- #
86
- # section :leafs # Remaining models, bulk of them, hanging off root and stem models.
87
- #
88
- # section do
89
- # seed :accounts, :data
90
- # end
91
- # end
92
- #
93
- # Since `section` is defined as `def section(*, **) = yield if block_given?`, you can use
94
- # all of Ruby's method signature flexibility to help communicate structure better.
95
- #
96
- # Use positional and keyword arguments, or use blocks to indent them, or combine them all.
97
- def section(*, **)
98
- yield if block_given?
99
- end
100
- end
101
-
102
- # Call `seed` in tests to load individual case files:
35
+ # Use positional & keyword arguments, blocks at multiple levels, or a combination.
36
+ #
37
+ # section :basic
38
+ # users.create name: "Someone"
39
+ #
40
+ # section :menus, quicksale: true
41
+ #
42
+ # section do
43
+ # # Leave name implicit and carve up visually with the indentation.
44
+ # section something: :nested
103
45
  #
104
- # class PaginationTest < ActionDispatch::IntegrationTest
105
- # setup do
106
- # seed "cases/pagination" # Loads `db/seeds/{,test}/cases/pagination{,**/*}.rb`
46
+ # section :another_level do
47
+ # # We can keep going, but maybe we shouldn't.
107
48
  # end
108
49
  # end
109
- delegate :seed, to: Oaken::Seeds
50
+ def self.section(*, **) = block_given? && yield
110
51
  end
@@ -1,45 +1,73 @@
1
1
  class Oaken::Stored::ActiveRecord
2
- def initialize(type)
3
- @type, @key = type, type.table_name
4
- @attributes = Oaken::Seeds.defaults_for(*type.column_names)
2
+ def initialize(loader, type)
3
+ @loader, @type = loader, type
4
+ @attributes = loader.defaults_for(*type.column_names)
5
5
  end
6
- attr_reader :type, :key
6
+ attr_reader :type
7
7
  delegate :transaction, to: :type # For multi-db setups to help open a transaction on secondary connections.
8
8
  delegate :find, :insert_all, :pluck, to: :type
9
9
 
10
- def defaults(**attributes)
11
- @attributes = @attributes.merge(attributes)
12
- @attributes
13
- end
14
-
10
+ # Create a record in the database with the passed `attributes`.
15
11
  def create(label = nil, unique_by: nil, **attributes)
16
- attributes = @attributes.merge(attributes)
17
- attributes.transform_values! { _1.respond_to?(:call) ? _1.call : _1 }
12
+ attributes = attributes_for(**attributes)
18
13
 
19
14
  finders = attributes.slice(*unique_by)
20
15
  record = type.find_by(finders)&.tap { _1.update!(**attributes) } if finders.any?
21
16
  record ||= type.create!(**attributes)
22
17
 
23
- label label => record if label
18
+ _label label, record.id if label
24
19
  record
25
20
  end
26
21
 
22
+ # Upsert a record in the database with the passed `attributes`.
27
23
  def upsert(label = nil, unique_by: nil, **attributes)
28
- attributes = @attributes.merge(attributes)
29
- attributes.transform_values! { _1.respond_to?(:call) ? _1.call : _1 }
24
+ attributes = attributes_for(**attributes)
30
25
 
31
26
  type.new(attributes).validate!
32
27
  record = type.new(id: type.upsert(attributes, unique_by: unique_by, returning: :id).rows.first.first)
33
- label label => record if label
28
+ _label label, record.id if label
34
29
  record
35
30
  end
36
31
 
37
- def label(**labels)
38
- # TODO: Fix hardcoding of db/seeds instead of using Oaken.lookup_paths
39
- location = caller_locations(1, 6).find { _1.path.match? /db\/seeds\// }
32
+ # Build attributes used for `create`/`upsert`, applying loader and per-type `defaults`.
33
+ #
34
+ # loader.defaults name: -> { "Global" }, email_address: -> { }
35
+ # users.defaults name: -> { Faker::Name.name } # This `name` takes precedence on users.
36
+ #
37
+ # users.attributes_for(email_address: "user@example.com") # => { name: "Some Faker Name", email_address: "user@example.com" }
38
+ def attributes_for(**attributes)
39
+ @attributes.merge(attributes).transform_values! { _1.respond_to?(:call) ? _1.call : _1 }
40
+ end
41
+
42
+ # Set defaults for all types:
43
+ #
44
+ # loader.defaults name: -> { "Global" }, email_address: -> { … }
45
+ #
46
+ # These defaults are used and evaluated in `create`/`upsert`/`attributes_for`, but you can override on a per-type basis:
47
+ #
48
+ # users.defaults name: -> { Faker::Name.name } # This `name` takes precedence on `users`.
49
+ # users.create # => Uses the users' default `name` and the loader `email_address`
50
+ def defaults(**attributes) = @attributes = @attributes.merge(attributes)
51
+
52
+ # Expose a record instance that's setup outside of using `create`/`upsert`. Like this:
53
+ #
54
+ # users.label someone: User.create!(name: "Someone")
55
+ # users.label someone: FactoryBot.create(:user, name: "Someone")
56
+ #
57
+ # Now `users.someone` returns the record instance.
58
+ #
59
+ # Ruby's Hash argument forwarding also works:
60
+ #
61
+ # someone, someone_else = users.create(name: "Someone"), users.create(name: "Someone Else")
62
+ # users.label someone:, someone_else:
63
+ #
64
+ # 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 }
66
+
67
+ private def _label(name, id)
68
+ location = @loader.definition_location or
69
+ raise ArgumentError, "you can only define labelled records outside of tests"
40
70
 
41
- labels.each do |label, record|
42
- class_eval "def #{label} = find(#{record.id})", location.path, location.lineno
43
- end
71
+ class_eval "def #{name} = find(#{id.inspect})", location.path, location.lineno
44
72
  end
45
73
  end
@@ -1,24 +1,23 @@
1
- module Oaken::TestSetup
2
- include Oaken::Seeds
1
+ class Oaken::TestSetup < Module
2
+ def initialize(loader)
3
+ @loader = loader
3
4
 
4
- def self.included(klass)
5
- klass.fixtures # Rely on fixtures to setup a shared connection pool and wrap tests in transactions.
6
- klass.parallelize_setup { Oaken.load_seed } # No need to truncate as parallel test databases are always empty.
7
- klass.prepend BeforeSetup
8
- end
9
-
10
- module BeforeSetup
11
5
  # We must inject late enough to call `should_parallelize?`, but before fixtures' `before_setup`.
12
6
  #
13
7
  # So we prepend into `before_setup` and later `super` to have fixtures wrap tests in transactions.
14
- def before_setup
15
- unless Minitest.parallel_executor.send(:should_parallelize?)
16
- ActiveRecord::Tasks::DatabaseTasks.truncate_all # Mimic fixtures by truncating before inserting.
17
- Oaken.load_seed
18
- end
8
+ instance = self
9
+ define_method :before_setup do
10
+ # `should_parallelize?` is only defined when Rails' test `parallelize` macro has been called.
11
+ loader.replant_seed unless Minitest.parallel_executor.then { _1.respond_to?(:should_parallelize?, true) && _1.send(:should_parallelize?) }
19
12
 
20
- Oaken::TestSetup::BeforeSetup.remove_method :before_setup # Only run once, so remove before passing to fixtures in `super`.
21
- super
13
+ instance.remove_method :before_setup # Only run once, so remove before passing to fixtures in `super`.
14
+ super()
22
15
  end
23
16
  end
17
+
18
+ def included(klass)
19
+ klass.fixtures # Rely on fixtures to setup a shared connection pool and wrap tests in transactions.
20
+ klass.include @loader.context
21
+ klass.parallelize_setup { @loader.load_seed } # No need to truncate as parallel test databases are always empty.
22
+ end
24
23
  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.7.1"
4
+ VERSION = "0.9.0"
5
5
  end
data/lib/oaken.rb CHANGED
@@ -6,6 +6,7 @@ require "pathname"
6
6
  module Oaken
7
7
  class Error < StandardError; end
8
8
 
9
+ autoload :Loader, "oaken/loader"
9
10
  autoload :Seeds, "oaken/seeds"
10
11
  autoload :TestSetup, "oaken/test_setup"
11
12
 
@@ -13,23 +14,9 @@ module Oaken
13
14
  autoload :ActiveRecord, "oaken/stored/active_record"
14
15
  end
15
16
 
16
- singleton_class.attr_reader :lookup_paths
17
- @lookup_paths = ["db/seeds"]
18
-
19
- class Loader
20
- def initialize(path)
21
- @entries = Pathname.glob("#{path}{,/**/*}.rb").sort
22
- end
23
-
24
- def load_onto(seeds) = @entries.each do |path|
25
- ActiveRecord::Base.transaction do
26
- seeds.class_eval path.read, path.to_s
27
- end
28
- end
29
- end
30
-
31
- def self.prepare(&block) = Seeds.instance_eval(&block)
32
- def self.load_seed = Rails.application.load_seed
17
+ singleton_class.attr_accessor :loader
18
+ singleton_class.delegate *Loader.public_instance_methods(false), to: :loader
19
+ @loader = Loader.new lookup_paths: "db/seeds"
33
20
  end
34
21
 
35
22
  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.7.1
4
+ version: 0.9.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: 2024-12-10 00:00:00.000000000 Z
11
+ date: 2025-05-13 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description:
14
14
  email:
@@ -24,6 +24,8 @@ files:
24
24
  - Rakefile
25
25
  - lib/generators/oaken/convert/fixtures_generator.rb
26
26
  - lib/oaken.rb
27
+ - lib/oaken/loader.rb
28
+ - lib/oaken/loader/type.rb
27
29
  - lib/oaken/railtie.rb
28
30
  - lib/oaken/rspec_setup.rb
29
31
  - lib/oaken/seeds.rb
@@ -53,7 +55,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
53
55
  - !ruby/object:Gem::Version
54
56
  version: '0'
55
57
  requirements: []
56
- rubygems_version: 3.5.18
58
+ rubygems_version: 3.5.22
57
59
  signing_key:
58
60
  specification_version: 4
59
61
  summary: Oaken aims to blend your Fixtures/Factories and levels up your database seeds.