request_migrations 0.1.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +111 -12
- data/lib/request_migrations/configuration.rb +29 -4
- data/lib/request_migrations/controller.rb +19 -2
- data/lib/request_migrations/gem.rb +1 -1
- data/lib/request_migrations/migration.rb +30 -0
- data/lib/request_migrations/migrator.rb +12 -1
- data/lib/request_migrations/router.rb +16 -0
- data/lib/request_migrations/version.rb +2 -0
- data/lib/request_migrations.rb +43 -2
- metadata +1 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 03674ecf6a2120accd4fcbbbe4add7f73a94098e08882a65630085b3dcc38ecd
|
4
|
+
data.tar.gz: 38b32703419f8857737fc0b8fa61d5ada954f69c201247892441291b901ed0fb
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: ed9e6c8e9cf08938177b687496f83e58e7ecbf0a35cda2f9f84428f83acff381c882d33f53e2ba3bc51b0d533f577f7a93bc1c9fc2533a57d79bd479a80a550f
|
7
|
+
data.tar.gz: 36cc532f949768e70bf1582a9c652db79e5900811ebe0a4e08bd9a4840442b346c6c93cedd0d6d4c6d2d39bf6f487c321dd4d731c8f8533fa7d64ff65954dd85
|
data/CHANGELOG.md
CHANGED
data/README.md
CHANGED
@@ -49,6 +49,7 @@ _We're working on improving the docs._
|
|
49
49
|
- Define migrations for migrating a request between versions.
|
50
50
|
- Define migrations for applying one-off migrations.
|
51
51
|
- Define version-based routing constraints.
|
52
|
+
- It's fast.
|
52
53
|
|
53
54
|
## Usage
|
54
55
|
|
@@ -138,7 +139,7 @@ RequestMigrations.configure do |config|
|
|
138
139
|
|
139
140
|
# Define previous versions and their migrations, in descending order.
|
140
141
|
config.versions = {
|
141
|
-
'1.0' => %i[
|
142
|
+
'1.0' => %i[combine_names_for_user_migration],
|
142
143
|
}
|
143
144
|
end
|
144
145
|
```
|
@@ -149,6 +150,14 @@ are applied:
|
|
149
150
|
```ruby
|
150
151
|
class ApplicationController < ActionController::API
|
151
152
|
include RequestMigrations::Controller::Migrations
|
153
|
+
|
154
|
+
# Optionally rescue from requests for unsupported versions
|
155
|
+
rescue_from RequestMigrations::UnsupportedVersionError, with: -> {
|
156
|
+
render(
|
157
|
+
json: { error: 'unsupported API version requested', code: 'INVALID_API_VERSION' },
|
158
|
+
status: :bad_request,
|
159
|
+
)
|
160
|
+
}
|
152
161
|
end
|
153
162
|
```
|
154
163
|
|
@@ -269,6 +278,8 @@ Now, we've successfully applied a migration to both our API responses, as well
|
|
269
278
|
as to the webhook events we send. In this case, if our `event` matches the
|
270
279
|
our user shape, e.g. `type: 'user'`, then the migration will be applied.
|
271
280
|
|
281
|
+
In addition to one-off migrations, this allows for easier testing.
|
282
|
+
|
272
283
|
### Routing constraints
|
273
284
|
|
274
285
|
When you want to encourage API clients to upgrade, you can utilize a routing `version_constraint`
|
@@ -324,9 +335,8 @@ RequestMigrations.configure do |config|
|
|
324
335
|
],
|
325
336
|
}
|
326
337
|
|
327
|
-
# Use a custom logger.
|
328
|
-
|
329
|
-
config.logger = ActiveSupport::TaggedLogging.new(...)
|
338
|
+
# Use a custom logger. Supports ActiveSupport::TaggedLogging.
|
339
|
+
config.logger = Rails.logger
|
330
340
|
end
|
331
341
|
```
|
332
342
|
|
@@ -343,11 +353,43 @@ to instead use one of the following, set via `config.version_format=`.
|
|
343
353
|
| `:float` | Use float versions, e.g. `1.0`, `1.1`, and `2.0`. |
|
344
354
|
| `:string` | Use string versions, e.g. `a`, `b`, and `z`. |
|
345
355
|
|
346
|
-
|
356
|
+
## Testing
|
357
|
+
|
358
|
+
Using one-offs allows for easier testing of migrations. For example, using Rspec:
|
359
|
+
|
360
|
+
```ruby
|
361
|
+
describe CombineNamesForUserMigration do
|
362
|
+
before do
|
363
|
+
RequestMigrations.configure do |config|
|
364
|
+
config.current_version = '1.1'
|
365
|
+
config.versions = {
|
366
|
+
'1.0' => [CombineNamesForUserMigration],
|
367
|
+
}
|
368
|
+
end
|
369
|
+
end
|
370
|
+
|
371
|
+
it 'should migrate user name attributes' do
|
372
|
+
migrator = RequestMigrations::Migrator.new(from: '1.1', to: '1.0')
|
373
|
+
data = serialize(
|
374
|
+
create(:user, first_name: 'John', last_name: 'Doe'),
|
375
|
+
)
|
376
|
+
|
377
|
+
expect(data).to include(type: 'user', first_name: 'John', last_name: 'Doe')
|
378
|
+
expect(data).to_not include(name: anything)
|
379
|
+
|
380
|
+
migrator.migrate!(data:)
|
381
|
+
|
382
|
+
expect(data).to include(type: 'user', name: 'John Doe')
|
383
|
+
expect(data).to_not include(first_name: 'John', last_name: 'Doe')
|
384
|
+
end
|
385
|
+
end
|
386
|
+
```
|
387
|
+
|
388
|
+
## Tips and tricks
|
347
389
|
|
348
390
|
Over the years, we're learned a thing or two about writing request migrations. We'll share tips here.
|
349
391
|
|
350
|
-
|
392
|
+
### Use pattern matching
|
351
393
|
|
352
394
|
Pattern matching really cleans up the `:if` conditions, and overall makes migrations more readable.
|
353
395
|
|
@@ -382,7 +424,7 @@ end
|
|
382
424
|
|
383
425
|
Just be sure to remember your `else` block when `case` pattern matching. :)
|
384
426
|
|
385
|
-
|
427
|
+
### Route helpers
|
386
428
|
|
387
429
|
If you need to use route helpers in a migration, include them in your migration:
|
388
430
|
|
@@ -392,7 +434,7 @@ class SomeMigration < RequestMigrations::Migration
|
|
392
434
|
end
|
393
435
|
```
|
394
436
|
|
395
|
-
|
437
|
+
### Separate by shape
|
396
438
|
|
397
439
|
Define separate migrations for different input shapes, e.g. define a migration for an `#index`
|
398
440
|
to migrate an array of objects, and define another migration that handles the singular object
|
@@ -454,7 +496,7 @@ end
|
|
454
496
|
|
455
497
|
Note that the `migrate` method now migrates an array input, and matches on the `#index` route.
|
456
498
|
|
457
|
-
|
499
|
+
### Always check response status
|
458
500
|
|
459
501
|
Always check a response's status. You don't want to unintentionally apply migrations to error
|
460
502
|
responses.
|
@@ -467,7 +509,9 @@ class SomeMigration < RequestMigrations::Migration
|
|
467
509
|
end
|
468
510
|
```
|
469
511
|
|
470
|
-
|
512
|
+
Also mind `204 No Content`, since the response body will be `nil`.
|
513
|
+
|
514
|
+
### Don't match on URL pattern
|
471
515
|
|
472
516
|
Don't match on URL pattern. Instead, use `response.request.params` to access the request params
|
473
517
|
in a `response` migration, and use the `:controller` and `:action` params to determine route.
|
@@ -482,7 +526,7 @@ class SomeMigration < RequestMigrations::Migration
|
|
482
526
|
end
|
483
527
|
```
|
484
528
|
|
485
|
-
|
529
|
+
### Namespace deprecated controllers
|
486
530
|
|
487
531
|
When you need to entirely change a controller or service class, use a `V1x0::UsersController`-style
|
488
532
|
namespace to keep the old deprecated classes tidy.
|
@@ -496,7 +540,7 @@ end
|
|
496
540
|
```
|
497
541
|
|
498
542
|
|
499
|
-
|
543
|
+
### Avoid routing contraints
|
500
544
|
|
501
545
|
Avoid using routing version constraints that remove functionality. They can be a headache
|
502
546
|
during upgrades. Consider only making _additive_ changes. You should remove docs for old
|
@@ -518,6 +562,61 @@ Rails.application.routes.draw do
|
|
518
562
|
end
|
519
563
|
```
|
520
564
|
|
565
|
+
### Avoid n+1s
|
566
|
+
|
567
|
+
Avoid introducing n+1 queries in your migration. Try to utilize the current data you have
|
568
|
+
to perform more meaningful queries, returning only the data needed for the migration.
|
569
|
+
|
570
|
+
```ruby
|
571
|
+
class AddRecentPostToUsersMigration < RequestMigrations::Migration
|
572
|
+
description %(adds :recent_post association to a collection of users)
|
573
|
+
|
574
|
+
# Bad (n+1)
|
575
|
+
migrate if: -> data { data in [*, { type: 'user' }, *] do |data|
|
576
|
+
data.each do |record|
|
577
|
+
case record
|
578
|
+
in type: 'user', id:
|
579
|
+
recent_post = Post.reorder(created_at: :desc)
|
580
|
+
.find_by(user_id: id)
|
581
|
+
|
582
|
+
record[:recent_post] = recent_post&.id
|
583
|
+
else
|
584
|
+
end
|
585
|
+
end
|
586
|
+
end
|
587
|
+
|
588
|
+
# Good
|
589
|
+
migrate if: -> data { data in [*, { type: 'user' }, *] do |data|
|
590
|
+
user_ids = data.collect { _1[:id] }
|
591
|
+
post_ids = Post.select(:id, :user_id)
|
592
|
+
.distinct_on(:user_id)
|
593
|
+
.where(user_id: user_ids)
|
594
|
+
.reorder(created_at: :desc)
|
595
|
+
.group_by(&:user_id)
|
596
|
+
|
597
|
+
data.each do |record|
|
598
|
+
case record
|
599
|
+
in type: 'user', id: user_id
|
600
|
+
record[:recent_post] = post_ids[user_id]&.id
|
601
|
+
else
|
602
|
+
end
|
603
|
+
end
|
604
|
+
end
|
605
|
+
|
606
|
+
response if: -> res { res.successful? && res.request.params in controller: 'api/v1/users',
|
607
|
+
action: 'index' } do |res|
|
608
|
+
data = JSON.parse(res.body, symbolize_names: true)
|
609
|
+
|
610
|
+
migrate!(data)
|
611
|
+
|
612
|
+
res.body = JSON.generate(data)
|
613
|
+
end
|
614
|
+
end
|
615
|
+
```
|
616
|
+
|
617
|
+
Instead of potentially tens or hundreds of queries, we make a single purposeful query
|
618
|
+
to get the data we need to complete the migration.
|
619
|
+
|
521
620
|
---
|
522
621
|
|
523
622
|
Have a tip of your own? Open a pull request!
|
@@ -4,10 +4,35 @@ module RequestMigrations
|
|
4
4
|
class Configuration
|
5
5
|
include ActiveSupport::Configurable
|
6
6
|
|
7
|
-
|
7
|
+
##
|
8
|
+
# logger defines the logger used by request_migrations.
|
9
|
+
#
|
10
|
+
# @return [Logger] the logger.
|
11
|
+
config_accessor(:logger) { Logger.new("/dev/null") }
|
12
|
+
|
13
|
+
##
|
14
|
+
# request_version_resolver defines how request_migrations should resolve the
|
15
|
+
# current version of a request.
|
16
|
+
#
|
17
|
+
# @return [Proc] the request version resolver.
|
8
18
|
config_accessor(:request_version_resolver) { -> req { self.current_version } }
|
9
|
-
|
10
|
-
|
11
|
-
|
19
|
+
|
20
|
+
##
|
21
|
+
# version_format defines the version format.
|
22
|
+
#
|
23
|
+
# @return [Symbol] format
|
24
|
+
config_accessor(:version_format) { :semver }
|
25
|
+
|
26
|
+
##
|
27
|
+
# current_version defines the latest version.
|
28
|
+
#
|
29
|
+
# @return [String, Integer, Float] the current version.
|
30
|
+
config_accessor(:current_version) { nil }
|
31
|
+
|
32
|
+
##
|
33
|
+
# versions defines past versions and their migrations.
|
34
|
+
#
|
35
|
+
# @return [Hash<String, Array<String, Integer, Float>>] past versions.
|
36
|
+
config_accessor(:versions) { [] }
|
12
37
|
end
|
13
38
|
end
|
@@ -2,6 +2,8 @@
|
|
2
2
|
|
3
3
|
module RequestMigrations
|
4
4
|
module Controller
|
5
|
+
##
|
6
|
+
# @private
|
5
7
|
class Migrator < Migrator
|
6
8
|
def initialize(request:, response:, **kwargs)
|
7
9
|
super(**kwargs)
|
@@ -35,9 +37,24 @@ module RequestMigrations
|
|
35
37
|
attr_accessor :request,
|
36
38
|
:response
|
37
39
|
|
38
|
-
def logger
|
40
|
+
def logger
|
41
|
+
if RequestMigrations.config.logger.respond_to?(:tagged)
|
42
|
+
RequestMigrations.logger.tagged(request&.request_id)
|
43
|
+
else
|
44
|
+
RequestMigrations.logger
|
45
|
+
end
|
46
|
+
end
|
39
47
|
end
|
40
48
|
|
49
|
+
##
|
50
|
+
# Migrations is controller middleware that automatically applies migrations.
|
51
|
+
#
|
52
|
+
# @example
|
53
|
+
# class ApplicationController < ActionController::API
|
54
|
+
# include RequestMigrations::Controller::Migrations
|
55
|
+
# end
|
56
|
+
#
|
57
|
+
# @raise [RequestMigrations::UnsupportedVersionError]
|
41
58
|
module Migrations
|
42
59
|
extend ActiveSupport::Concern
|
43
60
|
|
@@ -60,7 +77,7 @@ module RequestMigrations
|
|
60
77
|
extend ActiveSupport::Concern
|
61
78
|
|
62
79
|
included do
|
63
|
-
# TODO(ezekg) Implement controller-level version constraints
|
80
|
+
# TODO(ezekg) Implement controller-level version constraints.
|
64
81
|
end
|
65
82
|
end
|
66
83
|
end
|
@@ -2,6 +2,8 @@
|
|
2
2
|
|
3
3
|
module RequestMigrations
|
4
4
|
class Migration
|
5
|
+
##
|
6
|
+
# @private
|
5
7
|
class ConditionalBlock
|
6
8
|
def initialize(if: nil, &block)
|
7
9
|
@if = binding.local_variable_get(:if)
|
@@ -39,18 +41,40 @@ module RequestMigrations
|
|
39
41
|
klass.response_blocks = response_blocks.dup
|
40
42
|
end
|
41
43
|
|
44
|
+
##
|
45
|
+
# description sets the description.
|
46
|
+
#
|
47
|
+
# @param desc [String] the description
|
42
48
|
def description(desc)
|
43
49
|
self.description_value = desc
|
44
50
|
end
|
45
51
|
|
52
|
+
##
|
53
|
+
# request sets the request migration.
|
54
|
+
#
|
55
|
+
# @param if [Proc] the proc which determines if the migration should run.
|
56
|
+
#
|
57
|
+
# @yield [request] the block containing the migration.
|
46
58
|
def request(if: nil, &block)
|
47
59
|
self.request_blocks << ConditionalBlock.new(if:, &block)
|
48
60
|
end
|
49
61
|
|
62
|
+
##
|
63
|
+
# migrate sets the data migration.
|
64
|
+
#
|
65
|
+
# @param if [Proc] the proc which determines if the migration should run.
|
66
|
+
#
|
67
|
+
# @yield [data] the block containing the migration.
|
50
68
|
def migrate(if: nil, &block)
|
51
69
|
self.migration_blocks << ConditionalBlock.new(if:, &block)
|
52
70
|
end
|
53
71
|
|
72
|
+
##
|
73
|
+
# response sets the response migration.
|
74
|
+
#
|
75
|
+
# @param if [Proc] the proc which determines if the migration should run.
|
76
|
+
#
|
77
|
+
# @yield [response] the block containing the migration.
|
54
78
|
def response(if: nil, &block)
|
55
79
|
self.response_blocks << ConditionalBlock.new(if:, &block)
|
56
80
|
end
|
@@ -58,18 +82,24 @@ module RequestMigrations
|
|
58
82
|
|
59
83
|
extend DSL
|
60
84
|
|
85
|
+
##
|
86
|
+
# @private
|
61
87
|
def migrate_request!(request)
|
62
88
|
self.class.request_blocks.each { |block|
|
63
89
|
instance_exec(request) { block.call(self, _1) }
|
64
90
|
}
|
65
91
|
end
|
66
92
|
|
93
|
+
##
|
94
|
+
# @private
|
67
95
|
def migrate!(data)
|
68
96
|
self.class.migration_blocks.each { |block|
|
69
97
|
instance_exec(data) { block.call(self, _1) }
|
70
98
|
}
|
71
99
|
end
|
72
100
|
|
101
|
+
##
|
102
|
+
# @private
|
73
103
|
def migrate_response!(response)
|
74
104
|
self.class.response_blocks.each { |block|
|
75
105
|
instance_exec(response) { block.call(self, _1) }
|
@@ -2,11 +2,22 @@
|
|
2
2
|
|
3
3
|
module RequestMigrations
|
4
4
|
class Migrator
|
5
|
+
##
|
6
|
+
# Migrator represents a versioned migration from one version to another.
|
7
|
+
#
|
8
|
+
# @param from [String, Integer, Float] the current version.
|
9
|
+
# @param to [String, Integer, Float] the target version.
|
5
10
|
def initialize(from:, to:)
|
6
11
|
@current_version = Version.new(from)
|
7
12
|
@target_version = Version.new(to)
|
8
13
|
end
|
9
14
|
|
15
|
+
##
|
16
|
+
# migrate! attempts to apply all matching migrations on data.
|
17
|
+
#
|
18
|
+
# @param data [*] the data to be migrated.
|
19
|
+
#
|
20
|
+
# @return [*] the migrated data.
|
10
21
|
def migrate!(data:)
|
11
22
|
logger.debug { "Migrating from #{current_version} to #{target_version} (#{migrations.size} potential migrations)" }
|
12
23
|
|
@@ -26,7 +37,7 @@ module RequestMigrations
|
|
26
37
|
|
27
38
|
def logger = RequestMigrations.logger
|
28
39
|
|
29
|
-
# TODO(ezekg) These should be sorted
|
40
|
+
# TODO(ezekg) These should be sorted.
|
30
41
|
def migrations
|
31
42
|
@migrations ||=
|
32
43
|
RequestMigrations.config.versions
|
@@ -3,6 +3,8 @@
|
|
3
3
|
module RequestMigrations
|
4
4
|
module Router
|
5
5
|
module Constraints
|
6
|
+
##
|
7
|
+
# @private
|
6
8
|
class VersionConstraint
|
7
9
|
def initialize(constraint:)
|
8
10
|
@constraint = Version::Constraint.new(constraint)
|
@@ -19,6 +21,20 @@ module RequestMigrations
|
|
19
21
|
def resolver = RequestMigrations.config.request_version_resolver
|
20
22
|
end
|
21
23
|
|
24
|
+
##
|
25
|
+
# version_constraint is a router constraint that resolves routes for
|
26
|
+
# specific versions.
|
27
|
+
#
|
28
|
+
# @param constraint [String] the version constraint.
|
29
|
+
#
|
30
|
+
# @yield the block when the constraint is satisfied.
|
31
|
+
#
|
32
|
+
# @example
|
33
|
+
# Rails.application.routes.draw do
|
34
|
+
# version_constraint '> 1.0' do
|
35
|
+
# resources :some_new_resource
|
36
|
+
# end
|
37
|
+
# end
|
22
38
|
def version_constraint(constraint, &)
|
23
39
|
constraints VersionConstraint.new(constraint:) do
|
24
40
|
instance_eval(&)
|
data/lib/request_migrations.rb
CHANGED
@@ -2,6 +2,7 @@
|
|
2
2
|
|
3
3
|
require "active_support/concern"
|
4
4
|
require "semverse"
|
5
|
+
require "logger"
|
5
6
|
require "request_migrations/gem"
|
6
7
|
require "request_migrations/configuration"
|
7
8
|
require "request_migrations/version"
|
@@ -12,20 +13,60 @@ require "request_migrations/router"
|
|
12
13
|
require "request_migrations/railtie"
|
13
14
|
|
14
15
|
module RequestMigrations
|
16
|
+
##
|
17
|
+
# UnsupportedMigrationError is raised when an invalid migration is provided.
|
15
18
|
class UnsupportedMigrationError < StandardError; end
|
19
|
+
|
20
|
+
##
|
21
|
+
# InvalidVersionFormatError is raised when an badly formatted version is provided.
|
16
22
|
class InvalidVersionFormatError < StandardError; end
|
23
|
+
|
24
|
+
##
|
25
|
+
# UnsupportedVersionError is raised when an unsupported version is requested.
|
17
26
|
class UnsupportedVersionError < StandardError; end
|
27
|
+
|
28
|
+
##
|
29
|
+
# InvalidVersionError is raised when an invalid version is provided.
|
18
30
|
class InvalidVersionError < StandardError; end
|
19
31
|
|
20
|
-
|
32
|
+
##
|
33
|
+
# SUPPORTED_VERSION_FORMATS is a list of supported version formats.
|
34
|
+
SUPPORTED_VERSION_FORMATS = %i[
|
35
|
+
semver
|
36
|
+
date
|
37
|
+
float
|
38
|
+
integer
|
39
|
+
string
|
40
|
+
].freeze
|
21
41
|
|
22
|
-
|
42
|
+
##
|
43
|
+
# config returns the current config.
|
23
44
|
def self.config = @config ||= Configuration.new
|
24
45
|
|
46
|
+
##
|
47
|
+
# logger returns the configured logger.
|
48
|
+
#
|
49
|
+
# @return [Logger]
|
50
|
+
def self.logger
|
51
|
+
@logger ||= if RequestMigrations.config.logger.respond_to?(:tagged)
|
52
|
+
RequestMigrations.config.logger.tagged(:request_migrations)
|
53
|
+
else
|
54
|
+
RequestMigrations.config.logger
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
##
|
59
|
+
# configure yields the config.
|
60
|
+
#
|
61
|
+
# @yield [config]
|
25
62
|
def self.configure
|
26
63
|
yield config
|
27
64
|
end
|
28
65
|
|
66
|
+
##
|
67
|
+
# supported_versions returns an array of supported versions.
|
68
|
+
#
|
69
|
+
# @return [Array<String, Integer, Float>]
|
29
70
|
def self.supported_versions
|
30
71
|
[RequestMigrations.config.current_version, *RequestMigrations.config.versions.keys].uniq.freeze
|
31
72
|
end
|