breathing 0.0.1 → 0.0.6
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 +4 -4
- data/.circleci/config.yml +60 -0
- data/README.md +49 -12
- data/breathing.gemspec +8 -4
- data/lib/breathing.rb +50 -1
- data/lib/breathing/change_log.rb +29 -0
- data/lib/breathing/cli.rb +48 -2
- data/lib/breathing/excel.rb +108 -0
- data/lib/breathing/installer.rb +80 -0
- data/lib/breathing/terminal_table.rb +30 -0
- data/lib/breathing/trigger.rb +96 -0
- data/spec/app.rb +9 -1
- data/spec/breathing/excel_spec.rb +36 -0
- data/spec/breathing/terminal_table_spec.rb +22 -0
- data/spec/breathing_spec.rb +38 -0
- data/spec/database.yml +10 -3
- data/spec/spec_helper.rb +2 -0
- metadata +71 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: a6108eb021949faaaaf3d08037dddd7dee71d4fe53592ed5991bc29c4ef9f724
|
4
|
+
data.tar.gz: 36219756e661c52c19879a0510e9452bacdd34bef21f7cb767dcf37e46d14ba0
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4e4d6f883ef9e715b52ca09cf3d69135c7f8c49eb7ada7da157ec6164ccb39321c3a9217547541cfe8040bae2c2480328ad9568694f227ba10ae3e72d1246c58
|
7
|
+
data.tar.gz: 05b8557f1c107daf79c00e086554daddb51e8987327dfb8dafd5f030f85e25cca6b8df53e1192fd2902fd3e62820b2cacc8019227c5f07c27ac3ab02f835a628
|
@@ -0,0 +1,60 @@
|
|
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_HOST: '127.0.0.1'
|
11
|
+
- image: circleci/mysql:8-ram
|
12
|
+
environment:
|
13
|
+
MYSQL_USER: root
|
14
|
+
MYSQL_ROOT_PASSWORD: root
|
15
|
+
MYSQL_DATABASE: breathing_test
|
16
|
+
command: [--default-authentication-plugin=mysql_native_password]
|
17
|
+
- image: circleci/postgres:10.6-alpine-ram
|
18
|
+
environment:
|
19
|
+
POSTGRES_USER: root
|
20
|
+
POSTGRES_DB: breathing_test
|
21
|
+
|
22
|
+
commands:
|
23
|
+
setup_bundle:
|
24
|
+
steps:
|
25
|
+
- restore_cache:
|
26
|
+
key: bundle-{{ checksum "breathing.gemspec" }}
|
27
|
+
- run:
|
28
|
+
name: install dependencies
|
29
|
+
command: |
|
30
|
+
bundle install --jobs=4 --retry=3 --path vendor/bundle
|
31
|
+
- save_cache:
|
32
|
+
key: bundle-{{ checksum "breathing.gemspec" }}
|
33
|
+
paths:
|
34
|
+
- vendor/bundle
|
35
|
+
|
36
|
+
wait_for_db:
|
37
|
+
steps:
|
38
|
+
- run:
|
39
|
+
name: Wait for DB
|
40
|
+
command: dockerize -wait tcp://127.0.0.1:3306 -timeout 1m
|
41
|
+
- run:
|
42
|
+
name: Wait for DB
|
43
|
+
command: dockerize -wait tcp://127.0.0.1:5432 -timeout 1m
|
44
|
+
|
45
|
+
jobs:
|
46
|
+
test:
|
47
|
+
executor: default
|
48
|
+
steps:
|
49
|
+
- checkout
|
50
|
+
- setup_bundle
|
51
|
+
- wait_for_db
|
52
|
+
- run: DB=mysql DB_PASS=root bundle exec rspec ./spec
|
53
|
+
- run: DB=pg bundle exec rspec ./spec
|
54
|
+
|
55
|
+
workflows:
|
56
|
+
version: 2
|
57
|
+
|
58
|
+
test:
|
59
|
+
jobs:
|
60
|
+
- test
|
data/README.md
CHANGED
@@ -5,15 +5,8 @@ Logging mechanism using database triggers to store the old and new row states in
|
|
5
5
|
|
6
6
|
## Install
|
7
7
|
|
8
|
-
Put this line in your Gemfile:
|
9
|
-
|
10
|
-
```
|
11
|
-
gem 'breathing'
|
12
|
-
```
|
13
|
-
|
14
|
-
Then bundle:
|
15
8
|
```
|
16
|
-
|
9
|
+
gem install breathing
|
17
10
|
```
|
18
11
|
|
19
12
|
## Usage
|
@@ -24,13 +17,15 @@ Just run the following command.
|
|
24
17
|
|
25
18
|
```
|
26
19
|
% DATABASE_URL="mysql2://user:pass@host:port/database" breathing install
|
20
|
+
or
|
21
|
+
% DATABASE_URL="postgres://user:pass@host:port/database" breathing install
|
27
22
|
```
|
28
23
|
|
29
24
|
- Create table `change_logs`
|
30
25
|
- Create triggers
|
31
|
-
-
|
32
|
-
-
|
33
|
-
-
|
26
|
+
- change_logs_insert_{table_name}
|
27
|
+
- change_logs_update_{table_name}
|
28
|
+
- change_logs_delete_{table_name}
|
34
29
|
|
35
30
|
### Uninstall
|
36
31
|
|
@@ -40,7 +35,49 @@ Cleanup command.
|
|
40
35
|
% DATABASE_URL="mysql2://user:pass@host:port/database" breathing uninstall
|
41
36
|
```
|
42
37
|
|
43
|
-
- Drop table `change_logs`
|
38
|
+
- Drop table `change_logs`
|
39
|
+
- Drop triggers
|
40
|
+
- change_logs_insert_{table_name}
|
41
|
+
- change_logs_update_{table_name}
|
42
|
+
- change_logs_delete_{table_name}
|
43
|
+
|
44
|
+
### Export
|
45
|
+
|
46
|
+
```
|
47
|
+
% DATABASE_URL="mysql2://user:pass@host:port/database" breathing export
|
48
|
+
```
|
49
|
+
|
50
|
+
- Output file `breathing.xlsx`
|
51
|
+
|
52
|
+
### out
|
53
|
+
|
54
|
+
```
|
55
|
+
% DATABASE_URL="mysql2://user:pass@host:port/database" breathing out --table users --id 1
|
56
|
+
```
|
57
|
+
|
58
|
+
```
|
59
|
+
+----------------+------------------------+--------+----+-----+------+----------------------------+----------------------------+
|
60
|
+
| users |
|
61
|
+
+----------------+------------------------+--------+----+-----+------+----------------------------+----------------------------+
|
62
|
+
| change_logs.id | change_logs.created_at | action | id | age | name | created_at | updated_at |
|
63
|
+
+----------------+------------------------+--------+----+-----+------+----------------------------+----------------------------+
|
64
|
+
| 1 | 2020-12-18 22:43:32 | INSERT | 10 | 20 | a | 2020-12-18 13:43:32.316923 | 2020-12-18 13:43:32.316923 |
|
65
|
+
| 2 | 2020-12-18 22:43:32 | UPDATE | 10 | 21 | a | 2020-12-18 13:43:32.316923 | 2020-12-18 13:43:32.319706 |
|
66
|
+
| 3 | 2020-12-18 22:43:32 | DELETE | 10 | 21 | a | 2020-12-18 13:43:32.316923 | 2020-12-18 13:43:32.319706 |
|
67
|
+
+----------------+------------------------+--------+----+-----+------+----------------------------+----------------------------+
|
68
|
+
```
|
69
|
+
|
70
|
+
### tail
|
71
|
+
|
72
|
+
```
|
73
|
+
% DATABASE_URL="mysql2://user:pass@host:port/database" breathing tail --table users --id 1
|
74
|
+
```
|
75
|
+
|
76
|
+
## Compatibility
|
77
|
+
|
78
|
+
- Ruby 2.3.0+
|
79
|
+
- MySQL 5.7.0+
|
80
|
+
- PostgreSQL 8.0+
|
44
81
|
|
45
82
|
## Copyright
|
46
83
|
|
data/breathing.gemspec
CHANGED
@@ -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.
|
5
|
+
s.version = '0.0.6'
|
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 = '
|
11
|
-
s.description = '
|
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,7 +21,11 @@ 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.
|
24
|
+
s.add_dependency 'hairtrigger'
|
25
|
+
s.add_dependency 'mysql2'
|
26
|
+
s.add_dependency 'pg'
|
27
|
+
s.add_dependency 'terminal-table'
|
28
|
+
s.add_dependency 'rubyXL', ['>= 3.4.0']
|
25
29
|
s.add_development_dependency 'pry-byebug'
|
26
30
|
s.add_development_dependency 'rspec', '~> 3.9'
|
27
31
|
end
|
data/lib/breathing.rb
CHANGED
@@ -1,3 +1,52 @@
|
|
1
|
-
|
1
|
+
require 'active_record'
|
2
|
+
require 'hairtrigger'
|
3
|
+
require 'breathing/installer'
|
4
|
+
require 'breathing/trigger'
|
5
|
+
require 'breathing/change_log'
|
6
|
+
require 'breathing/excel'
|
7
|
+
require 'breathing/terminal_table'
|
8
|
+
|
9
|
+
module Breathing
|
2
10
|
VERSION = Gem.loaded_specs['breathing'].version.to_s
|
11
|
+
|
12
|
+
class << self
|
13
|
+
def install
|
14
|
+
ActiveRecord::Base.establish_connection
|
15
|
+
Breathing::Installer.new.install
|
16
|
+
end
|
17
|
+
|
18
|
+
def uninstall
|
19
|
+
ActiveRecord::Base.establish_connection
|
20
|
+
Breathing::Installer.new.uninstall
|
21
|
+
end
|
22
|
+
|
23
|
+
def clear
|
24
|
+
ActiveRecord::Base.establish_connection
|
25
|
+
Breathing::ChangeLog.delete_all
|
26
|
+
end
|
27
|
+
|
28
|
+
def export
|
29
|
+
ActiveRecord::Base.establish_connection
|
30
|
+
Breathing::Excel.new.create
|
31
|
+
end
|
32
|
+
|
33
|
+
def render_terminal_table(table_name:, id: 1)
|
34
|
+
ActiveRecord::Base.establish_connection
|
35
|
+
puts Breathing::TerminalTable.new(table_name).render(id: id)
|
36
|
+
end
|
37
|
+
|
38
|
+
def tail_f(table_name:, id: 1)
|
39
|
+
ActiveRecord::Base.establish_connection
|
40
|
+
table = Breathing::TerminalTable.new(table_name)
|
41
|
+
|
42
|
+
loop do
|
43
|
+
text = table.render(id: id)
|
44
|
+
if text.present?
|
45
|
+
puts text
|
46
|
+
id = table.last_id + 1
|
47
|
+
end
|
48
|
+
sleep 5
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
3
52
|
end
|
@@ -0,0 +1,29 @@
|
|
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
|
+
|
16
|
+
def data
|
17
|
+
action == 'DELETE' ? before_data : after_data
|
18
|
+
end
|
19
|
+
|
20
|
+
def data_attributes
|
21
|
+
data_column_names.each.with_object("change_logs.id" => id,
|
22
|
+
"change_logs.created_at" => created_at.to_s(:db),
|
23
|
+
"action" => action,
|
24
|
+
"id" => transaction_id) do |name, hash|
|
25
|
+
hash[name] = data[name]
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
data/lib/breathing/cli.rb
CHANGED
@@ -1,11 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
1
2
|
require 'thor'
|
2
3
|
require 'breathing'
|
3
4
|
|
4
|
-
|
5
|
+
module Breathing
|
5
6
|
class Cli < Thor
|
6
|
-
default_command :
|
7
|
+
default_command :export
|
8
|
+
|
9
|
+
desc 'install', 'Create table change_logs and create triggers'
|
10
|
+
|
11
|
+
def install
|
12
|
+
Breathing.install
|
13
|
+
end
|
14
|
+
|
15
|
+
desc 'uninstall', 'Drop table change_logs and drop triggers'
|
16
|
+
|
17
|
+
def uninstall
|
18
|
+
Breathing.uninstall
|
19
|
+
end
|
20
|
+
|
21
|
+
desc 'clear', 'Delete all records in change_logs table'
|
22
|
+
|
23
|
+
def clear
|
24
|
+
Breathing.clear
|
25
|
+
end
|
26
|
+
|
27
|
+
desc 'export', 'output xlsx'
|
28
|
+
|
29
|
+
def export
|
30
|
+
Breathing.export
|
31
|
+
end
|
32
|
+
|
33
|
+
desc 'out', 'output stdout'
|
34
|
+
method_option :type, aliases: '-t', default: 'terminal_table', type: :string
|
35
|
+
method_option :table, type: :string, required: true
|
36
|
+
method_option :id, default: 1, type: :numeric
|
37
|
+
def out
|
38
|
+
if options[:table] == 'terminal_table'
|
39
|
+
Breathing.render_terminal_table(table_name: options[:table], id: options[:id].to_i)
|
40
|
+
else
|
41
|
+
# TODO
|
42
|
+
# Breathing.export(table_name: options[:table], id: options[:id].to_i)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
desc 'tail', 'tail terminal_table'
|
47
|
+
method_option :table, type: :string, required: true
|
48
|
+
method_option :id, default: 1, type: :numeric
|
49
|
+
def tail
|
50
|
+
Breathing.tail_f(table_name: options[:table], id: options[:id].to_i)
|
51
|
+
end
|
7
52
|
|
8
53
|
desc 'version', 'Show Version'
|
54
|
+
|
9
55
|
def version
|
10
56
|
say "Version: #{Breathing::VERSION}"
|
11
57
|
end
|
@@ -0,0 +1,108 @@
|
|
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
|
+
column_widths = []
|
23
|
+
|
24
|
+
if first_row = rows.first
|
25
|
+
add_header_row(sheet, first_row, column_widths)
|
26
|
+
end
|
27
|
+
add_body_rows(sheet, rows, column_widths)
|
28
|
+
|
29
|
+
column_widths.each.with_index(0) do |size, i|
|
30
|
+
sheet.change_column_width(i, size + 2)
|
31
|
+
end
|
32
|
+
add_style(sheet)
|
33
|
+
end
|
34
|
+
|
35
|
+
@workbook.write(file_name)
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def add_header_row(sheet, row, column_widths)
|
41
|
+
sheet.add_cell(0, 0, 'change_logs.id').tap do |cell|
|
42
|
+
cell.change_font_bold(true)
|
43
|
+
cell.change_fill('ddedf3') # color: blue
|
44
|
+
end
|
45
|
+
sheet.add_cell(0, 1, 'change_logs.created_at').tap do |cell|
|
46
|
+
cell.change_font_bold(true)
|
47
|
+
cell.change_fill('ddedf3') # color: blue
|
48
|
+
end
|
49
|
+
sheet.add_cell(0, 2, 'action').tap do |cell|
|
50
|
+
cell.change_font_bold(true)
|
51
|
+
cell.change_fill('ddedf3') # color: blue
|
52
|
+
end
|
53
|
+
sheet.add_cell(0, 3, 'id').tap do |cell|
|
54
|
+
cell.change_font_bold(true)
|
55
|
+
cell.change_fill('ddedf3') # color: blue
|
56
|
+
end
|
57
|
+
|
58
|
+
column_widths << 'change_logs.id'.size
|
59
|
+
column_widths << 'change_logs.created_at'.size
|
60
|
+
column_widths << 'action'.size
|
61
|
+
column_widths << 'id'.size
|
62
|
+
|
63
|
+
row.data_column_names.each.with_index(3) do |column_name, i|
|
64
|
+
cell = sheet.add_cell(0, i, column_name)
|
65
|
+
cell.change_font_bold(true)
|
66
|
+
cell.change_fill('ddedf3') # color: blue
|
67
|
+
column_widths << column_name.size
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def add_body_rows(sheet, rows, column_widths)
|
72
|
+
rows.each.with_index(1) do |row, i|
|
73
|
+
column_widths[0] = row.id.to_s.size if column_widths[0] < row.id.to_s.size
|
74
|
+
column_widths[2] = row.transaction_id.to_s.size if column_widths[2] < row.transaction_id.to_s.size
|
75
|
+
sheet.add_cell(i, 0, row.id)
|
76
|
+
sheet.add_cell(i, 1, row.created_at.to_s(:db))
|
77
|
+
sheet.add_cell(i, 2, row.action)
|
78
|
+
sheet.add_cell(i, 3, row.transaction_id)
|
79
|
+
|
80
|
+
data = row.action == 'DELETE' ? row.before_data : row.after_data
|
81
|
+
|
82
|
+
row.data_column_names.each.with_index(3) do |column_name, j|
|
83
|
+
value = data[column_name].to_s
|
84
|
+
column_widths[j] = value.size if column_widths[j] < value.size
|
85
|
+
cell_object = sheet.add_cell(i, j, value)
|
86
|
+
if row.action == 'UPDATE' && column_name != 'updated_at' && row.changed_attribute_columns.include?(column_name)
|
87
|
+
cell_object.change_fill('ffff00') # color: yellow
|
88
|
+
elsif row.action == 'DELETE'
|
89
|
+
cell_object.change_fill('d9d9d9') # color: grey
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
def add_style(sheet)
|
96
|
+
sheet.sheet_data.rows.each.with_index do |row, i|
|
97
|
+
row.cells.each do |cell|
|
98
|
+
%i[top bottom left right].each do |direction|
|
99
|
+
cell.change_border(direction, 'thin')
|
100
|
+
end
|
101
|
+
|
102
|
+
cell.change_border(:bottom, 'double') if i.zero?
|
103
|
+
end
|
104
|
+
end
|
105
|
+
sheet.change_row_horizontal_alignment(0, 'center')
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
require 'active_record'
|
2
|
+
require 'breathing'
|
3
|
+
require 'breathing/trigger'
|
4
|
+
require 'breathing/change_log'
|
5
|
+
|
6
|
+
module Breathing
|
7
|
+
class UnsupportedError < StandardError; end
|
8
|
+
|
9
|
+
class Installer
|
10
|
+
def install
|
11
|
+
raise Breathing::UnsupportedError, "Version MySQL 5.6 is not supported." unless database_version_valid?
|
12
|
+
|
13
|
+
create_log_table unless log_table_exists?
|
14
|
+
|
15
|
+
models.each do |model|
|
16
|
+
column_names = model.columns.map(&:name)
|
17
|
+
if column_names.include?('id') && column_names.include?('updated_at')
|
18
|
+
Breathing::Trigger.new(model, log_table_name).create
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def uninstall
|
24
|
+
drop_log_table if log_table_exists?
|
25
|
+
|
26
|
+
models.each { |model| Breathing::Trigger.new(model, log_table_name).drop }
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def database_version_valid?
|
32
|
+
connection = ActiveRecord::Base.connection
|
33
|
+
connection.adapter_name == "PostgreSQL" || (connection.adapter_name == 'Mysql2' && connection.raw_connection.info[:version].to_f >= 5.7)
|
34
|
+
end
|
35
|
+
|
36
|
+
def log_table_name
|
37
|
+
Breathing::ChangeLog.table_name
|
38
|
+
end
|
39
|
+
|
40
|
+
def create_log_table(table_name: log_table_name)
|
41
|
+
ActiveRecord::Schema.define version: 0 do
|
42
|
+
create_table table_name, force: false do |t|
|
43
|
+
t.string :action, null: false
|
44
|
+
t.string :table_name, null: false
|
45
|
+
t.string :transaction_id, null: false
|
46
|
+
t.json :before_data, null: false
|
47
|
+
t.json :after_data, null: false
|
48
|
+
t.datetime :created_at, null: false, index: true
|
49
|
+
t.index %w[table_name transaction_id]
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def drop_log_table
|
55
|
+
sql = "DROP TABLE #{log_table_name}"
|
56
|
+
puts sql
|
57
|
+
ActiveRecord::Base.connection.execute(sql)
|
58
|
+
end
|
59
|
+
|
60
|
+
def log_table_exists?
|
61
|
+
ActiveRecord::Base.connection.table_exists?(log_table_name)
|
62
|
+
end
|
63
|
+
|
64
|
+
def models
|
65
|
+
ignores = %w[schema_migrations ar_internal_metadata] << log_table_name
|
66
|
+
|
67
|
+
ActiveRecord::Base.connection.tables.each do |table_name|
|
68
|
+
next if ignores.include?(table_name) || Object.const_defined?(table_name.classify)
|
69
|
+
|
70
|
+
eval <<-EOS
|
71
|
+
class #{table_name.classify} < ActiveRecord::Base
|
72
|
+
self.table_name = :#{table_name}
|
73
|
+
end
|
74
|
+
EOS
|
75
|
+
end
|
76
|
+
|
77
|
+
ActiveRecord::Base.descendants.reject(&:abstract_class).reject { |m| ignores.include? m.table_name }
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
require 'breathing'
|
2
|
+
require 'terminal-table'
|
3
|
+
|
4
|
+
module Breathing
|
5
|
+
class TerminalTable
|
6
|
+
attr_reader :last_id
|
7
|
+
|
8
|
+
def initialize(table_name)
|
9
|
+
@last_id = 1
|
10
|
+
@table_name = table_name
|
11
|
+
end
|
12
|
+
|
13
|
+
def render(id: 1)
|
14
|
+
rows = Breathing::ChangeLog.where(table_name: @table_name).where("id >= ? ", id).order(:id)
|
15
|
+
|
16
|
+
return if rows.size.zero?
|
17
|
+
|
18
|
+
@table = Terminal::Table.new(title: rows.first.table_name,
|
19
|
+
headings: rows.first.data_attributes.keys,
|
20
|
+
rows: rows.map { |row| row.data_attributes.values })
|
21
|
+
|
22
|
+
@last_id = rows.last.id
|
23
|
+
@table.to_s
|
24
|
+
end
|
25
|
+
|
26
|
+
def rows
|
27
|
+
@table.rows
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,96 @@
|
|
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
|
+
|
16
|
+
unless exists?(trigger_name)
|
17
|
+
puts "CREATE TRIGGER #{trigger_name}"
|
18
|
+
|
19
|
+
ActiveRecord::Base.connection.create_trigger(trigger_name).on(model.table_name).after(:insert) do
|
20
|
+
<<-SQL
|
21
|
+
INSERT INTO #{log_table_name} (action, table_name, transaction_id, before_data, after_data, created_at)
|
22
|
+
VALUES ('INSERT', '#{model.table_name}', NEW.id,
|
23
|
+
'{}',
|
24
|
+
#{row_to_json(model.columns, 'NEW')},
|
25
|
+
CURRENT_TIMESTAMP);
|
26
|
+
SQL
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
trigger_name = "#{log_table_name}_update_#{model.table_name}"
|
31
|
+
unless exists?(trigger_name)
|
32
|
+
puts "CREATE TRIGGER #{trigger_name}"
|
33
|
+
|
34
|
+
ActiveRecord::Base.connection.create_trigger(trigger_name).on(model.table_name).before(:update).of(:updated_at) do
|
35
|
+
<<-SQL
|
36
|
+
INSERT INTO #{log_table_name} (action, table_name, transaction_id, before_data, after_data, created_at)
|
37
|
+
VALUES ('UPDATE', '#{model.table_name}', NEW.id,
|
38
|
+
#{row_to_json(model.columns, 'OLD')},
|
39
|
+
#{row_to_json(model.columns, 'NEW')},
|
40
|
+
CURRENT_TIMESTAMP);
|
41
|
+
SQL
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
trigger_name = "#{log_table_name}_delete_#{model.table_name}"
|
46
|
+
unless exists?(trigger_name)
|
47
|
+
puts "CREATE TRIGGER #{trigger_name}"
|
48
|
+
ActiveRecord::Base.connection.create_trigger(trigger_name).on(model.table_name).after(:delete) do
|
49
|
+
<<-SQL
|
50
|
+
INSERT INTO #{log_table_name} (action, table_name, transaction_id, before_data, after_data, created_at)
|
51
|
+
VALUES ('DELETE', '#{model.table_name}', OLD.id,
|
52
|
+
#{row_to_json(model.columns, 'OLD')},
|
53
|
+
'{}',
|
54
|
+
CURRENT_TIMESTAMP);
|
55
|
+
SQL
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def drop
|
61
|
+
%w[insert update delete].each do |action|
|
62
|
+
trigger_name = "#{log_table_name}_#{action}_#{model.table_name}"
|
63
|
+
|
64
|
+
begin
|
65
|
+
sql = "DROP TRIGGER IF EXISTS #{trigger_name}"
|
66
|
+
if ActiveRecord::Base.connection.adapter_name == "PostgreSQL"
|
67
|
+
sql << " ON #{model.table_name} CASCADE;"
|
68
|
+
sql << " DROP FUNCTION IF EXISTS #{trigger_name} CASCADE;"
|
69
|
+
end
|
70
|
+
puts sql
|
71
|
+
ActiveRecord::Base.connection.execute(sql)
|
72
|
+
rescue StandardError => e
|
73
|
+
puts "#{e.message} trigger_name:#{trigger_name}"
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
private
|
79
|
+
|
80
|
+
def exists?(trigger_name)
|
81
|
+
ActiveRecord::Base.connection.triggers.keys.include?(trigger_name)
|
82
|
+
end
|
83
|
+
|
84
|
+
def row_to_json(columns, state)
|
85
|
+
if ActiveRecord::Base.connection.adapter_name == "PostgreSQL"
|
86
|
+
"row_to_json(#{state}.*)"
|
87
|
+
else
|
88
|
+
json_object_values = columns.each.with_object([]) do |column, array|
|
89
|
+
array << "'#{column.name}'"
|
90
|
+
array << "#{state}.#{column.name}"
|
91
|
+
end
|
92
|
+
"JSON_OBJECT(#{json_object_values.join(',')})"
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
data/spec/app.rb
CHANGED
@@ -2,7 +2,7 @@ require 'active_record'
|
|
2
2
|
require 'breathing'
|
3
3
|
|
4
4
|
ActiveRecord::Base.establish_connection(
|
5
|
-
YAML.load(ERB.new(File.read('spec/database.yml')).result)['
|
5
|
+
YAML.load(ERB.new(File.read('spec/database.yml')).result)["test_#{ENV['DB'] || 'mysql'}"]
|
6
6
|
)
|
7
7
|
|
8
8
|
ActiveRecord::Schema.define version: 0 do
|
@@ -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,36 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Breathing::Excel do
|
4
|
+
describe '#create' do
|
5
|
+
before { Breathing::Installer.new.install }
|
6
|
+
after do
|
7
|
+
Breathing::Installer.new.uninstall if ActiveRecord::Base.connection.adapter_name == "Mysql2"
|
8
|
+
end
|
9
|
+
|
10
|
+
it do
|
11
|
+
user = User.create!(name: 'a', age: 20)
|
12
|
+
user.update!(age: 21)
|
13
|
+
user.destroy!
|
14
|
+
expect(Breathing::ChangeLog.count).to eq(3)
|
15
|
+
|
16
|
+
Tempfile.open(['tmp', '.xlsx']) do |file|
|
17
|
+
Breathing::Excel.new.create(file_name: file.path)
|
18
|
+
workbook = RubyXL::Parser.parse(file.path)
|
19
|
+
expect(workbook.sheets[0].name).to eq('users')
|
20
|
+
user_sheet = workbook.worksheets[0]
|
21
|
+
expect(user_sheet.sheet_data.size).to eq(Breathing::ChangeLog.where(table_name: :users).count + 1)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
it 'multi sheets' do
|
26
|
+
User.create!(name: 'a', age: 20)
|
27
|
+
Department.create!(name: 'a')
|
28
|
+
|
29
|
+
Tempfile.open(['tmp', '.xlsx']) do |file|
|
30
|
+
Breathing::Excel.new.create(file_name: file.path)
|
31
|
+
workbook = RubyXL::Parser.parse(file.path)
|
32
|
+
expect(workbook.sheets.size).to eq(2)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Breathing::TerminalTable do
|
4
|
+
describe '#render' do
|
5
|
+
before { Breathing::Installer.new.install }
|
6
|
+
after do
|
7
|
+
Breathing::Installer.new.uninstall if ActiveRecord::Base.connection.adapter_name == "Mysql2"
|
8
|
+
end
|
9
|
+
|
10
|
+
it do
|
11
|
+
user = User.create!(name: 'a', age: 20)
|
12
|
+
user.update!(age: 21)
|
13
|
+
user.destroy!
|
14
|
+
expect(Breathing::ChangeLog.count).to eq(3)
|
15
|
+
|
16
|
+
table = Breathing::TerminalTable.new(:users)
|
17
|
+
puts table.render(id: 1)
|
18
|
+
|
19
|
+
expect(table.rows.size).to eq(3)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
data/spec/breathing_spec.rb
CHANGED
@@ -4,4 +4,42 @@ 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
|
+
describe 'change_logs' do
|
9
|
+
before { Breathing::Installer.new.install }
|
10
|
+
|
11
|
+
after do
|
12
|
+
Breathing::Installer.new.uninstall if ActiveRecord::Base.connection.adapter_name == "Mysql2"
|
13
|
+
end
|
14
|
+
|
15
|
+
it do
|
16
|
+
expect(Breathing::ChangeLog.count).to eq(0)
|
17
|
+
|
18
|
+
# INSERT
|
19
|
+
user = User.create!(name: 'a', age: 20)
|
20
|
+
expect(Breathing::ChangeLog.count).to eq(1)
|
21
|
+
|
22
|
+
log = Breathing::ChangeLog.where(table_name: user.class.table_name, transaction_id: user.id).last
|
23
|
+
expect(log.before_data).to eq({})
|
24
|
+
expect(log.after_data['name']).to eq('a')
|
25
|
+
expect(log.after_data['age']).to eq(20)
|
26
|
+
|
27
|
+
# UPDATE
|
28
|
+
user.update!(age: 21)
|
29
|
+
expect(Breathing::ChangeLog.count).to eq(2)
|
30
|
+
|
31
|
+
log = Breathing::ChangeLog.where(table_name: user.class.table_name, transaction_id: user.id).last
|
32
|
+
expect(log.before_data['age']).to eq(20)
|
33
|
+
expect(log.after_data['age']).to eq(21)
|
34
|
+
expect(log.before_data['name']).to eq(log.after_data['name'])
|
35
|
+
expect(log.changed_attribute_columns).to eq(%w[age updated_at])
|
36
|
+
|
37
|
+
# DELETE
|
38
|
+
user.destroy!
|
39
|
+
expect(Breathing::ChangeLog.count).to eq(3)
|
40
|
+
log = Breathing::ChangeLog.where(table_name: user.class.table_name, transaction_id: user.id).last
|
41
|
+
expect(log.before_data['name']).to eq('a')
|
42
|
+
expect(log.after_data).to eq({})
|
43
|
+
end
|
44
|
+
end
|
7
45
|
end
|
data/spec/database.yml
CHANGED
@@ -1,11 +1,18 @@
|
|
1
|
-
|
1
|
+
test_mysql:
|
2
2
|
adapter: mysql2
|
3
3
|
encoding: utf8mb4
|
4
4
|
charset: utf8mb4
|
5
5
|
collation: utf8mb4_general_ci
|
6
|
-
pool: 5
|
7
6
|
username: <%= ENV.fetch("DB_USER") { 'root' } %>
|
8
7
|
password: <%= ENV.fetch("DB_PASS") { '' } %>
|
9
8
|
host: <%= ENV.fetch("DB_HOST") { '127.0.0.1' } %>
|
10
9
|
socket: /tmp/mysql.sock
|
11
|
-
database:
|
10
|
+
database: breathing_test
|
11
|
+
|
12
|
+
test_pg:
|
13
|
+
adapter: postgresql
|
14
|
+
encoding: unicode
|
15
|
+
username: <%= ENV.fetch("DB_USER") { 'root' } %>
|
16
|
+
password: <%= ENV.fetch("DB_PASS") { '' } %>
|
17
|
+
host: <%= ENV.fetch("DB_HOST") { '127.0.0.1' } %>
|
18
|
+
database: breathing_test
|
data/spec/spec_helper.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: breathing
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.6
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Akira Kusumoto
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2020-12-
|
11
|
+
date: 2020-12-18 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: thor
|
@@ -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: hairtrigger
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
41
55
|
- !ruby/object:Gem::Dependency
|
42
56
|
name: mysql2
|
43
57
|
requirement: !ruby/object:Gem::Requirement
|
@@ -45,13 +59,55 @@ dependencies:
|
|
45
59
|
- - ">="
|
46
60
|
- !ruby/object:Gem::Version
|
47
61
|
version: '0'
|
48
|
-
type: :
|
62
|
+
type: :runtime
|
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: pg
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :runtime
|
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: terminal-table
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
type: :runtime
|
49
91
|
prerelease: false
|
50
92
|
version_requirements: !ruby/object:Gem::Requirement
|
51
93
|
requirements:
|
52
94
|
- - ">="
|
53
95
|
- !ruby/object:Gem::Version
|
54
96
|
version: '0'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: rubyXL
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - ">="
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: 3.4.0
|
104
|
+
type: :runtime
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - ">="
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: 3.4.0
|
55
111
|
- !ruby/object:Gem::Dependency
|
56
112
|
name: pry-byebug
|
57
113
|
requirement: !ruby/object:Gem::Requirement
|
@@ -80,7 +136,7 @@ dependencies:
|
|
80
136
|
- - "~>"
|
81
137
|
- !ruby/object:Gem::Version
|
82
138
|
version: '3.9'
|
83
|
-
description:
|
139
|
+
description: Audit logging for database
|
84
140
|
email:
|
85
141
|
- akirakusumo10@gmail.com
|
86
142
|
executables:
|
@@ -88,6 +144,7 @@ executables:
|
|
88
144
|
extensions: []
|
89
145
|
extra_rdoc_files: []
|
90
146
|
files:
|
147
|
+
- ".circleci/config.yml"
|
91
148
|
- ".gitignore"
|
92
149
|
- Gemfile
|
93
150
|
- MIT-LICENSE
|
@@ -96,8 +153,15 @@ files:
|
|
96
153
|
- breathing.gemspec
|
97
154
|
- exe/breathing
|
98
155
|
- lib/breathing.rb
|
156
|
+
- lib/breathing/change_log.rb
|
99
157
|
- lib/breathing/cli.rb
|
158
|
+
- lib/breathing/excel.rb
|
159
|
+
- lib/breathing/installer.rb
|
160
|
+
- lib/breathing/terminal_table.rb
|
161
|
+
- lib/breathing/trigger.rb
|
100
162
|
- spec/app.rb
|
163
|
+
- spec/breathing/excel_spec.rb
|
164
|
+
- spec/breathing/terminal_table_spec.rb
|
101
165
|
- spec/breathing_spec.rb
|
102
166
|
- spec/database.yml
|
103
167
|
- spec/spec_helper.rb
|
@@ -123,9 +187,11 @@ requirements: []
|
|
123
187
|
rubygems_version: 3.0.3
|
124
188
|
signing_key:
|
125
189
|
specification_version: 4
|
126
|
-
summary:
|
190
|
+
summary: Audit logging for database
|
127
191
|
test_files:
|
128
192
|
- spec/app.rb
|
193
|
+
- spec/breathing/excel_spec.rb
|
194
|
+
- spec/breathing/terminal_table_spec.rb
|
129
195
|
- spec/breathing_spec.rb
|
130
196
|
- spec/database.yml
|
131
197
|
- spec/spec_helper.rb
|