request_migrations 0.1.0 → 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|