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 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