oaken 0.8.0 → 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: 43bfd3d777a6a7060eacfda4d23ee10bdb19932547bd6c52e82757b77e897d0e
4
- data.tar.gz: 2c81615943933ca2a22f375f2d7afedd07078ea1ca4c71f609970273da5fe0a5
3
+ metadata.gz: 1eea7421c7a71e2be111c2a82448345d9913830db2405c9903f1e57bf4371e79
4
+ data.tar.gz: 185532dbf42d282773eed6fef3a1ec139f81d9fdc37490c6a3b69b80e88bd862
5
5
  SHA512:
6
- metadata.gz: 2e13841a16e33779150ba1ba45cf7c4407f1d2f7ead9fb29ca886b93afe2a78fc3394c39899b1f550878b2e0f915f6ceeb52e80582c0fb167e3784a68f453002
7
- data.tar.gz: 751a8d8613e43d1af6c5f76ab73be8ee6acc1aa1a6d2b0a3c58250540555d5da1503860e7f3d582d4107da097b840add7e186fe6067344c3bfabd8bc36b5a836
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
12
129
 
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?
130
+ ### Consistent data & constrained Ruby
14
131
 
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.
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.
16
133
 
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.
134
+ ### Pick up in 1 hour or less
18
135
 
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.
136
+ We don't want to be a costly DSL that takes ages to learn and relearn when you come back to it.
137
+
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.
172
+
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.
24
200
 
25
- You can set it up in `db/seeds.rb`, like this:
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
219
+
220
+ For the cases part, you may want to tweak it a bit more.
40
221
 
41
- kasper = users.create :kasper, name: "Kasper", accounts: [donuts]
42
- coworker = users.create :coworker, name: "Coworker", accounts: [donuts]
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.
43
223
 
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
224
+ If not, you can add environment specific ones in `db/seeds/development/cases/pagination.rb` and `db/seeds/test/cases/pagination.rb`.
47
225
 
48
- supporter = users.create name: "Super Supporter"
49
- orders.insert_all [user_id: supporter.id, item_id: plain_donut.id] * 10
226
+ You could also avoid loading all the cases in the test environment like this:
50
227
 
51
- orders.insert_all \
52
- 10.times.map { { user_id: users.create(name: "Customer #{_1}").id, item_id: menu.items.sample.id } }
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
+ ```
247
+
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
255
+
256
+ #### Configuring loaders
61
257
 
62
- Now you can run `bin/rails db:seed` and `bin/rails db:seed:replant`.
258
+ You can customize the loading and loader as well:
63
259
 
64
- ### Interlude: Directory Naming Conventions
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)
65
265
 
66
- Oaken has some chosen directory conventions to help strengthen your understanding of your object graph:
266
+ Oaken.loader = loader # You can also replace Oaken's default loader.
267
+ ```
67
268
 
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/test/cases` for any specific cases that are only used in some tests, like `pagination.rb`.
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.
71
272
 
72
- ### Using default attributes
273
+ #### In db/seeds.rb
73
274
 
74
- You can set up default attributes that's applied to created/inserted records at different levels, like this:
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,39 +314,138 @@ 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" }
113
- end
344
+ users.create name: "Someone"
114
345
  ```
115
346
 
116
- And in RSpec:
347
+ Some records have uniqueness constraints, like a User's `email_address`, you can pass that via `unique_by`:
117
348
 
118
349
  ```ruby
119
- RSpec.describe "Pagination", type: :feature do
120
- before { seed "cases/pagination" }
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)
121
419
  end
122
420
  ```
123
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
+
124
431
  > [!NOTE]
125
- > 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:
126
- >
127
- > - Reduce amount of junk data generated for unrelated tests
128
- > - Make it easier to debug a particular test
129
- > - Reduce test flakiness
130
- > - 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
131
435
 
132
- ### Fixtures Converter
436
+ ### From fixtures
437
+
438
+ #### Converter
133
439
 
134
440
  You can convert your Rails fixtures to Oaken's seeds by running:
135
441
 
136
- $ bin/rails generate oaken:convert:fixtures
442
+ ```
443
+ bin/rails generate oaken:convert:fixtures
444
+ ```
137
445
 
138
- This will convert anything in test/fixtures to db/seeds. E.g. `test/fixtures/users.yml` becomes `db/seeds/users.rb`.
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.
139
447
 
140
- ### Disable fixtures
448
+ #### Disable fixtures
141
449
 
142
450
  IF you've fully converted to Oaken you may no longer want fixtures when running Rails' generators,
143
451
  so you can disable generating them in `config/application.rb` like this:
@@ -156,6 +464,44 @@ The `test_framework` repeating is to preserve `:test_unit` or `:rspec` respectiv
156
464
  > [!NOTE]
157
465
  > If you're using `FactoryBot` as well, you don't need to do this since it already replaces fixtures for you.
158
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.
504
+
159
505
  ## Installation
160
506
 
161
507
  Install the gem and add to the application's Gemfile by executing:
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Oaken::Type < Struct.new(:name, :gsub)
3
+ class Oaken::Loader::Type < Struct.new(:name, :gsub)
4
+ def self.locate(name) = self.for(name.to_s).locate
4
5
  def self.for(name) = new(name, name.classify.gsub(/(?<=[a-z])(?=[A-Z])/))
5
6
 
6
7
  def locate
@@ -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
@@ -1,8 +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
- Oaken.replant_seed
7
- end
4
+ config.before(:suite) { Oaken.loader.replant_seed }
8
5
  end
data/lib/oaken/seeds.rb CHANGED
@@ -1,15 +1,14 @@
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
13
  # We'll also match partial and full nested namespaces:
15
14
  #
@@ -19,84 +18,34 @@ module Oaken::Seeds
19
18
  #
20
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
- if type = Oaken::Type.for(meth.to_s).locate
21
+ if type = loader.locate(meth)
23
22
  register type, as: meth
24
23
  public_send(meth, ...)
25
24
  else
26
25
  super
27
26
  end
28
27
  end
29
- def self.respond_to_missing?(meth, ...) = Oaken::Type.for(meth.to_s).locate || super
28
+ def self.respond_to_missing?(meth, ...) = loader.locate(meth) || super
30
29
 
31
- # Register a model class to be accessible as an instance method via `include Oaken::Seeds`.
32
- # 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.
33
31
  #
34
- # 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.
35
34
  #
36
- # Oaken uses `name.tableize.tr("/", "_")` on the passed classes for the method names, so they're
37
- # `accounts`, `account_jobs`, and `account_job_tasks`, respectively.
35
+ # Use positional & keyword arguments, blocks at multiple levels, or a combination.
38
36
  #
39
- # You can also pass an explicit `as:` option, if you'd like:
37
+ # section :basic
38
+ # users.create name: "Someone"
40
39
  #
41
- # register User, as: :something_else
42
- def self.register(*types, as: nil)
43
- types.each do |type|
44
- stored = provider.new(type) and define_method(as || type.name.tableize.tr("/", "_")) { stored }
45
- end
46
- end
47
- def self.provider = Oaken::Stored::ActiveRecord
48
-
49
- class << self
50
- # Set up a general seed rule or perform a one-off seed for a test file.
51
- #
52
- # You can set up a general seed rule in `db/seeds.rb` like this:
53
- #
54
- # Oaken.prepare do
55
- # seed :accounts # Seeds from `db/seeds/accounts/**/*.rb` and `db/seeds/<Rails.env>/accounts/**/*.rb`
56
- # end
57
- #
58
- # Then if you need a test specific scenario, we recommend putting them in `db/seeds/test/cases`.
59
- #
60
- # Say you have `db/seeds/test/cases/pagination.rb`, you can load it like this:
61
- #
62
- # # test/integration/pagination_test.rb
63
- # class PaginationTest < ActionDispatch::IntegrationTest
64
- # setup { seed "cases/pagination" }
65
- # end
66
- def seed(*identifiers)
67
- Oaken::Loader.from(identifiers).load_onto self
68
- end
69
-
70
- # `section` is purely for decorative purposes to carve up `Oaken.prepare` and seed files.
71
- #
72
- # Oaken.prepare do
73
- # section :roots # Just the very few top-level models like Accounts and Users.
74
- # users.defaults email_address: -> { Faker::Internet.email }, webauthn_id: -> { SecureRandom.hex }
75
- #
76
- # section :stems # Models building on the roots.
77
- #
78
- # section :leafs # Remaining models, bulk of them, hanging off root and stem models.
79
- #
80
- # section do
81
- # seed :accounts, :data
82
- # end
83
- # end
84
- #
85
- # Since `section` is defined as `def section(*, **) = yield if block_given?`, you can use
86
- # all of Ruby's method signature flexibility to help communicate structure better.
87
- #
88
- # Use positional and keyword arguments, or use blocks to indent them, or combine them all.
89
- def section(*, **)
90
- yield if block_given?
91
- end
92
- end
93
-
94
- # Call `seed` in tests to load individual case files:
40
+ # section :menus, quicksale: true
41
+ #
42
+ # section do
43
+ # # Leave name implicit and carve up visually with the indentation.
44
+ # section something: :nested
95
45
  #
96
- # class PaginationTest < ActionDispatch::IntegrationTest
97
- # setup do
98
- # 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.
99
48
  # end
100
49
  # end
101
- delegate :seed, to: Oaken::Seeds
50
+ def self.section(*, **) = block_given? && yield
102
51
  end
@@ -1,12 +1,13 @@
1
1
  class Oaken::Stored::ActiveRecord
2
- def initialize(type)
3
- @type = type
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
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
+ # Create a record in the database with the passed `attributes`.
10
11
  def create(label = nil, unique_by: nil, **attributes)
11
12
  attributes = attributes_for(**attributes)
12
13
 
@@ -14,47 +15,39 @@ class Oaken::Stored::ActiveRecord
14
15
  record = type.find_by(finders)&.tap { _1.update!(**attributes) } if finders.any?
15
16
  record ||= type.create!(**attributes)
16
17
 
17
- label label => record if label
18
+ _label label, record.id if label
18
19
  record
19
20
  end
20
21
 
22
+ # Upsert a record in the database with the passed `attributes`.
21
23
  def upsert(label = nil, unique_by: nil, **attributes)
22
24
  attributes = attributes_for(**attributes)
23
25
 
24
26
  type.new(attributes).validate!
25
27
  record = type.new(id: type.upsert(attributes, unique_by: unique_by, returning: :id).rows.first.first)
26
- label label => record if label
28
+ _label label, record.id if label
27
29
  record
28
30
  end
29
31
 
30
- # Build attributes used for `create`/`upsert`, applying any global and per-type `defaults`.
32
+ # Build attributes used for `create`/`upsert`, applying loader and per-type `defaults`.
31
33
  #
32
- # # db/seeds.rb
33
- # Oaken.prepare do
34
- # defaults name: -> { "Global" }, email_address: -> { … }
35
- # users.defaults name: -> { Faker::Name.name } # This `name` takes precedence on users.
36
- # end
34
+ # loader.defaults name: -> { "Global" }, email_address: -> { … }
35
+ # users.defaults name: -> { Faker::Name.name } # This `name` takes precedence on users.
37
36
  #
38
37
  # users.attributes_for(email_address: "user@example.com") # => { name: "Some Faker Name", email_address: "user@example.com" }
39
38
  def attributes_for(**attributes)
40
39
  @attributes.merge(attributes).transform_values! { _1.respond_to?(:call) ? _1.call : _1 }
41
40
  end
42
41
 
43
- # Set defaults for this type:
42
+ # Set defaults for all types:
44
43
  #
45
- # # db/seeds.rb
46
- # Oaken.prepare do
47
- # defaults name: -> { "Global" }, email_address: -> { … }
48
- # users.defaults name: -> { Faker::Name.name } # This `name` takes precedence on users.
49
- # end
44
+ # loader.defaults name: -> { "Global" }, email_address: -> { … }
50
45
  #
51
- # These defaults are used and evaluated in `create`/`upsert`/`attributes_for`.
46
+ # These defaults are used and evaluated in `create`/`upsert`/`attributes_for`, but you can override on a per-type basis:
52
47
  #
53
- # users.create # => Uses the users' default `name` and the global `email_address`
54
- def defaults(**attributes)
55
- @attributes = @attributes.merge(attributes)
56
- @attributes
57
- end
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)
58
51
 
59
52
  # Expose a record instance that's setup outside of using `create`/`upsert`. Like this:
60
53
  #
@@ -65,18 +58,15 @@ class Oaken::Stored::ActiveRecord
65
58
  #
66
59
  # Ruby's Hash argument forwarding also works:
67
60
  #
68
- # someone = users.create(name: "Someone")
69
- # someone_else = users.create(name: "Someone Else")
61
+ # someone, someone_else = users.create(name: "Someone"), users.create(name: "Someone Else")
70
62
  # users.label someone:, someone_else:
71
63
  #
72
64
  # Note: `users.method(:someone).source_location` also points back to the file and line of the `label` call.
73
- def label(**labels)
74
- labels.each { |label, record| _label label, record.id }
75
- end
65
+ def label(**labels) = labels.each { |label, record| _label label, record.id }
76
66
 
77
67
  private def _label(name, id)
78
- raise ArgumentError, "you can only define labelled records outside of tests" \
79
- unless location = Oaken::Loader.definition_location
68
+ location = @loader.definition_location or
69
+ raise ArgumentError, "you can only define labelled records outside of tests"
80
70
 
81
71
  class_eval "def #{name} = find(#{id.inspect})", location.path, location.lineno
82
72
  end
@@ -1,22 +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
8
+ instance = self
9
+ define_method :before_setup do
15
10
  # `should_parallelize?` is only defined when Rails' test `parallelize` macro has been called.
16
- Oaken.replant_seed unless Minitest.parallel_executor.then { _1.respond_to?(:should_parallelize?, true) && _1.send(:should_parallelize?) }
11
+ loader.replant_seed unless Minitest.parallel_executor.then { _1.respond_to?(:should_parallelize?, true) && _1.send(:should_parallelize?) }
17
12
 
18
- Oaken::TestSetup::BeforeSetup.remove_method :before_setup # Only run once, so remove before passing to fixtures in `super`.
19
- super
13
+ instance.remove_method :before_setup # Only run once, so remove before passing to fixtures in `super`.
14
+ super()
20
15
  end
21
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
22
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.8.0"
4
+ VERSION = "0.9.0"
5
5
  end
data/lib/oaken.rb CHANGED
@@ -6,57 +6,17 @@ 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
- autoload :Type, "oaken/type"
11
11
  autoload :TestSetup, "oaken/test_setup"
12
12
 
13
13
  module Stored
14
14
  autoload :ActiveRecord, "oaken/stored/active_record"
15
15
  end
16
16
 
17
- singleton_class.attr_reader :lookup_paths
18
- @lookup_paths = ["db/seeds"]
19
-
20
- def self.glob(identifier)
21
- patterns = lookup_paths.map { File.join _1, "#{identifier}{,/**/*}.rb" }
22
-
23
- Pathname.glob(patterns).tap do |found|
24
- raise NoSeedsFoundError, "found no seed files for #{identifier.inspect}" if found.none?
25
- end
26
- end
27
- NoSeedsFoundError = Class.new ArgumentError
28
-
29
- class Loader
30
- def self.from(identifiers)
31
- new identifiers.flat_map { Oaken.glob _1 }
32
- end
33
-
34
- def initialize(entries)
35
- @entries = entries
36
- end
37
-
38
- def load_onto(seeds) = @entries.each do |path|
39
- ActiveRecord::Base.transaction do
40
- seeds.class_eval path.read, path.to_s
41
- end
42
- end
43
-
44
- def self.definition_location
45
- # Trickery abounds! Due to Ruby's `caller_locations` + our `load_onto`'s `class_eval` above
46
- # we can use this format to detect the location in the seed file where the call came from.
47
- caller_locations(2, 8).find { _1.label.match? /block .*?load_onto/ }
48
- end
49
- end
50
-
51
- def self.prepare(&block)
52
- Seeds.instance_eval(&block)
53
- end
54
-
55
- def self.replant_seed
56
- ActiveRecord::Tasks::DatabaseTasks.truncate_all
57
- load_seed
58
- end
59
- 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"
60
20
  end
61
21
 
62
22
  require_relative "oaken/railtie" if defined?(Rails::Railtie)
metadata CHANGED
@@ -1,14 +1,16 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: oaken
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kasper Timm Hansen
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2025-05-13 00:00:00.000000000 Z
11
12
  dependencies: []
13
+ description:
12
14
  email:
13
15
  - hey@kaspth.com
14
16
  executables: []
@@ -22,12 +24,13 @@ files:
22
24
  - Rakefile
23
25
  - lib/generators/oaken/convert/fixtures_generator.rb
24
26
  - lib/oaken.rb
27
+ - lib/oaken/loader.rb
28
+ - lib/oaken/loader/type.rb
25
29
  - lib/oaken/railtie.rb
26
30
  - lib/oaken/rspec_setup.rb
27
31
  - lib/oaken/seeds.rb
28
32
  - lib/oaken/stored/active_record.rb
29
33
  - lib/oaken/test_setup.rb
30
- - lib/oaken/type.rb
31
34
  - lib/oaken/version.rb
32
35
  homepage: https://github.com/kaspth/oaken
33
36
  licenses:
@@ -37,6 +40,7 @@ metadata:
37
40
  homepage_uri: https://github.com/kaspth/oaken
38
41
  source_code_uri: https://github.com/kaspth/oaken
39
42
  changelog_uri: https://github.com/kaspth/oaken/blob/main/CHANGELOG.md
43
+ post_install_message:
40
44
  rdoc_options: []
41
45
  require_paths:
42
46
  - lib
@@ -51,7 +55,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
51
55
  - !ruby/object:Gem::Version
52
56
  version: '0'
53
57
  requirements: []
54
- rubygems_version: 3.6.8
58
+ rubygems_version: 3.5.22
59
+ signing_key:
55
60
  specification_version: 4
56
61
  summary: Oaken aims to blend your Fixtures/Factories and levels up your database seeds.
57
62
  test_files: []