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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a07f06f1ea7b2b51851ff1490e1233363bc4d13bc6ac8918e7c1d886c9497ffd
4
- data.tar.gz: 79f9bb8c317e906b4d4d6992811156103265d902f82ca0fcc1ac65a6e85327de
3
+ metadata.gz: 03674ecf6a2120accd4fcbbbe4add7f73a94098e08882a65630085b3dcc38ecd
4
+ data.tar.gz: 38b32703419f8857737fc0b8fa61d5ada954f69c201247892441291b901ed0fb
5
5
  SHA512:
6
- metadata.gz: 595d53c2b5d25dd34d4212e9db9208b96dccf684ec03e4cd75fa485d5421c400cc0b465e4914d7613a3ef15c08757b93ce163cebf1cb886dccf14e7986ca16bb
7
- data.tar.gz: 95cd329eee002333f976886c2394a538dc1f3e6231f093a4d7155b9330eaf0725dc9ed38e31c6ccc0d9e7fdeb6a32b7f54970dc6114064b6286b96888b0be4d2
6
+ metadata.gz: ed9e6c8e9cf08938177b687496f83e58e7ecbf0a35cda2f9f84428f83acff381c882d33f53e2ba3bc51b0d533f577f7a93bc1c9fc2533a57d79bd479a80a550f
7
+ data.tar.gz: 36cc532f949768e70bf1582a9c652db79e5900811ebe0a4e08bd9a4840442b346c6c93cedd0d6d4c6d2d39bf6f487c321dd4d731c8f8533fa7d64ff65954dd85
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0
4
+
5
+ - Initial release.
6
+
3
7
  ## 0.1.0
4
8
 
5
9
  - Test release.
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[combine_user_names_migration],
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. Must be a tagged logger. Defaults to
328
- # using Rails.logger.
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
- ### Tips and tricks
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
- #### Use pattern matching
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
- #### Route helpers
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
- #### Separate by shape
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
- #### Always check response status
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
- #### Don't match on URL pattern
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
- #### Namespace deprecated controllers
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
- #### Avoid routing contraints
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
- config_accessor(:logger) { Rails.logger }
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
- config_accessor(:version_format) { :semver }
10
- config_accessor(:current_version) { nil }
11
- config_accessor(:versions) { [] }
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 = RequestMigrations.logger.tagged(request&.request_id)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RequestMigrations
4
- VERSION = "0.1.0"
4
+ VERSION = "1.0.0"
5
5
  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(&)
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RequestMigrations
4
+ ##
5
+ # @private
4
6
  class Version
5
7
  include Comparable
6
8
 
@@ -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
- SUPPORTED_VERSION_FORMATS = %i[semver date float integer string].freeze
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
- def self.logger = @logger ||= RequestMigrations.config.logger.tagged(:request_migrations)
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: request_migrations
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Zeke Gabrielse