breathing 0.0.1 → 0.0.2

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: 2dd890230542703d28a6dcc8e7b6f1213cabf089027ebddc91280edda54cb322
4
- data.tar.gz: e29abf8900cc93a231c8ac1d12763d89bc8719ded8ddcb1fb68f5d7d7de6aa61
3
+ metadata.gz: 9b0a34aef932a21f7369a766114261c484fcc68cdb32ec50e81c721da2f13d0a
4
+ data.tar.gz: eb6c8c5370a13e422674c9256032eeb650a4a58e018eed78f33c234a7a9cbc41
5
5
  SHA512:
6
- metadata.gz: 8ebf6e138f187609e34aee5f26f16b33a94efd98afb248a69c43286e4fe00e8496b176d8b3d8baa2a6dd59485b8ea7958dd1982c4ee329475f3bef04de1b3b86
7
- data.tar.gz: 798a892f5c78b97b4b7b7b61c6de09c74488953476901cd640da8e77e90d436d909e4873b9bc99f36f8d42db7ab53adf1a8fc7bad1be117e82f11af5432e36ad
6
+ metadata.gz: 91a00104ce4496d7250df3664624cd3cd5fb1b6af4d48f57445fbd7f0e798a4e4dd2003d0a2cfc3b1bc1e31d4729971abcfc0086701208a37b1a1975522527da
7
+ data.tar.gz: 4dc36063b0e355d5f245660136b2c439bacf16bf2da61fe899ee05b19a51f17c09e2036e1f6aa4505671c1f89cd4f4c637f0f58baf1adf0d1776bf6379594ccf
@@ -0,0 +1,53 @@
1
+ version: 2.1
2
+
3
+ executors:
4
+ default:
5
+ working_directory: ~/app
6
+ docker:
7
+ - image: circleci/ruby:2.6
8
+ environment:
9
+ DB_USER: 'root'
10
+ DB_PASS: 'root'
11
+ DB_HOST: '127.0.0.1'
12
+ - image: circleci/mysql:8-ram
13
+ environment:
14
+ MYSQL_USER: root
15
+ MYSQL_ROOT_PASSWORD: root
16
+ MYSQL_DATABASE: breathing_test
17
+ command: [--default-authentication-plugin=mysql_native_password]
18
+
19
+ commands:
20
+ setup_bundle:
21
+ steps:
22
+ - restore_cache:
23
+ key: bundle-{{ checksum "breathing.gemspec" }}
24
+ - run:
25
+ name: install dependencies
26
+ command: |
27
+ bundle install --jobs=4 --retry=3 --path vendor/bundle
28
+ - save_cache:
29
+ key: bundle-{{ checksum "breathing.gemspec" }}
30
+ paths:
31
+ - vendor/bundle
32
+
33
+ wait_for_db:
34
+ steps:
35
+ - run:
36
+ name: Wait for DB
37
+ command: dockerize -wait tcp://127.0.0.1:3306 -timeout 1m
38
+
39
+ jobs:
40
+ test:
41
+ executor: default
42
+ steps:
43
+ - checkout
44
+ - setup_bundle
45
+ - wait_for_db
46
+ - run: bundle exec rspec ./spec
47
+
48
+ workflows:
49
+ version: 2
50
+
51
+ test:
52
+ jobs:
53
+ - test
data/README.md CHANGED
@@ -28,9 +28,9 @@ Just run the following command.
28
28
 
29
29
  - Create table `change_logs`
30
30
  - Create triggers
31
- - breathing_insert_{table_name}
32
- - breathing_update_{table_name}
33
- - breathing_delete_{table_name}
31
+ - change_logs_insert_{table_name}
32
+ - change_logs_update_{table_name}
33
+ - change_logs_delete_{table_name}
34
34
 
35
35
  ### Uninstall
36
36
 
@@ -40,7 +40,17 @@ Cleanup command.
40
40
  % DATABASE_URL="mysql2://user:pass@host:port/database" breathing uninstall
41
41
  ```
42
42
 
43
- - Drop table `change_logs` and triggers
43
+ - Drop table `change_logs`
44
+ - Drop triggers
45
+ - change_logs_insert_{table_name}
46
+ - change_logs_update_{table_name}
47
+ - change_logs_delete_{table_name}
48
+
49
+ ### export
50
+
51
+ ```
52
+ % DATABASE_URL="mysql2://user:pass@host:port/database" breathing export
53
+ ```
44
54
 
45
55
  ## Copyright
46
56
 
@@ -2,13 +2,13 @@ $:.push File.expand_path('lib', __dir__)
2
2
 
3
3
  Gem::Specification.new do |s|
4
4
  s.name = 'breathing'
5
- s.version = '0.0.1'
5
+ s.version = '0.0.2'
6
6
  s.platform = Gem::Platform::RUBY
7
7
  s.authors = ['Akira Kusumoto']
8
8
  s.email = ['akirakusumo10@gmail.com']
9
9
  s.homepage = 'https://github.com/bluerabbit/breathing'
10
- s.summary = 'audit logging for database'
11
- s.description = 'audit logging for database'
10
+ s.summary = 'Audit logging for database'
11
+ s.description = 'Audit logging for database'
12
12
 
13
13
  s.files = `git ls-files`.split("\n")
14
14
  s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
@@ -21,6 +21,7 @@ Gem::Specification.new do |s|
21
21
  s.add_runtime_dependency 'thor'
22
22
 
23
23
  s.add_dependency 'activerecord', ['>= 5.0.0']
24
+ s.add_dependency 'rubyXL', ['>= 3.4.0']
24
25
  s.add_development_dependency 'mysql2'
25
26
  s.add_development_dependency 'pry-byebug'
26
27
  s.add_development_dependency 'rspec', '~> 3.9'
@@ -1,3 +1,35 @@
1
- class Breathing
1
+ require 'active_record'
2
+ require 'breathing/installer'
3
+ require 'breathing/trigger'
4
+ require 'breathing/change_log'
5
+ require 'breathing/excel'
6
+
7
+ module Breathing
2
8
  VERSION = Gem.loaded_specs['breathing'].version.to_s
9
+
10
+ class << self
11
+ def install
12
+ establish_connection
13
+ Breathing::Installer.new.install
14
+ end
15
+
16
+ def uninstall
17
+ establish_connection
18
+ Breathing::Installer.new.uninstall
19
+ end
20
+
21
+ def clear
22
+ establish_connection
23
+ Breathing::ChangeLog.delete_all
24
+ end
25
+
26
+ def export
27
+ establish_connection
28
+ Breathing::Excel.new.create
29
+ end
30
+
31
+ def establish_connection
32
+ ActiveRecord::Base.establish_connection(url: ENV['DATABASE_URL'])
33
+ end
34
+ end
3
35
  end
@@ -0,0 +1,16 @@
1
+ require 'active_record'
2
+
3
+ module Breathing
4
+ class ChangeLog < ActiveRecord::Base
5
+ def changed_attribute_columns
6
+ before_data.each.with_object([]) do |(column, value), columns|
7
+ columns << column if after_data[column] != value
8
+ end
9
+ end
10
+
11
+ def data_column_names
12
+ names = before_data.keys.present? ? before_data.keys : after_data.keys
13
+ names.reject { |name| name == 'id' }
14
+ end
15
+ end
16
+ end
@@ -1,9 +1,29 @@
1
1
  require 'thor'
2
2
  require 'breathing'
3
3
 
4
- class Breathing
4
+ module Breathing
5
5
  class Cli < Thor
6
- default_command :version
6
+ default_command :install
7
+
8
+ desc 'install', 'Create table change_logs and create triggers'
9
+ def install
10
+ Breathing.install
11
+ end
12
+
13
+ desc 'uninstall', 'Drop table change_logs and drop triggers'
14
+ def uninstall
15
+ Breathing.uninstall
16
+ end
17
+
18
+ desc 'clear', 'Delete all records in change_logs table'
19
+ def clear
20
+ Breathing.clear
21
+ end
22
+
23
+ desc 'export', 'output xlsx'
24
+ def export
25
+ Breathing.export
26
+ end
7
27
 
8
28
  desc 'version', 'Show Version'
9
29
  def version
@@ -0,0 +1,74 @@
1
+ require 'rubyXL'
2
+ require 'rubyXL/convenience_methods'
3
+ require 'breathing'
4
+
5
+ module Breathing
6
+ class Excel
7
+ def initialize
8
+ @workbook = RubyXL::Workbook.new
9
+ end
10
+
11
+ def create(id: 1, file_name: 'breathing.xlsx')
12
+ sheet = @workbook[0]
13
+ table_names = Breathing::ChangeLog.where('id >= ?', id).group(:table_name).pluck(:table_name)
14
+ table_names.each do |table_name|
15
+ if sheet.sheet_name == 'Sheet1'
16
+ sheet.sheet_name = table_name
17
+ else
18
+ sheet = @workbook.add_worksheet(table_name)
19
+ end
20
+
21
+ rows = Breathing::ChangeLog.where(table_name: table_name).where('id >= ?', id).order(:id)
22
+
23
+ if first_row = rows.first
24
+ add_header_row(sheet, first_row)
25
+ end
26
+ add_body_rows(sheet, rows)
27
+ add_style(sheet)
28
+ end
29
+
30
+ @workbook.write(file_name)
31
+ end
32
+
33
+ private
34
+
35
+ def add_header_row(sheet, row)
36
+ sheet.add_cell(0, 0, 'change_logs.id').change_font_bold(true)
37
+ sheet.add_cell(0, 1, 'action').change_font_bold(true)
38
+ sheet.add_cell(0, 2, 'id').change_font_bold(true)
39
+ row.data_column_names.each.with_index(3) do |column_name, i|
40
+ sheet.add_cell(0, i, column_name).change_font_bold(true)
41
+ end
42
+ end
43
+
44
+ def add_body_rows(sheet, rows)
45
+ rows.each.with_index(1) do |row, i|
46
+ sheet.add_cell(i, 0, row.id)
47
+ sheet.add_cell(i, 1, row.action)
48
+ sheet.add_cell(i, 2, row.transaction_id)
49
+ row.data_column_names.each.with_index(3) do |column_name, j|
50
+ data = row.action == 'DELETE' ? row.before_data : row.after_data
51
+ cell_object = sheet.add_cell(i, j, data[column_name])
52
+ if row.action == 'UPDATE' && column_name != 'updated_at' && row.changed_attribute_columns.include?(column_name)
53
+ cell_object.change_fill('ffff00') # color: yellow
54
+ elsif row.action == 'DELETE'
55
+ cell_object.change_fill('d9d9d9') # color: grey
56
+ end
57
+ end
58
+ end
59
+ end
60
+
61
+ def add_style(sheet)
62
+ sheet.sheet_data.rows.each.with_index do |row, i|
63
+ row.cells.each do |cell|
64
+ %i[top bottom left right].each do |direction|
65
+ cell.change_border(direction, 'thin')
66
+ end
67
+
68
+ cell.change_border(:bottom, 'medium') if i.zero?
69
+ end
70
+ end
71
+ sheet.change_row_horizontal_alignment(0, 'center')
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,71 @@
1
+ require 'active_record'
2
+ require 'breathing'
3
+ require 'breathing/trigger'
4
+ require 'breathing/change_log'
5
+
6
+ module Breathing
7
+ class Installer
8
+ def install
9
+ create_log_table unless log_table_exists?
10
+
11
+ models.each do |model|
12
+ column_names = model.columns.map(&:name)
13
+ if column_names.include?('id') && column_names.include?('updated_at')
14
+ Breathing::Trigger.new(model, log_table_name).create
15
+ end
16
+ end
17
+ end
18
+
19
+ def uninstall
20
+ drop_log_table if log_table_exists?
21
+
22
+ models.each { |model| Breathing::Trigger.new(model, log_table_name).drop }
23
+ end
24
+
25
+ private
26
+
27
+ def log_table_name
28
+ Breathing::ChangeLog.table_name
29
+ end
30
+
31
+ def create_log_table(table_name: log_table_name)
32
+ ActiveRecord::Schema.define version: 0 do
33
+ create_table table_name, force: false do |t|
34
+ t.string :action, null: false
35
+ t.string :table_name, null: false
36
+ t.string :transaction_id, null: false
37
+ t.json :before_data, null: false
38
+ t.json :after_data, null: false
39
+ t.datetime :created_at, null: false, index: true
40
+ t.index %w[table_name transaction_id]
41
+ end
42
+ end
43
+ end
44
+
45
+ def drop_log_table
46
+ sql = "DROP TABLE #{log_table_name}"
47
+ puts sql
48
+ ActiveRecord::Base.connection.execute(sql)
49
+ end
50
+
51
+ def log_table_exists?
52
+ ActiveRecord::Base.connection.table_exists?(log_table_name)
53
+ end
54
+
55
+ def models
56
+ ignores = %w[schema_migrations ar_internal_metadata] << log_table_name
57
+
58
+ ActiveRecord::Base.connection.tables.each do |table_name|
59
+ next if ignores.include?(table_name) || Object.const_defined?(table_name.classify)
60
+
61
+ eval <<-EOS
62
+ class #{table_name.classify} < ActiveRecord::Base
63
+ self.table_name = :#{table_name}
64
+ end
65
+ EOS
66
+ end
67
+
68
+ ActiveRecord::Base.descendants.reject(&:abstract_class).reject { |m| ignores.include? m.table_name }
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,100 @@
1
+ require 'active_record'
2
+ require 'breathing/change_log'
3
+
4
+ module Breathing
5
+ class Trigger
6
+ attr_reader :model, :log_table_name
7
+
8
+ def initialize(model, log_table_name)
9
+ @model = model
10
+ @log_table_name = log_table_name
11
+ end
12
+
13
+ def create
14
+ trigger_name = "#{log_table_name}_insert_#{model.table_name}"
15
+ unless exists?(trigger_name)
16
+ puts "CREATE TRIGGER #{trigger_name}"
17
+ ActiveRecord::Base.connection.execute <<-SQL
18
+ CREATE TRIGGER #{trigger_name}
19
+ AFTER INSERT
20
+ ON #{model.table_name}
21
+ FOR EACH ROW
22
+ BEGIN
23
+ INSERT INTO #{log_table_name} (`action`, `table_name`, `transaction_id`, `before_data`, `after_data`, `created_at`)
24
+ VALUES ('INSERT', '#{model.table_name}', NEW.id,
25
+ JSON_OBJECT(),
26
+ JSON_OBJECT(#{json_object_values(model.columns, 'NEW')}),
27
+ CURRENT_TIMESTAMP);
28
+ END;
29
+ SQL
30
+ end
31
+
32
+ trigger_name = "#{log_table_name}_update_#{model.table_name}"
33
+ unless exists?(trigger_name)
34
+ puts "CREATE TRIGGER #{trigger_name}"
35
+ ActiveRecord::Base.connection.execute <<-SQL
36
+ CREATE TRIGGER #{trigger_name}
37
+ BEFORE UPDATE
38
+ ON #{model.table_name}
39
+ FOR EACH ROW
40
+ BEGIN
41
+ IF (OLD.updated_at != NEW.updated_at) THEN
42
+ INSERT INTO #{log_table_name} (`action`, `table_name`, `transaction_id`, `before_data`, `after_data`, `created_at`)
43
+ VALUES ('UPDATE', '#{model.table_name}', NEW.id,
44
+ JSON_OBJECT(#{json_object_values(model.columns, 'OLD')}),
45
+ JSON_OBJECT(#{json_object_values(model.columns, 'NEW')}),
46
+ CURRENT_TIMESTAMP);
47
+ end if;
48
+ END;
49
+ SQL
50
+ end
51
+
52
+ trigger_name = "#{log_table_name}_delete_#{model.table_name}"
53
+ unless exists?(trigger_name)
54
+ puts "CREATE TRIGGER #{trigger_name}"
55
+ ActiveRecord::Base.connection.execute <<-SQL
56
+ CREATE TRIGGER #{trigger_name}
57
+ AFTER DELETE
58
+ ON #{model.table_name}
59
+ FOR EACH ROW
60
+ BEGIN
61
+ INSERT INTO #{log_table_name} (`action`, `table_name`, `transaction_id`, `before_data`, `after_data`, `created_at`)
62
+ VALUES ('DELETE', '#{model.table_name}', OLD.id,
63
+ JSON_OBJECT(#{json_object_values(model.columns, 'OLD')}),
64
+ JSON_OBJECT(),
65
+ CURRENT_TIMESTAMP);
66
+ END;
67
+ SQL
68
+ end
69
+ end
70
+
71
+ def drop
72
+ %w[insert update delete].each do |action|
73
+ trigger_name = "#{log_table_name}_#{action}_#{model.table_name}"
74
+ next unless exists?(trigger_name)
75
+
76
+ begin
77
+ sql = "DROP TRIGGER #{trigger_name}"
78
+ puts sql
79
+ ActiveRecord::Base.connection.execute(sql)
80
+ rescue StandardError => e
81
+ puts "#{e.message} trigger_name:#{trigger_name}"
82
+ end
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ def exists?(trigger_name)
89
+ @trigger_names ||= ActiveRecord::Base.connection.select_rows('SHOW TRIGGERS').map { |row| row.first }
90
+ @trigger_names.include?(trigger_name)
91
+ end
92
+
93
+ def json_object_values(columns, state)
94
+ columns.each.with_object([]) do |column, array|
95
+ array << "'#{column.name}'"
96
+ array << "#{state}.#{column.name}"
97
+ end.join(',')
98
+ end
99
+ end
100
+ end
@@ -11,7 +11,15 @@ ActiveRecord::Schema.define version: 0 do
11
11
  t.integer :age, null: false
12
12
  t.timestamps null: false
13
13
  end
14
+
15
+ create_table :departments, force: true do |t|
16
+ t.string :name, null: false
17
+ t.timestamps null: false
18
+ end
14
19
  end
15
20
 
16
21
  class User < ActiveRecord::Base
17
22
  end
23
+
24
+ class Department < ActiveRecord::Base
25
+ end
@@ -0,0 +1,34 @@
1
+ require 'spec_helper'
2
+
3
+ describe Breathing::Excel do
4
+ describe '#create' do
5
+ before { Breathing::Installer.new.install }
6
+ after { Breathing::Installer.new.uninstall }
7
+
8
+ it do
9
+ user = User.create!(name: 'a', age: 20)
10
+ user.update!(age: 21)
11
+ user.destroy!
12
+ expect(Breathing::ChangeLog.count).to eq(3)
13
+
14
+ Tempfile.open(['tmp', '.xlsx']) do |file|
15
+ Breathing::Excel.new.create(file_name: file.path)
16
+ workbook = RubyXL::Parser.parse(file.path)
17
+ expect(workbook.sheets[0].name).to eq('users')
18
+ user_sheet = workbook.worksheets[0]
19
+ expect(user_sheet.sheet_data.size).to eq(Breathing::ChangeLog.where(table_name: :users).count + 1)
20
+ end
21
+ end
22
+
23
+ it 'multi sheets' do
24
+ User.create!(name: 'a', age: 20)
25
+ Department.create!(name: 'a')
26
+
27
+ Tempfile.open(['tmp', '.xlsx']) do |file|
28
+ Breathing::Excel.new.create(file_name: file.path)
29
+ workbook = RubyXL::Parser.parse(file.path)
30
+ expect(workbook.sheets.size).to eq(2)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -4,4 +4,37 @@ describe Breathing do
4
4
  it 'has a version number' do
5
5
  expect(Breathing::VERSION).not_to be nil
6
6
  end
7
+
8
+ before { Breathing::Installer.new.install }
9
+ after { Breathing::Installer.new.uninstall }
10
+
11
+ it do
12
+ expect(Breathing::ChangeLog.count).to eq(0)
13
+
14
+ # INSERT
15
+ user = User.create!(name: 'a', age: 20)
16
+ expect(Breathing::ChangeLog.count).to eq(1)
17
+
18
+ log = Breathing::ChangeLog.where(table_name: user.class.table_name, transaction_id: user.id).last
19
+ expect(log.before_data).to eq({})
20
+ expect(log.after_data['name']).to eq('a')
21
+ expect(log.after_data['age']).to eq(20)
22
+
23
+ # UPDATE
24
+ user.update!(age: 21)
25
+ expect(Breathing::ChangeLog.count).to eq(2)
26
+
27
+ log = Breathing::ChangeLog.where(table_name: user.class.table_name, transaction_id: user.id).last
28
+ expect(log.before_data['age']).to eq(20)
29
+ expect(log.after_data['age']).to eq(21)
30
+ expect(log.before_data['name']).to eq(log.after_data['name'])
31
+ expect(log.changed_attribute_columns).to eq(%w[age updated_at])
32
+
33
+ # DELETE
34
+ user.destroy!
35
+ expect(Breathing::ChangeLog.count).to eq(3)
36
+ log = Breathing::ChangeLog.where(table_name: user.class.table_name, transaction_id: user.id).last
37
+ expect(log.before_data['name']).to eq('a')
38
+ expect(log.after_data).to eq({})
39
+ end
7
40
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: breathing
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Akira Kusumoto
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
40
  version: 5.0.0
41
+ - !ruby/object:Gem::Dependency
42
+ name: rubyXL
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: 3.4.0
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: 3.4.0
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: mysql2
43
57
  requirement: !ruby/object:Gem::Requirement
@@ -80,7 +94,7 @@ dependencies:
80
94
  - - "~>"
81
95
  - !ruby/object:Gem::Version
82
96
  version: '3.9'
83
- description: audit logging for database
97
+ description: Audit logging for database
84
98
  email:
85
99
  - akirakusumo10@gmail.com
86
100
  executables:
@@ -88,6 +102,7 @@ executables:
88
102
  extensions: []
89
103
  extra_rdoc_files: []
90
104
  files:
105
+ - ".circleci/config.yml"
91
106
  - ".gitignore"
92
107
  - Gemfile
93
108
  - MIT-LICENSE
@@ -96,8 +111,13 @@ files:
96
111
  - breathing.gemspec
97
112
  - exe/breathing
98
113
  - lib/breathing.rb
114
+ - lib/breathing/change_log.rb
99
115
  - lib/breathing/cli.rb
116
+ - lib/breathing/excel.rb
117
+ - lib/breathing/installer.rb
118
+ - lib/breathing/trigger.rb
100
119
  - spec/app.rb
120
+ - spec/breathing/excel_spec.rb
101
121
  - spec/breathing_spec.rb
102
122
  - spec/database.yml
103
123
  - spec/spec_helper.rb
@@ -123,9 +143,10 @@ requirements: []
123
143
  rubygems_version: 3.0.3
124
144
  signing_key:
125
145
  specification_version: 4
126
- summary: audit logging for database
146
+ summary: Audit logging for database
127
147
  test_files:
128
148
  - spec/app.rb
149
+ - spec/breathing/excel_spec.rb
129
150
  - spec/breathing_spec.rb
130
151
  - spec/database.yml
131
152
  - spec/spec_helper.rb