rails_outbox 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1d94b1bb0518502cdc2f533c9b2c62f74cba24977608ff9461bf26dcc199ae32
4
+ data.tar.gz: 0a391ea6f13216b6625ef7948356ff8e2a61d312b88670c9b5106708169de526
5
+ SHA512:
6
+ metadata.gz: 66ffbab3bf48d09fd3b9013487da810f8b66da36c0b0fa6fd284027437127a07b8b322a27f2c4a602f2be3d0a1b11d425ca3d6e3ac3bf3beae837000b1d0fb8c
7
+ data.tar.gz: 93488c76456fd42a6dc344eecf836c42d60013bfa80e72baa5eb17cb3c18b75c4ad88cacf2e41965e020de9f7ad3832c4ea34253f001ee60cc78b32c852701f4
data/README.md ADDED
@@ -0,0 +1,110 @@
1
+ # Rails Outbox
2
+ A Transactional Outbox implementation for Rails and ActiveRecord.
3
+
4
+ ![transactional outbox pattern](./docs/images/transactional_outbox.png)
5
+
6
+ This gem aims to implement the event persistance side of the pattern, focusing only on providing a seamless way to store Outbox records whenever a change occurs on a given model (#1 in the diagram).
7
+ We do not provide an event publisher, nor a consumer as a part of this gem since the idea is to keep it as light weight as possible.
8
+
9
+ ## Motivation
10
+ If you find yourself repeatedly defining a transaction block every time you need to persist an event, it might be a sign that something needs improvement. We believe that adopting a pattern should enhance your workflow, not hinder it. Creating, updating or destroying a record should remain a familiar and smooth process.
11
+
12
+ Our primary objective is to ensure a seamless experience without imposing our own opinions or previous experiences. That's why this gem exclusively focuses on persisting records. We leave the other aspects of the pattern entirely open for your customization. You can emit these events using Sidekiq jobs, or explore more sophisticated solutions like Kafka Connect.
13
+
14
+ ## Why rails_outbox?
15
+ - Seamless integration with ActiveRecord
16
+ - CRUD events out of the box
17
+ - Ability to set custom events
18
+ - Test helpers to easily check Outbox records are being created correctly
19
+ - Customizable
20
+
21
+ ## Installation
22
+
23
+ Add this line to your application's Gemfile:
24
+
25
+ ```ruby
26
+ gem 'rails_outbox'
27
+ ```
28
+
29
+ And then execute:
30
+ ```bash
31
+ bundle install
32
+ ```
33
+ Or install it yourself as:
34
+ ```bash
35
+ gem install rails_outbox
36
+ ```
37
+
38
+ ## Usage
39
+ ### Setup
40
+ Create the outbox table and model using the provided generator. Any model name can be passed as an argument but if empty it will default to `outboxes` and `Outbox` respectively.
41
+ ```bash
42
+ rails g rails_outbox:model <optional model_name>
43
+
44
+ create db/migrate/20231115182800_rails_outbox_create_<model_name_>outboxes.rb
45
+ create app/models/<model_name_>outbox.rb
46
+ ```
47
+ After running the migration, create an initializer under `config/initializers/rails_outbox.rb` and setup the default outbox class to the new `Outbox` model you just created.
48
+ ```bash
49
+ rails g rails_outbox:install
50
+ ```
51
+
52
+ To allow models to store Outbox records on changes, you will have to include the `Outboxable` concern.
53
+ ```ruby
54
+ # app/models/user.rb
55
+
56
+ class User < ApplicationRecord
57
+ include RailsOutbox::Outboxable
58
+ end
59
+ ```
60
+ ### Base Events
61
+ Using the User model as an example, the default event names provided are:
62
+ - USER_CREATED
63
+ - USER_UPDATED
64
+ - USER_DESTROYED
65
+
66
+ This will live under `RailsOutbox::Events` wherever you include the `Outboxable` concern. The intent is to define it under `Object` for non-namespaced models, as well as under each model namespace that is encountered.
67
+
68
+ ### Custom Events
69
+ If you want to persist a custom event other than the provided base events, you can do so.
70
+ ```ruby
71
+ user.save(outbox_event: 'YOUR_CUSTOM_EVENT')
72
+ ```
73
+ ## Advanced Usage
74
+ ### Supporting UUIDs
75
+ By default our Outbox migration has an `aggregate_identifier` field which serves the purpose of identifying which record was involved in the event emission. We default to integer IDs, but if you're using UUIDs as a primary key for your records you have to adjust the migrations accordingly. To do so just run the model generator with the `--uuid` flag.
76
+ ```bash
77
+ rails g rails_outbox:model <optional model_name> --uuid
78
+ ```
79
+ ### Modularized Outbox Mappings
80
+ If more granularity is desired multiple outbox classes can be configured. Using the provided generators we can specify namespaces and the folder structure.
81
+ ```bash
82
+ rails g rails_outbox:model user_access/ --component-path packs/user_access
83
+
84
+ create packs/user_access/db/migrate/20231115181205_rails_outbox_create_user_access_outboxes.rb
85
+ create packs/user_access/app/models/user_access/outbox.rb
86
+ ```
87
+ After creating the needed `Outbox` classes for each module you can specify multiple mappings in the initializer.
88
+ ```ruby
89
+ # frozen_string_literal: true
90
+
91
+ Rails.application.reloader.to_prepare do
92
+ RailsOutbox.configure do |config|
93
+ config.outbox_mapping = {
94
+ 'member' => 'Member::Outbox',
95
+ 'user_access' => 'UserAccess::Outbox'
96
+ }
97
+ end
98
+ end
99
+ ```
100
+ ## Contributing
101
+
102
+ Bug reports and pull requests are welcome on GitHub at https://github.com/guillermoap/rails_outbox. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/guillermoap/rails_outbox/blob/main/CODE_OF_CONDUCT.md).
103
+
104
+ ## License
105
+
106
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/license/mit/).
107
+
108
+ ## Code of Conduct
109
+
110
+ Everyone interacting in the RailsOutbox project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/guillermoap/rails_outbox/blob/main/CODE_OF_CONDUCT.md).
data/bin/outbox ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'rails'
5
+ require 'rails_outbox'
6
+
7
+ rails g outbox ARGV[0]
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators/base'
4
+
5
+ module RailsOutbox
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ source_root File.expand_path('../templates', __dir__)
9
+
10
+ desc 'Creates an initializer file at config/initializers/rails_outbox.rb'
11
+
12
+ def create_initializer_file
13
+ copy_file('initializer.rb', Rails.root.join('config', 'initializers', 'rails_outbox.rb'))
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails'
4
+ require 'rails/generators/active_record'
5
+ require 'rails/generators/base'
6
+ require 'rails/generators/migration'
7
+
8
+ module RailsOutbox
9
+ module Generators
10
+ class ModelGenerator < Rails::Generators::Base
11
+ source_root File.expand_path('../templates', __dir__)
12
+
13
+ include RailsOutbox::AdapterHelper
14
+ include Rails::Generators::Migration
15
+
16
+ class << self
17
+ delegate :next_migration_number, to: ActiveRecord::Generators::Base
18
+ end
19
+
20
+ desc 'Creates the Outbox model migration'
21
+
22
+ argument :model_name, type: :string, default: ''
23
+ class_option :component_path,
24
+ type: :string,
25
+ desc: 'Indicates where to create the outbox migration'
26
+ class_option :uuid,
27
+ type: :boolean,
28
+ default: false,
29
+ desc: 'Use UUID to identify aggregate records in events. Defaults to ID'
30
+
31
+ def create_migration_file
32
+ migration_path = "#{root_path}/db/migrate"
33
+ migration_template(
34
+ 'migration.rb',
35
+ "#{migration_path}/rails_outbox_create_#{table_name}.rb",
36
+ migration_version: migration_version
37
+ )
38
+
39
+ template('model.rb', "#{root_path}/app/models/#{path_name}.rb")
40
+ end
41
+
42
+ def root_path
43
+ path = options['component_path'].blank? ? '' : "/#{options['component_path']}"
44
+ "#{Rails.root}#{path}"
45
+ end
46
+
47
+ def migration_version
48
+ "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
49
+ end
50
+
51
+ def table_name
52
+ *namespace, name = model_name.downcase.split('/')
53
+ name = name.blank? ? 'outboxes' : "#{name}_outboxes"
54
+ namespace = namespace.join('_')
55
+ namespace.blank? ? name : "#{namespace}_#{name}"
56
+ end
57
+
58
+ def path_name
59
+ name = ''
60
+ *namespace = model_name.downcase.split('/')
61
+ if (model_name.include?('/') && model_name.last != '/' && namespace.length > 1) || !model_name.include?('/')
62
+ name = namespace.pop
63
+ end
64
+ name = name.blank? ? 'outbox' : "#{name}_outbox"
65
+ namespace = namespace.join('/')
66
+ namespace.blank? ? name : "#{namespace}/#{name}"
67
+ end
68
+
69
+ def aggregate_identifier_type
70
+ options['uuid'].present? ? RailsOutbox::AdapterHelper.uuid_type : RailsOutbox::AdapterHelper.bigint_type
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ Rails.application.reloader.to_prepare do
4
+ RailsOutbox.configure do |config|
5
+ # To configure which Outbox class maps to which domain
6
+ # See https://github.com/guillermoap/rails_outbox#advanced-usage for advanced examples
7
+ config.outbox_mapping = {
8
+ 'default' => 'Outbox'
9
+ }
10
+
11
+ # Configure database adapter
12
+ # config.adapter = :postgresql
13
+ end
14
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RailsOutboxCreate<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ create_table :<%= table_name %> do |t|
6
+ t.<%= RailsOutbox::AdapterHelper.uuid_type %> :identifier, null: false, index: { unique: true }
7
+ t.string :event, null: false
8
+ t.<%= RailsOutbox::AdapterHelper.json_type %> :payload
9
+ t.string :aggregate, null: false
10
+ t.<%= aggregate_identifier_type %> :aggregate_identifier, null: false, index: true
11
+
12
+ t.timestamps
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= path_name.camelize %> < ApplicationRecord
4
+ validates_presence_of :identifier, :payload, :aggregate, :aggregate_identifier, :event
5
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsOutbox
4
+ module AdapterHelper
5
+ def self.uuid_type
6
+ return 'uuid' if postgres?
7
+ return 'string' if mysql?
8
+
9
+ 'string'
10
+ end
11
+
12
+ def self.json_type
13
+ return 'jsonb' if postgres?
14
+ return 'json' if mysql?
15
+
16
+ 'string'
17
+ end
18
+
19
+ def self.bigint_type
20
+ return 'bigint' if postgres? || mysql?
21
+
22
+ 'integer'
23
+ end
24
+
25
+ def self.postgres?
26
+ ActiveRecord::Base.connection.adapter_name.downcase == 'postgresql'
27
+ end
28
+
29
+ def self.mysql?
30
+ ActiveRecord::Base.connection.adapter_name.downcase == 'mysql2'
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsOutbox
4
+ class OutboxConfigurationError < StandardError; end
5
+
6
+ class OutboxClassNotFoundError < OutboxConfigurationError
7
+ def message
8
+ <<~MESSAGE
9
+ Missing Outbox class definition. Configure mapping in `config/initializers/rails_outbox.rb`:
10
+
11
+ Rails.application.reloader.to_prepare do
12
+ RailsOutbox.configure do |config|
13
+ config.outbox_mapping = {
14
+ 'default' => <outbox model name>
15
+ }
16
+ end
17
+ end
18
+ MESSAGE
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ module RailsOutbox
6
+ module Outboxable
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ *namespace, klass = name.underscore.upcase.split('/')
11
+ namespace = namespace.reverse.join('.')
12
+
13
+ module_parent.const_set('RailsOutbox', Module.new) unless module_parent.const_defined?('RailsOutbox', false)
14
+
15
+ unless module_parent::RailsOutbox.const_defined?('Events', false)
16
+ module_parent::RailsOutbox.const_set('Events', Module.new)
17
+ end
18
+
19
+ { create: 'CREATED', update: 'UPDATED', destroy: 'DESTROYED' }.each do |key, value|
20
+ const_name = "#{klass}_#{value}"
21
+
22
+ unless module_parent::RailsOutbox::Events.const_defined?(const_name)
23
+ module_parent::RailsOutbox::Events.const_set(const_name,
24
+ "#{const_name}#{namespace.blank? ? '' : '.'}#{namespace}")
25
+ end
26
+
27
+ event_name = module_parent::RailsOutbox::Events.const_get(const_name)
28
+
29
+ send("after_#{key}") { create_outbox!(key, event_name) }
30
+ end
31
+ end
32
+
33
+ def save(**options, &block)
34
+ assign_outbox_event(options)
35
+ super(**options, &block)
36
+ end
37
+
38
+ def save!(**options, &block)
39
+ assign_outbox_event(options)
40
+ super(**options, &block)
41
+ end
42
+
43
+ private
44
+
45
+ def assign_outbox_event(options)
46
+ @outbox_event = options[:outbox_event].underscore.upcase if options[:outbox_event].present?
47
+ end
48
+
49
+ def create_outbox!(action, event_name)
50
+ outbox = outbox_model.new(
51
+ aggregate: self.class.name,
52
+ aggregate_identifier: send(self.class.primary_key),
53
+ event: @outbox_event || event_name,
54
+ identifier: SecureRandom.uuid,
55
+ payload: formatted_payload(action)
56
+ )
57
+ @outbox_event = nil
58
+ handle_outbox_errors(outbox) if outbox.invalid?
59
+ outbox.save!
60
+ end
61
+
62
+ def outbox_model
63
+ module_parent = self.class.module_parent
64
+ # sets _inherit_ option to false so it doesn't lookup in ancestors for the constant
65
+ unless module_parent.const_defined?('OUTBOX_MODEL', false)
66
+ outbox_model = outbox_model_name!.safe_constantize
67
+ module_parent.const_set('OUTBOX_MODEL', outbox_model)
68
+ end
69
+
70
+ module_parent.const_get('OUTBOX_MODEL')
71
+ end
72
+
73
+ def outbox_model_name!
74
+ namespace_outbox_mapping || default_outbox_mapping || raise(OutboxClassNotFoundError)
75
+ end
76
+
77
+ def namespace_outbox_mapping
78
+ namespace = self.class.module_parent.name.underscore
79
+
80
+ RailsOutbox.config.outbox_mapping[namespace]
81
+ end
82
+
83
+ def default_outbox_mapping
84
+ RailsOutbox.config.outbox_mapping['default']
85
+ end
86
+
87
+ def handle_outbox_errors(outbox)
88
+ outbox.errors.each do |error|
89
+ errors.import(error, attribute: "outbox.#{error.attribute}")
90
+ end
91
+ end
92
+
93
+ def formatted_payload(action)
94
+ payload = construct_payload(action)
95
+ AdapterHelper.postgres? ? payload : payload.to_json
96
+ end
97
+
98
+ def construct_payload(action)
99
+ case action
100
+ when :create
101
+ { before: nil, after: as_json }
102
+ when :update
103
+ changes = previous_changes.transform_values(&:first)
104
+ { before: as_json.merge(changes), after: as_json }
105
+ when :destroy
106
+ { before: as_json, after: nil }
107
+ else
108
+ raise ActiveRecord::RecordNotSaved.new(
109
+ "Failed to create Outbox payload for #{self.class.name}: #{send(self.class.primary_key)}",
110
+ self
111
+ )
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsOutbox
4
+ class Railtie < Rails::Railtie
5
+ end
6
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails_outbox/adapter_helper'
4
+ require 'rails_outbox/errors'
5
+ require 'rails_outbox/outboxable'
6
+ require 'rails_outbox/railtie' if defined?(Rails::Railtie)
7
+ require 'dry-configurable'
8
+
9
+ module RailsOutbox
10
+ extend Dry::Configurable
11
+
12
+ setting :adapter, default: :sqlite
13
+ setting :outbox_mapping, default: {}
14
+ end
metadata ADDED
@@ -0,0 +1,83 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails_outbox
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Guillermo Aguirre
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-09-06 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: dry-configurable
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rails
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '6.1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '6.1'
41
+ description:
42
+ email: guillermoaguirre@hey.com
43
+ executables:
44
+ - outbox
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - README.md
49
+ - bin/outbox
50
+ - lib/generators/rails_outbox/install/install_generator.rb
51
+ - lib/generators/rails_outbox/model/model_generator.rb
52
+ - lib/generators/rails_outbox/templates/initializer.rb
53
+ - lib/generators/rails_outbox/templates/migration.rb
54
+ - lib/generators/rails_outbox/templates/model.rb
55
+ - lib/rails_outbox.rb
56
+ - lib/rails_outbox/adapter_helper.rb
57
+ - lib/rails_outbox/errors.rb
58
+ - lib/rails_outbox/outboxable.rb
59
+ - lib/rails_outbox/railtie.rb
60
+ homepage: https://github.com/guillermoap/rails_outbox
61
+ licenses:
62
+ - MIT
63
+ metadata: {}
64
+ post_install_message:
65
+ rdoc_options: []
66
+ require_paths:
67
+ - lib
68
+ required_ruby_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '3.0'
73
+ required_rubygems_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ requirements: []
79
+ rubygems_version: 3.5.16
80
+ signing_key:
81
+ specification_version: 4
82
+ summary: A Transactional Outbox implementation for ActiveRecord and Rails
83
+ test_files: []