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 +4 -4
- data/README.md +411 -65
- data/lib/oaken/{type.rb → loader/type.rb} +2 -1
- data/lib/oaken/loader.rb +102 -0
- data/lib/oaken/rspec_setup.rb +2 -5
- data/lib/oaken/seeds.rb +22 -73
- data/lib/oaken/stored/active_record.rb +20 -30
- data/lib/oaken/test_setup.rb +14 -13
- data/lib/oaken/version.rb +1 -1
- data/lib/oaken.rb +4 -44
- metadata +9 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 1eea7421c7a71e2be111c2a82448345d9913830db2405c9903f1e57bf4371e79
|
4
|
+
data.tar.gz: 185532dbf42d282773eed6fef3a1ec139f81d9fdc37490c6a3b69b80e88bd862
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
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
|
-
|
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
|
-
|
130
|
+
### Consistent data & constrained Ruby
|
14
131
|
|
15
|
-
|
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
|
-
|
134
|
+
### Pick up in 1 hour or less
|
18
135
|
|
19
|
-
|
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
|
-
###
|
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
|
-
|
201
|
+
If you follow all these conventions you could do this:
|
26
202
|
|
27
203
|
```ruby
|
28
|
-
Oaken.
|
29
|
-
seed :accounts, :data
|
30
|
-
end
|
204
|
+
Oaken.loader.seed :data, :accounts, :cases
|
31
205
|
```
|
32
206
|
|
33
|
-
|
207
|
+
And here's some potential file suggestions you could take advantage of:
|
34
208
|
|
35
|
-
|
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
|
-
|
38
|
-
|
39
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
52
|
-
|
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
|
-
|
57
|
-
|
235
|
+
class PaginationTest < ActionDispatch::IntegrationTest
|
236
|
+
setup { seed "cases/pagination" }
|
237
|
+
end
|
58
238
|
```
|
59
239
|
|
60
|
-
|
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
|
-
|
258
|
+
You can customize the loading and loader as well:
|
63
259
|
|
64
|
-
|
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
|
266
|
+
Oaken.loader = loader # You can also replace Oaken's default loader.
|
267
|
+
```
|
67
268
|
|
68
|
-
|
69
|
-
|
70
|
-
|
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
|
-
|
273
|
+
#### In db/seeds.rb
|
73
274
|
|
74
|
-
|
275
|
+
Call `loader.seed` and it'll follow the rules mentioned above:
|
75
276
|
|
76
277
|
```ruby
|
77
|
-
|
78
|
-
|
79
|
-
|
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
|
-
|
82
|
-
|
83
|
-
|
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
|
-
> `
|
296
|
+
> Oaken wraps each file load in an `ActiveRecord::Base.transaction` so any invalid data rolls back the whole file.
|
88
297
|
|
89
|
-
|
298
|
+
#### In tests & specs
|
90
299
|
|
91
|
-
|
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
|
305
|
+
include Oaken.loader.test_setup
|
97
306
|
end
|
98
307
|
```
|
99
308
|
|
100
|
-
|
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
|
-
|
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
|
-
|
112
|
-
setup { seed "cases/pagination" }
|
113
|
-
end
|
344
|
+
users.create name: "Someone"
|
114
345
|
```
|
115
346
|
|
116
|
-
|
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
|
-
|
120
|
-
|
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
|
-
>
|
126
|
-
|
127
|
-
|
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
|
-
###
|
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
|
-
|
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
|
-
|
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's @erikaxel.bsky.social's team! They shaved 5.5 minutes off their test suite.
|
474
|
+
|
475
|
+
And that'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>— 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
|
data/lib/oaken/loader.rb
ADDED
@@ -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/rspec_setup.rb
CHANGED
data/lib/oaken/seeds.rb
CHANGED
@@ -1,15 +1,14 @@
|
|
1
1
|
module Oaken::Seeds
|
2
|
-
|
2
|
+
def self.loader = Oaken.loader
|
3
|
+
singleton_class.delegate :seed, :register, to: :loader
|
4
|
+
delegate :seed, to: self
|
3
5
|
|
4
|
-
|
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
|
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 =
|
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, ...) =
|
28
|
+
def self.respond_to_missing?(meth, ...) = loader.locate(meth) || super
|
30
29
|
|
31
|
-
#
|
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
|
-
#
|
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
|
-
#
|
37
|
-
# `accounts`, `account_jobs`, and `account_job_tasks`, respectively.
|
35
|
+
# Use positional & keyword arguments, blocks at multiple levels, or a combination.
|
38
36
|
#
|
39
|
-
#
|
37
|
+
# section :basic
|
38
|
+
# users.create name: "Someone"
|
40
39
|
#
|
41
|
-
#
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
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
|
-
#
|
97
|
-
#
|
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
|
-
|
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 =
|
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
|
-
|
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
|
-
|
28
|
+
_label label, record.id if label
|
27
29
|
record
|
28
30
|
end
|
29
31
|
|
30
|
-
# Build attributes used for `create`/`upsert`, applying
|
32
|
+
# Build attributes used for `create`/`upsert`, applying loader and per-type `defaults`.
|
31
33
|
#
|
32
|
-
#
|
33
|
-
#
|
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
|
42
|
+
# Set defaults for all types:
|
44
43
|
#
|
45
|
-
#
|
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.
|
54
|
-
|
55
|
-
|
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
|
-
|
79
|
-
|
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
|
data/lib/oaken/test_setup.rb
CHANGED
@@ -1,22 +1,23 @@
|
|
1
|
-
|
2
|
-
|
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
|
-
|
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
|
-
|
11
|
+
loader.replant_seed unless Minitest.parallel_executor.then { _1.respond_to?(:should_parallelize?, true) && _1.send(:should_parallelize?) }
|
17
12
|
|
18
|
-
|
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
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.
|
18
|
-
|
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.
|
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:
|
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.
|
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: []
|