db_blaster 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (34) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +46 -0
  4. data/Rakefile +20 -0
  5. data/app/assets/config/db_blaster_manifest.js +1 -0
  6. data/app/assets/stylesheets/db_blaster/application.css +15 -0
  7. data/app/controllers/db_blaster/application_controller.rb +6 -0
  8. data/app/helpers/db_blaster/application_helper.rb +7 -0
  9. data/app/jobs/db_blaster/application_job.rb +7 -0
  10. data/app/jobs/db_blaster/publish_all_job.rb +20 -0
  11. data/app/jobs/db_blaster/publish_source_table_job.rb +16 -0
  12. data/app/models/concerns/db_blaster/sync.rb +21 -0
  13. data/app/models/db_blaster/application_record.rb +8 -0
  14. data/app/models/db_blaster/source_table.rb +10 -0
  15. data/config/brakeman.ignore +26 -0
  16. data/config/routes.rb +4 -0
  17. data/db/migrate/20210727222252_create_source_tables.rb +14 -0
  18. data/lib/db_blaster.rb +28 -0
  19. data/lib/db_blaster/available_tables.rb +12 -0
  20. data/lib/db_blaster/chunker.rb +77 -0
  21. data/lib/db_blaster/configuration.rb +58 -0
  22. data/lib/db_blaster/engine.rb +12 -0
  23. data/lib/db_blaster/finder.rb +70 -0
  24. data/lib/db_blaster/one_record_too_large_error.rb +30 -0
  25. data/lib/db_blaster/publish_source_table.rb +30 -0
  26. data/lib/db_blaster/publisher.rb +42 -0
  27. data/lib/db_blaster/rspec.rb +9 -0
  28. data/lib/db_blaster/source_table_configuration.rb +19 -0
  29. data/lib/db_blaster/source_table_configuration_builder.rb +55 -0
  30. data/lib/db_blaster/version.rb +5 -0
  31. data/lib/generators/db_blaster/install/install_generator.rb +15 -0
  32. data/lib/generators/db_blaster/install/templates/db_blaster_config.rb +37 -0
  33. data/lib/tasks/db_blaster_tasks.rake +5 -0
  34. metadata +162 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e8d31a519addfa74e6a2ca3c2d41f58b05e2b4551802948b5a4dc0fe20278dca
4
+ data.tar.gz: 33a1e76574a2b8e70c681beebf544af587ff143252b08d0dd1a8dbc0b3615864
5
+ SHA512:
6
+ metadata.gz: 378969c9223acf49c50a055b566b1f3f2bff3a0b032bbc9e1aebbb64b5031c9d1a2af6c539d55efc7e90a8254d0ca4cf413be762ab41dbdd1fd4e01efd723d33
7
+ data.tar.gz: afabd1c77490c8c80bdd1d0a49965026ae69a77d727ffe7da475c5683391dafc4cd9f91d7880ecc5c0818a4c5afeacd4403844a198f932c0c4d448b0c53cb2dc
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2021 Perry Hertler
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,46 @@
1
+ ![Coverage Badge](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/perryqh/be2fa5413124206272dbc700f3201f5a/raw/db_blaster__master.json)
2
+ # DbBlaster
3
+ ![Image of DB to SNS](https://lucid.app/publicSegments/view/c70feed3-2f48-46ee-8734-423474488feb/image.png)
4
+
5
+ DbBlaster publishes changed database rows to AWS SNS. The first time `DbBlaster::PublishAllJob.perform_later` is ran,
6
+ the entire database will be incrementally published to SNS. Subsequent runs will publish rows whose `updated_at` column
7
+ is more recent than the last run.
8
+
9
+ Consuming the published messages is functionality not provided by DbBlaster.
10
+
11
+ ## Usage
12
+
13
+ Update `config/initializers/db_blaster_config.rb` with valid AWS credentials, topics, and options.
14
+
15
+ Schedule `DbBlaster::PublishAllJob.perform_later` to run periodically with something
16
+ like [sidekiq-cron](https://github.com/ondrejbartas/sidekiq-cron) or [whenever](https://github.com/javan/whenever)
17
+
18
+ ## Installation
19
+
20
+ Add this line to your application's Gemfile:
21
+
22
+ ```ruby
23
+ gem 'db_blaster'
24
+ ```
25
+
26
+ And then execute:
27
+
28
+ ```bash
29
+ $ bundle
30
+ ```
31
+
32
+ Install Migrations:
33
+
34
+ ```bash
35
+ $ rake db_blaster:install:migrations && rake db:migrate
36
+ ```
37
+
38
+ Copy sample config file to rails project:
39
+
40
+ ```bash
41
+ rails g db_blaster:install
42
+ ```
43
+
44
+ ## License
45
+
46
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+
5
+ APP_RAKEFILE = File.expand_path('spec/dummy/Rakefile', __dir__)
6
+ load 'rails/tasks/engine.rake'
7
+
8
+ load 'rails/tasks/statistics.rake'
9
+
10
+ require 'bundler/gem_tasks'
11
+
12
+ begin
13
+ require 'rspec/core/rake_task'
14
+
15
+ RSpec::Core::RakeTask.new(:spec)
16
+
17
+ task default: :spec
18
+ rescue LoadError
19
+ # no rspec available
20
+ end
@@ -0,0 +1 @@
1
+ //= link_directory ../stylesheets/db_blaster .css
@@ -0,0 +1,15 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DbBlaster
4
+ class ApplicationController < ActionController::Base
5
+ end
6
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DbBlaster
4
+ # Helper methods for views
5
+ module ApplicationHelper
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DbBlaster
4
+ # Base ActiveJob
5
+ class ApplicationJob < ActiveJob::Base
6
+ end
7
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ActiveJob
4
+ # 1) sync configuration with DbBlaster::SourceTable
5
+ # 2) Enqueue PublishSourceTableJob for every source_table
6
+ module DbBlaster
7
+ # Enqueues PublishSourceTableJob for every source-table
8
+ class PublishAllJob < ApplicationJob
9
+ queue_as 'default'
10
+
11
+ def perform
12
+ SourceTable.sync(SourceTableConfigurationBuilder
13
+ .build_all(DbBlaster.configuration))
14
+
15
+ DbBlaster::SourceTable.pluck(:id).each do |source_table_id|
16
+ PublishSourceTableJob.perform_later(source_table_id)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Executes PublishSourceTable for provided `source_table_id`
4
+ module DbBlaster
5
+ # Publishes changed rows to SNS
6
+ class PublishSourceTableJob < ApplicationJob
7
+ queue_as 'default'
8
+
9
+ def perform(source_table_id)
10
+ source_table = SourceTable.find_by(id: source_table_id)
11
+ return unless source_table
12
+
13
+ PublishSourceTable.execute(source_table)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DbBlaster
4
+ # Syncs tables derived from SourceTableConfigurationBuilder
5
+ # with SourceTable rows
6
+ module Sync
7
+ extend ActiveSupport::Concern
8
+
9
+ class_methods do
10
+ # Syncs configuration tables with db
11
+ def sync(table_configs)
12
+ SourceTable.where.not(name: table_configs.collect(&:source_table_name)).delete_all
13
+
14
+ table_configs.each do |config|
15
+ source_table = SourceTable.where(name: config.source_table_name).first_or_create
16
+ source_table.update!(config.update_params)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DbBlaster
4
+ # Base class for models
5
+ class ApplicationRecord < ActiveRecord::Base
6
+ self.abstract_class = true
7
+ end
8
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DbBlaster
4
+ # Represents tables that should be synced
5
+ class SourceTable < ApplicationRecord
6
+ include Sync
7
+
8
+ validates :name, uniqueness: { case_sensitive: false }, presence: true
9
+ end
10
+ end
@@ -0,0 +1,26 @@
1
+ {
2
+ "ignored_warnings": [
3
+ {
4
+ "warning_type": "SQL Injection",
5
+ "warning_code": 0,
6
+ "fingerprint": "6f4d3da0707c3f5f5c5bf5a002a254fee246210248aafa655cb2f15adfb47aa7",
7
+ "check_name": "SQL",
8
+ "message": "Possible SQL injection",
9
+ "file": "lib/db_blaster/finder.rb",
10
+ "line": 38,
11
+ "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
12
+ "code": "ActiveRecord::Base.connection.execute(\"#{select_sql} OFFSET #{offset}\")",
13
+ "render_path": null,
14
+ "location": {
15
+ "type": "method",
16
+ "class": "DbBlaster::Finder",
17
+ "method": "find_records_in_batches"
18
+ },
19
+ "user_input": "select_sql",
20
+ "confidence": "Medium",
21
+ "note": "No SQL injection can occur"
22
+ }
23
+ ],
24
+ "updated": "2021-08-09 11:03:06 -0600",
25
+ "brakeman_version": "5.1.1"
26
+ }
data/config/routes.rb ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # DbBlaster::Engine.routes.draw do
4
+ # end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Create db_blaster_source_tables
4
+ class CreateSourceTables < ActiveRecord::Migration[6.1]
5
+ def change
6
+ create_table :db_blaster_source_tables do |t|
7
+ t.string :name, null: false
8
+ t.text :ignored_columns, array: true, default: []
9
+ t.datetime :last_published_updated_at # the most recent published `updated_at`
10
+ t.integer :batch_size, default: 100
11
+ t.timestamps
12
+ end
13
+ end
14
+ end
data/lib/db_blaster.rb ADDED
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aws-sdk-sns'
4
+ require 'db_blaster/version'
5
+ require 'db_blaster/engine'
6
+ require 'db_blaster/one_record_too_large_error'
7
+ require 'db_blaster/available_tables'
8
+ require 'db_blaster/configuration'
9
+ require 'db_blaster/source_table_configuration'
10
+ require 'db_blaster/source_table_configuration_builder'
11
+ require 'db_blaster/publisher'
12
+ require 'db_blaster/publish_source_table'
13
+ require 'db_blaster/chunker'
14
+ require 'db_blaster/finder'
15
+
16
+ # Top-level module that serves as an entry point
17
+ # into the engine gem
18
+ module DbBlaster
19
+ class << self
20
+ def configuration
21
+ @configuration ||= Configuration.new
22
+ end
23
+
24
+ def configure
25
+ yield(configuration)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DbBlaster
4
+ # Finds all tables in current database and removes the "system tables"
5
+ module AvailableTables
6
+ SYSTEM_TABLES = %w[schema_migrations ar_internal_metadata].freeze
7
+
8
+ def available_tables
9
+ @available_tables ||= ActiveRecord::Base.connection.tables - SYSTEM_TABLES
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DbBlaster
4
+ # Chunk the records into sizes < Configuration.max_message_size_in_kilobytes
5
+ # yielding the chunks inline to the provided block
6
+ # If the records' size is already less than Configuration.max_message_size_in_kilobytes,
7
+ # all the records are yielded to the provided block
8
+ class Chunker
9
+ attr_reader :source_table, :records, :block_on_chunk, :current_chunk, :current_chunk_size
10
+
11
+ def initialize(source_table, records, &block_on_chunk)
12
+ @source_table = source_table
13
+ @records = records
14
+ @block_on_chunk = block_on_chunk
15
+ @current_chunk_size = 0
16
+ @current_chunk = []
17
+ end
18
+
19
+ def self.chunk(source_table, records, &block)
20
+ new(source_table, records, &block).chunk
21
+ end
22
+
23
+ def chunk
24
+ return if yield_if_records_acceptable?
25
+
26
+ breakup_records
27
+ end
28
+
29
+ def yield_if_records_acceptable?
30
+ return if records.to_json.size >= max_bytes
31
+
32
+ block_on_chunk.call(records)
33
+ true
34
+ end
35
+
36
+ def max_bytes
37
+ @max_bytes ||= 1000 * max_kilobytes
38
+ end
39
+
40
+ def max_kilobytes
41
+ DbBlaster.configuration.max_message_size_in_kilobytes ||
42
+ DbBlaster.configuration.class::DEFAULT_MAX_MESSAGE_SIZE_IN_KILOBYTES
43
+ end
44
+
45
+ def breakup_records
46
+ records.each(&method(:process_record))
47
+ block_on_chunk.call(current_chunk) if current_chunk.length.positive?
48
+ end
49
+
50
+ private
51
+
52
+ def process_record(record)
53
+ record_size = record.to_json.size
54
+ @current_chunk_size += record_size
55
+ if current_chunk_size < max_bytes
56
+ current_chunk << record
57
+ elsif record_size >= max_bytes
58
+ blow_up_one_record_too_large(record)
59
+ else
60
+ yield_chunk(record, record_size)
61
+ end
62
+ end
63
+
64
+ def yield_chunk(record, record_size)
65
+ block_on_chunk.call(current_chunk)
66
+ @current_chunk_size = record_size
67
+ @current_chunk = [record]
68
+ end
69
+
70
+ def blow_up_one_record_too_large(record)
71
+ block_on_chunk.call(current_chunk) if current_chunk.length.positive?
72
+ raise OneRecordTooLargeError.new(source_table: source_table,
73
+ record: record,
74
+ max_kilobytes: max_kilobytes)
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DbBlaster
4
+ # Configuration class for providing credentials, topics, and customizations.
5
+ class Configuration
6
+ DEFAULT_BATCH_SIZE = 100
7
+ DEFAULT_MAX_MESSAGE_SIZE_IN_KILOBYTES = 256 # max size allowed by AWS SNS
8
+
9
+ # The required configuration fields
10
+ REQUIRED_FIELDS = %i[aws_access_key aws_access_secret aws_region sns_topic].freeze
11
+
12
+ # The topic to which messages will be published
13
+ attr_accessor :sns_topic
14
+ attr_accessor :aws_access_key, :aws_access_secret, :aws_region
15
+
16
+ # Global list of column names not to include in published SNS messages
17
+ # example: config.ignored_column_names = ['email', 'phone_number']
18
+ attr_accessor :ignored_column_names
19
+
20
+ # Optional
21
+ # If set, only publish tables specified.
22
+ # example: config.only_source_tables = ['posts', 'tags', 'comments']
23
+ attr_accessor :only_source_tables
24
+
25
+ # Optional
26
+ # Customize batch_size and/or ignored_columns
27
+ # example:
28
+ # config.source_table_options = [{ source_table_name: 'posts', batch_size: 100, ignored_column_names: ['email'] },
29
+ # { source_table_name: 'comments', ignored_column_names: ['tags'] }]
30
+ attr_accessor :source_table_options
31
+
32
+ # Optional
33
+ # Extra [SNS message_attributes](https://docs.aws.amazon.com/sns/latest/dg/sns-message-attributes.html)
34
+ # Attributes set here will be included in every published message
35
+ # example: config.extra_sns_message_attributes = {'infra_id' => {data_type: 'String', value: '061'}}
36
+ attr_accessor :extra_sns_message_attributes
37
+
38
+ # Optional
39
+ # db_blaster will select and then publish `batch_size` rows at a time
40
+ # Default value is 100
41
+ attr_accessor :batch_size
42
+
43
+ # Optional
44
+ # DbBlaster will publish no messages larger than this value
45
+ # Default value is 256
46
+ attr_accessor :max_message_size_in_kilobytes
47
+
48
+ # Raises error if a required field is not set
49
+ def verify!
50
+ no_values = REQUIRED_FIELDS.select do |attribute|
51
+ send(attribute).nil? || send(attribute).strip.empty?
52
+ end
53
+ return if no_values.empty?
54
+
55
+ raise "missing configuration values for [#{no_values.join(', ')}]"
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DbBlaster
4
+ # Engine to isolate DbBlaster namespace
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace DbBlaster
7
+
8
+ config.generators do |g|
9
+ g.test_framework :rspec
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DbBlaster
4
+ # Find records and yield them a `batch_size` at a time
5
+ class Finder
6
+ include AvailableTables
7
+ attr_reader :source_table, :block_on_find, :offset
8
+
9
+ def initialize(source_table, &block)
10
+ @source_table = source_table
11
+ @block_on_find = block
12
+ @offset = 0
13
+ end
14
+
15
+ delegate :batch_size, :name, :last_published_updated_at, to: :source_table, prefix: true
16
+
17
+ def self.find(source_table, &block)
18
+ new(source_table, &block).find
19
+ end
20
+
21
+ def find
22
+ verify_source_table_name
23
+
24
+ find_records_in_batches do |batch|
25
+ filtered = batch.collect(&method(:filter_columns))
26
+ next if filtered.blank?
27
+
28
+ Chunker.chunk(source_table, filtered) do |chunked|
29
+ block_on_find.call(chunked)
30
+ end
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def find_records_in_batches
37
+ loop do
38
+ result = ActiveRecord::Base.connection.execute("#{select_sql} OFFSET #{offset}")
39
+ yield(result)
40
+ break if result.count != source_table_batch_size
41
+
42
+ @offset += source_table_batch_size
43
+ end
44
+ end
45
+
46
+ def filter_columns(selected_row)
47
+ selected_row.except(*source_table.ignored_columns)
48
+ end
49
+
50
+ def verify_source_table_name
51
+ raise invalid_source_table_message unless available_tables.include?(source_table_name)
52
+ end
53
+
54
+ def invalid_source_table_message
55
+ "source_table.name: '#{source_table_name}' does not exist!"
56
+ end
57
+
58
+ def select_sql
59
+ "SELECT * FROM #{source_table_name} #{where} ORDER BY updated_at ASC LIMIT #{source_table_batch_size}"
60
+ end
61
+
62
+ def where
63
+ return '' unless source_table_last_published_updated_at
64
+
65
+ ActiveRecord::Base.sanitize_sql_for_conditions(
66
+ ['WHERE updated_at >= :updated_at', { updated_at: source_table_last_published_updated_at.to_s(:db) }]
67
+ )
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DbBlaster
4
+ # Custom error representing a serious error.
5
+ # If one individual record is larger than the max bytes
6
+ # allowed, db_blaster "fails fast".
7
+ # If we ignored this record and moved on, it would never make it
8
+ # to SNS as its `updated_at` would end up being less than the
9
+ # last processed record.
10
+ # Possible fixes:
11
+ # 1) if possible ignore the column(s) that are too large
12
+ # 2) manually move the record to the intended destination
13
+ # 3) bug me to provide an S3 workaround
14
+ class OneRecordTooLargeError < StandardError
15
+ attr_reader :source_table, :record, :max_kilobytes
16
+
17
+ def initialize(params)
18
+ super
19
+ params.each_key do |key|
20
+ instance_variable_set("@#{key}", params[key])
21
+ end
22
+ end
23
+
24
+ def message
25
+ ["One individual record with ID '#{record[:id]}'",
26
+ " in source-table '#{source_table.name}'",
27
+ " is larger than #{max_kilobytes} KB!"].join(' ')
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DbBlaster
4
+ # Given a `source_table` providing the table name,
5
+ # finds rows in `batch_size` chunks that are published to SNS
6
+ class PublishSourceTable
7
+ attr_reader :source_table
8
+
9
+ def initialize(source_table)
10
+ @source_table = source_table
11
+ end
12
+
13
+ def self.execute(source_table)
14
+ new(source_table).execute
15
+ end
16
+
17
+ def execute
18
+ DbBlaster.configuration.verify! # will raise error if required configurations are not set
19
+
20
+ # pessimistically lock row for the duration
21
+ source_table.with_lock do
22
+ Finder.find(source_table) do |records|
23
+ Publisher.publish(source_table, records)
24
+ source_table.update(last_published_updated_at: records.last['updated_at'])
25
+ end
26
+ end
27
+ self
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ # publish records to SNS topic
4
+ module DbBlaster
5
+ # Publishes records to AWS SNS
6
+ class Publisher
7
+ attr_reader :source_table, :records
8
+
9
+ def initialize(source_table, records)
10
+ @source_table = source_table
11
+ @records = records
12
+ end
13
+
14
+ def self.publish(source_table, records)
15
+ new(source_table, records).publish
16
+ end
17
+
18
+ def publish
19
+ topic.publish(message_attributes: message_attributes,
20
+ message: records.to_json)
21
+ end
22
+
23
+ def topic
24
+ resource.topic(DbBlaster.configuration.sns_topic)
25
+ end
26
+
27
+ def resource
28
+ @resource ||= Aws::SNS::Resource.new(client: client)
29
+ end
30
+
31
+ def client
32
+ @client ||= Aws::SNS::Client.new(region: DbBlaster.configuration.aws_region,
33
+ credentials: Aws::Credentials.new(DbBlaster.configuration.aws_access_key,
34
+ DbBlaster.configuration.aws_access_secret))
35
+ end
36
+
37
+ def message_attributes
38
+ (DbBlaster.configuration.extra_sns_message_attributes || {})
39
+ .merge('source_table' => { data_type: 'String', string_value: source_table.name })
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ if defined?(FactoryBot)
4
+ FactoryBot.define do
5
+ factory :db_blaster_source_table, class: DbBlaster::SourceTable do
6
+ sequence(:name) { |n| "table_#{n}" }
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DbBlaster
4
+ # PORO for source-table-configuration fields
5
+ class SourceTableConfiguration
6
+ attr_reader :source_table_name, :batch_size, :ignored_column_names
7
+
8
+ def initialize(params)
9
+ params.each_key do |key|
10
+ instance_variable_set("@#{key}", params[key])
11
+ end
12
+ end
13
+
14
+ def update_params
15
+ { batch_size: batch_size,
16
+ ignored_columns: ignored_column_names }
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DbBlaster
4
+ # Builds source-table configurations based off the tables in the
5
+ # current database and the provided DbBlaster::Configuration
6
+ class SourceTableConfigurationBuilder
7
+ include AvailableTables
8
+ attr_reader :configuration, :source_table_configurations
9
+
10
+ def initialize(configuration)
11
+ @configuration = configuration
12
+ @source_table_configurations = []
13
+ end
14
+
15
+ def self.build_all(configuration)
16
+ new(configuration).build_all
17
+ end
18
+
19
+ def build_all
20
+ @build_all ||= table_names_for_configuration
21
+ .collect(&method(:build_configuration))
22
+ end
23
+
24
+ def build_configuration(source_table_name)
25
+ SourceTableConfiguration.new(source_table_name: source_table_name,
26
+ batch_size: batch_size(source_table_name),
27
+ ignored_column_names: ignored_column_names(source_table_name))
28
+ end
29
+
30
+ def batch_size(source_table_name)
31
+ overridden_value_or_global(source_table_name, :batch_size) || configuration.class::DEFAULT_BATCH_SIZE
32
+ end
33
+
34
+ def ignored_column_names(source_table_name)
35
+ overridden_value_or_global(source_table_name, :ignored_column_names) || []
36
+ end
37
+
38
+ def overridden_value_or_global(source_table_name, field_name)
39
+ find_source_table_options(source_table_name)&.send(:[], field_name) || configuration.send(field_name)
40
+ end
41
+
42
+ def find_source_table_options(source_table_name)
43
+ (configuration.source_table_options || [])
44
+ .detect { |option| option[:source_table_name] == source_table_name }
45
+ end
46
+
47
+ def table_names_for_configuration
48
+ if configuration.only_source_tables&.length&.positive?
49
+ available_tables & configuration.only_source_tables
50
+ else
51
+ available_tables
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DbBlaster
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DbBlaster
4
+ module Generators
5
+ # Generator to copy a sample db_blaster_config.rb to the
6
+ # rails app's config/initializer directory
7
+ class InstallGenerator < ::Rails::Generators::Base
8
+ source_root File.expand_path('templates', __dir__)
9
+
10
+ def copy_initializer_file
11
+ copy_file 'db_blaster_config.rb', 'config/initializers/db_blaster_config.rb'
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ DbBlaster.configure do |config|
4
+ # SNS topic to receive database changes
5
+ config.sns_topic = 'the-topic'
6
+ config.aws_access_key = 'access-key'
7
+ config.aws_access_secret = 'secret'
8
+ config.aws_region = 'region'
9
+
10
+ # Optional
11
+ # db_blaster will select and then publish `batch_size` rows at a time
12
+ # config.batch_size = 100
13
+
14
+ # Optional
15
+ # db_blaster will publish no messages larger than this value
16
+ # Default value is 256
17
+ # attr_accessor :max_message_size_in_kilobytes
18
+
19
+ # Optional
20
+ # Extra [SNS message_attributes](https://docs.aws.amazon.com/sns/latest/dg/sns-message-attributes.html)
21
+ # Attributes set here will be included in every published message
22
+ # config.extra_sns_message_attributes = {'infra_id' => {data_type: 'String', value: '061'}}
23
+
24
+ # Global list of column names not to include in published SNS messages
25
+ # example: config.ignored_column_names = ['email', 'phone_number']
26
+ # config.ignored_column_names = ['email', 'phone_number']
27
+
28
+ # Optional
29
+ # If set, only publish tables specified.
30
+ # config.only_source_tables = ['posts', 'tags', 'comments']
31
+
32
+ # Optional
33
+ # Customize batch_size and/or ignored_columns
34
+ # example:
35
+ # config.source_table_options = [{ source_table_name: 'posts', batch_size: 100, ignored_column_names: ['email'] },
36
+ # { source_table_name: 'comments', ignored_column_names: ['tags'] }]
37
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+ # desc "Explaining what the task does"
3
+ # task :db_blaster do
4
+ # # Task goes here
5
+ # end
metadata ADDED
@@ -0,0 +1,162 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: db_blaster
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Perry Hertler
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2021-08-09 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: aws-sdk-sns
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '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: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: factory_bot_rails
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec-its
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec-rails
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: shoulda-matchers
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ description: Push db changes to AWS SNS.
98
+ email:
99
+ - perry@hertler.org
100
+ executables: []
101
+ extensions: []
102
+ extra_rdoc_files: []
103
+ files:
104
+ - MIT-LICENSE
105
+ - README.md
106
+ - Rakefile
107
+ - app/assets/config/db_blaster_manifest.js
108
+ - app/assets/stylesheets/db_blaster/application.css
109
+ - app/controllers/db_blaster/application_controller.rb
110
+ - app/helpers/db_blaster/application_helper.rb
111
+ - app/jobs/db_blaster/application_job.rb
112
+ - app/jobs/db_blaster/publish_all_job.rb
113
+ - app/jobs/db_blaster/publish_source_table_job.rb
114
+ - app/models/concerns/db_blaster/sync.rb
115
+ - app/models/db_blaster/application_record.rb
116
+ - app/models/db_blaster/source_table.rb
117
+ - config/brakeman.ignore
118
+ - config/routes.rb
119
+ - db/migrate/20210727222252_create_source_tables.rb
120
+ - lib/db_blaster.rb
121
+ - lib/db_blaster/available_tables.rb
122
+ - lib/db_blaster/chunker.rb
123
+ - lib/db_blaster/configuration.rb
124
+ - lib/db_blaster/engine.rb
125
+ - lib/db_blaster/finder.rb
126
+ - lib/db_blaster/one_record_too_large_error.rb
127
+ - lib/db_blaster/publish_source_table.rb
128
+ - lib/db_blaster/publisher.rb
129
+ - lib/db_blaster/rspec.rb
130
+ - lib/db_blaster/source_table_configuration.rb
131
+ - lib/db_blaster/source_table_configuration_builder.rb
132
+ - lib/db_blaster/version.rb
133
+ - lib/generators/db_blaster/install/install_generator.rb
134
+ - lib/generators/db_blaster/install/templates/db_blaster_config.rb
135
+ - lib/tasks/db_blaster_tasks.rake
136
+ homepage: https://github.com/perryqh/db_blaster
137
+ licenses:
138
+ - MIT
139
+ metadata:
140
+ homepage_uri: https://github.com/perryqh/db_blaster
141
+ source_code_uri: https://github.com/perryqh/db_blaster
142
+ changelog_uri: https://github.com/perryqh/db_blaster/CHANGELOG.md
143
+ post_install_message:
144
+ rdoc_options: []
145
+ require_paths:
146
+ - lib
147
+ required_ruby_version: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - ">="
150
+ - !ruby/object:Gem::Version
151
+ version: '2.6'
152
+ required_rubygems_version: !ruby/object:Gem::Requirement
153
+ requirements:
154
+ - - ">="
155
+ - !ruby/object:Gem::Version
156
+ version: '0'
157
+ requirements: []
158
+ rubygems_version: 3.1.6
159
+ signing_key:
160
+ specification_version: 4
161
+ summary: Push db changes to AWS SNS.
162
+ test_files: []