logidze 0.4.1 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 17de700c04926cec63c3498e6568b889cfd240b9
4
- data.tar.gz: 3dd305a8537eec34a7c68f0c318b9150b162205d
3
+ metadata.gz: c8e3b2e4793fe16e96a40e2059df0b137be3a6b0
4
+ data.tar.gz: 96bdab4a164cdb0180d6b91b602b1d5831245512
5
5
  SHA512:
6
- metadata.gz: bc4e0b72623d6d7419e444c973ea01138eccd9b41e21a4712323f2ee2a88df5389fa13acc7ad9be00d96e50b272c7c2ab3d98e85f180f96c294b0f834a8cebf1
7
- data.tar.gz: 12c1f0cb5e81aba650a91a840a1911831219c8f5a9c972024f976d8369abd277156851212c5c4ce6ef143e9fe359e44ee6576e9b2b79e00083da1b01a717fd9b
6
+ metadata.gz: 6b5eccd3de9563162195ca6e8350ed3136a696085485bdd95a806e6eef449b2d76b9690b4aea6558773d39df321be5bbb8998b97f5461907763643bb7f8800e2
7
+ data.tar.gz: 1b1389f59b82cb3fc637151e3c8561cea387a7d83a58dbf93805d619916b34ca0a682e0be54d88bb934ff4caa1697d2ed2986167837234fdf36f8d6513844a4b
data/.rubocop.yml CHANGED
@@ -23,7 +23,7 @@ Style/Documentation:
23
23
  Exclude:
24
24
  - 'spec/**/*.rb'
25
25
 
26
- Style/StringLiterals:
26
+ Style/StringLiterals:
27
27
  Enabled: false
28
28
 
29
29
  Style/SpaceInsideStringInterpolation:
@@ -49,4 +49,15 @@ Rails/Date:
49
49
  Enabled: false
50
50
 
51
51
  Rails/TimeZone:
52
- Enabled: false
52
+ Enabled: false
53
+
54
+ Style/NumericLiteralPrefix:
55
+ Enabled: false
56
+
57
+ Lint/HandleExceptions:
58
+ Enabled: true
59
+ Exclude:
60
+ - 'spec/**/*.rb'
61
+
62
+ Style/DotPosition:
63
+ EnforcedStyle: leading
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Change log
2
2
 
3
+ ## 0.5.0 (2017-03-28)
4
+
5
+ - Add an option to preserve future versions. ([@akxcv][])
6
+
7
+ - Add `--timestamp_column` option to model migration generator. ([@akxcv][])
8
+
9
+ - Default version timestamp to timestamp column. ([@akxcv][])
10
+
11
+ - Associations versioning. ([@charlie-wasp][])
12
+
3
13
  ## 0.4.1 (2017-02-06)
4
14
 
5
15
  - Add `--path` option to model migration generator. ([@palkan][])
@@ -36,3 +46,4 @@
36
46
 
37
47
  [@palkan]: https://github.com/palkan
38
48
  [@charlie-wasp]: https://github.com/charlie-wasp
49
+ [@akxcv]: https://github.com/akxcv
data/README.md CHANGED
@@ -75,6 +75,20 @@ By default, Logidze tries to infer the path to the model file from the model nam
75
75
  rails generate logidze:model Post --path "app/models/custom/post.rb"
76
76
  ```
77
77
 
78
+ By default, Logidze tries to get a timestamp for a version from record's `updated_at` field whenever appropriate. If
79
+ your model does not have that column, Logidze will gracefully fall back to `statement_timestamp()`.
80
+ To change the column name or disable this feature completely, you can use the `timestamp_column` option:
81
+
82
+ ```ruby
83
+ # will try to get the timestamp value from `time` column
84
+ rails generate logidze:model Post --timestamp_column time
85
+ # will always set version timestamp to `statement_timestamp()`
86
+ rails generate logidze:model Post --timestamp_column nil # "null" and "false" will also work
87
+ ```
88
+
89
+ Logidze also supports associations versioning. It is experimental feature, and disabled by default. You can learn more
90
+ in the [wiki](https://github.com/palkan/logidze/wiki/Associations-versioning).
91
+
78
92
  ## Troubleshooting
79
93
 
80
94
  The most common problem is `"permission denied to set parameter "logidze.xxx"` caused by `ALTER DATABASE ...` query.
@@ -157,7 +171,24 @@ post.redo!
157
171
  post.switch_to!(2)
158
172
  ```
159
173
 
160
- If you update record after `#undo!` or `#switch_to!` you lose all "future" versions and `#redo!` is no longer possible.
174
+ Normally, if you update record after `#undo!` or `#switch_to!` you lose all "future" versions and `#redo!` is no
175
+ longer possible. However, you can provide an `append: true` option to `#undo!` or `#switch_to!`, which will
176
+ create a new version with old data. Caveat: when switching to a newer version, `append` will have no effect.
177
+
178
+ ```ruby
179
+ post = Post.create!(title: 'first post') # v1
180
+ post.update!(title: 'new title') # v2
181
+ post.undo!(append: true) # v3 (with same attributes as v1)
182
+ ```
183
+
184
+ Note that `redo!` will not work after `undo!(append: true)` because the latter will create a new version
185
+ instead of rolling back to an old one.
186
+ Alternatively, you can configure Logidze to always default to `append: true`.
187
+
188
+ ```ruby
189
+ Logidze.append_on_undo = true
190
+ ```
191
+
161
192
 
162
193
  ## Track responsibility (aka _whodunnit_)
163
194
 
@@ -19,17 +19,19 @@ class <%= @migration_class_name %> < ActiveRecord::Migration
19
19
  execute <<-SQL
20
20
  DROP FUNCTION IF EXISTS logidze_version(bigint, jsonb);
21
21
  DROP FUNCTION IF EXISTS logidze_snapshot(jsonb);
22
+ DROP FUNCTION IF EXISTS logidze_version(bigint, jsonb, text[]);
23
+ DROP FUNCTION IF EXISTS logidze_snapshot(jsonb, text[]);
22
24
  SQL
23
25
  <% end %>
24
26
 
25
27
  execute <<-SQL
26
- CREATE OR REPLACE FUNCTION logidze_version(v bigint, data jsonb, blacklist text[] DEFAULT '{}') RETURNS jsonb AS $body$
28
+ CREATE OR REPLACE FUNCTION logidze_version(v bigint, data jsonb, ts timestamp with time zone, blacklist text[] DEFAULT '{}') RETURNS jsonb AS $body$
27
29
  DECLARE
28
30
  buf jsonb;
29
31
  BEGIN
30
32
  buf := jsonb_build_object(
31
33
  'ts',
32
- (extract(epoch from now()) * 1000)::bigint,
34
+ (extract(epoch from ts) * 1000)::bigint,
33
35
  'v',
34
36
  v,
35
37
  'c',
@@ -43,12 +45,19 @@ class <%= @migration_class_name %> < ActiveRecord::Migration
43
45
  $body$
44
46
  LANGUAGE plpgsql;
45
47
 
46
- CREATE OR REPLACE FUNCTION logidze_snapshot(item jsonb, blacklist text[] DEFAULT '{}') RETURNS jsonb AS $body$
48
+ CREATE OR REPLACE FUNCTION logidze_snapshot(item jsonb, ts_column text, blacklist text[] DEFAULT '{}') RETURNS jsonb AS $body$
49
+ DECLARE
50
+ ts timestamp with time zone;
47
51
  BEGIN
52
+ IF ts_column IS NULL THEN
53
+ ts := statement_timestamp();
54
+ ELSE
55
+ ts := coalesce((item->>ts_column)::timestamp with time zone, statement_timestamp());
56
+ END IF;
48
57
  return json_build_object(
49
58
  'v', 1,
50
59
  'h', jsonb_build_array(
51
- logidze_version(1, item, blacklist)
60
+ logidze_version(1, item, ts, blacklist)
52
61
  )
53
62
  );
54
63
  END;
@@ -62,7 +71,7 @@ class <%= @migration_class_name %> < ActiveRecord::Migration
62
71
  BEGIN
63
72
  res := obj;
64
73
  FOREACH key IN ARRAY keys
65
- LOOP
74
+ LOOP
66
75
  res := res - key;
67
76
  END LOOP;
68
77
  RETURN res;
@@ -93,7 +102,7 @@ class <%= @migration_class_name %> < ActiveRecord::Migration
93
102
  jsonb_set(
94
103
  log_data->'h',
95
104
  '{1}',
96
- merged
105
+ merged
97
106
  ) - 0
98
107
  );
99
108
  END;
@@ -111,23 +120,35 @@ class <%= @migration_class_name %> < ActiveRecord::Migration
111
120
  iterator integer;
112
121
  item record;
113
122
  columns_blacklist text[];
123
+ ts timestamp with time zone;
124
+ ts_column text;
114
125
  BEGIN
115
- columns_blacklist := TG_ARGV[1];
126
+ ts_column := NULLIF(TG_ARGV[1], 'null');
127
+ columns_blacklist := TG_ARGV[2];
116
128
 
117
129
  IF TG_OP = 'INSERT' THEN
118
130
 
119
- NEW.log_data := logidze_snapshot(to_jsonb(NEW.*), columns_blacklist);
120
-
131
+ NEW.log_data := logidze_snapshot(to_jsonb(NEW.*), ts_column, columns_blacklist);
132
+
121
133
  ELSIF TG_OP = 'UPDATE' THEN
122
-
134
+
123
135
  IF OLD.log_data is NULL OR OLD.log_data = '{}'::jsonb THEN
124
- NEW.log_data := logidze_snapshot(to_jsonb(NEW.*), columns_blacklist);
136
+ NEW.log_data := logidze_snapshot(to_jsonb(NEW.*), ts_column, columns_blacklist);
125
137
  RETURN NEW;
126
138
  END IF;
127
139
 
128
140
  history_limit := NULLIF(TG_ARGV[0], 'null');
129
141
  current_version := (NEW.log_data->>'v')::int;
130
142
 
143
+ IF ts_column IS NULL THEN
144
+ ts := statement_timestamp();
145
+ ELSE
146
+ ts := (to_jsonb(NEW.*)->>ts_column)::timestamp with time zone;
147
+ IF ts IS NULL OR ts = (to_jsonb(OLD.*)->>ts_column)::timestamp with time zone THEN
148
+ ts := statement_timestamp();
149
+ END IF;
150
+ END IF;
151
+
131
152
  IF NEW = OLD THEN
132
153
  RETURN NEW;
133
154
  END IF;
@@ -158,7 +179,7 @@ class <%= @migration_class_name %> < ActiveRecord::Migration
158
179
  NEW.log_data := jsonb_set(
159
180
  NEW.log_data,
160
181
  ARRAY['h', size::text],
161
- logidze_version(new_v, changes, columns_blacklist),
182
+ logidze_version(new_v, changes, ts, columns_blacklist),
162
183
  true
163
184
  );
164
185
 
@@ -183,9 +204,9 @@ class <%= @migration_class_name %> < ActiveRecord::Migration
183
204
  def down
184
205
  <% unless update? %>
185
206
  execute <<-SQL
186
- DROP FUNCTION logidze_version(bigint, jsonb, text[]) CASCADE;
207
+ DROP FUNCTION logidze_version(bigint, jsonb, timestamp with time zone, text[]) CASCADE;
187
208
  DROP FUNCTION logidze_compact_history(jsonb) CASCADE;
188
- DROP FUNCTION logidze_snapshot(jsonb, text[]) CASCADE;
209
+ DROP FUNCTION logidze_snapshot(jsonb, text, text[]) CASCADE;
189
210
  DROP FUNCTION logidze_logger() CASCADE;
190
211
  SQL
191
212
  <% end %>
@@ -1,3 +1,4 @@
1
+ # rubocop:disable Metrics/BlockLength
1
2
  # frozen_string_literal: true
2
3
  require "rails/generators"
3
4
  require "rails/generators/active_record/migration/migration_generator"
@@ -20,6 +21,9 @@ module Logidze
20
21
  class_option :blacklist, type: :array, optional: true
21
22
  class_option :whitelist, type: :array, optional: true
22
23
 
24
+ class_option :timestamp_column, type: :string, optional: true,
25
+ desc: "Specify timestamp column"
26
+
23
27
  def generate_migration
24
28
  if options[:blacklist] && options[:whitelist]
25
29
  $stderr.puts "Use only one: --whitelist or --blacklist"
@@ -62,30 +66,46 @@ module Logidze
62
66
  class_name.constantize.column_names - options[:whitelist]
63
67
  end
64
68
 
65
- array || []
69
+ format_pgsql_array(array)
70
+ end
71
+
72
+ def timestamp_column
73
+ value = options[:timestamp_column] || 'updated_at'
74
+ return if %w(nil null false).include?(value)
75
+ escape_pgsql_string(value)
66
76
  end
67
77
 
68
78
  def logidze_logger_parameters
69
- if limit.nil? && columns_blacklist.empty?
70
- ''
71
- elsif !limit.nil? && columns_blacklist.empty?
72
- limit
73
- elsif !limit.nil? && !columns_blacklist.empty?
74
- "#{limit}, #{format_pgsql_array(columns_blacklist)}"
75
- elsif limit.nil? && !columns_blacklist.empty?
76
- "null, #{format_pgsql_array(columns_blacklist)}"
77
- end
79
+ format_pgsql_args(limit, timestamp_column, columns_blacklist)
78
80
  end
79
81
 
80
82
  def logidze_snapshot_parameters
81
- return 'to_jsonb(t)' if columns_blacklist.empty?
82
-
83
- "to_jsonb(t), #{format_pgsql_array(columns_blacklist)}"
83
+ format_pgsql_args('to_jsonb(t)', timestamp_column, columns_blacklist)
84
84
  end
85
85
 
86
86
  def format_pgsql_array(ruby_array)
87
+ return if ruby_array.blank?
87
88
  "'{" + ruby_array.join(', ') + "}'"
88
89
  end
90
+
91
+ def escape_pgsql_string(string)
92
+ return if string.blank?
93
+ "'#{string}'"
94
+ end
95
+
96
+ # Convenience method for formatting pg arguments.
97
+ # Some examples:
98
+ # format_pgsql_args('a', 'b', nil) #=> "a, b"
99
+ # format_pgsql_args(nil, '', 'c') #=> "null, null, c"
100
+ # format_pgsql_args('a', '', []) #=> "a"
101
+ def format_pgsql_args(*values)
102
+ args = []
103
+ values.reverse_each do |value|
104
+ formatted_value = value.presence || (args.any? && 'null')
105
+ args << formatted_value if formatted_value
106
+ end
107
+ args.compact.reverse.join(', ')
108
+ end
89
109
  end
90
110
 
91
111
  private
@@ -1,7 +1,7 @@
1
1
  class <%= @migration_class_name %> < ActiveRecord::Migration
2
2
  require 'logidze/migration'
3
3
  include Logidze::Migration
4
-
4
+
5
5
  def up
6
6
  <% unless only_trigger? %>
7
7
  add_column :<%= table_name %>, :log_data, :jsonb
data/lib/logidze/model.rb CHANGED
@@ -33,23 +33,38 @@ module Logidze
33
33
  def without_logging(&block)
34
34
  Logidze.without_logging(&block)
35
35
  end
36
+
37
+ def has_logidze?
38
+ true
39
+ end
36
40
  end
37
41
 
38
42
  # Use this to convert Ruby time to milliseconds
39
43
  TIME_FACTOR = 1_000
40
44
 
45
+ attr_accessor :logidze_requested_ts
46
+
41
47
  # Return a dirty copy of record at specified time
42
48
  # If time is less then the first version, then return nil.
43
49
  # If time is greater then the last version, then return self.
44
50
  def at(ts)
45
51
  ts = parse_time(ts)
52
+
46
53
  return nil unless log_data.exists_ts?(ts)
47
- return self if log_data.current_ts?(ts)
54
+
55
+ if log_data.current_ts?(ts)
56
+ self.logidze_requested_ts = ts
57
+ return self
58
+ end
48
59
 
49
60
  version = log_data.find_by_time(ts).version
50
61
 
51
62
  object_at = dup
52
63
  object_at.apply_diff(version, log_data.changes_to(version: version))
64
+ object_at.id = id
65
+ object_at.logidze_requested_ts = ts
66
+
67
+ object_at
53
68
  end
54
69
 
55
70
  # Revert record to the version at specified time (without saving to DB)
@@ -93,10 +108,10 @@ module Logidze
93
108
 
94
109
  # Restore record to the previous version.
95
110
  # Return false if no previous version found, otherwise return updated record.
96
- def undo!
111
+ def undo!(append: Logidze.append_on_undo)
97
112
  version = log_data.previous_version
98
113
  return false if version.nil?
99
- switch_to!(version.version)
114
+ switch_to!(version.version, append: append)
100
115
  end
101
116
 
102
117
  # Restore record to the _future_ version (if `undo!` was applied)
@@ -109,9 +124,38 @@ module Logidze
109
124
 
110
125
  # Restore record to the specified version.
111
126
  # Return false if version is unknown.
112
- def switch_to!(version)
113
- return false unless at_version!(version)
114
- self.class.without_logging { save! }
127
+ def switch_to!(version, append: Logidze.append_on_undo)
128
+ return false unless at_version(version)
129
+
130
+ if append && version < log_version
131
+ update!(log_data.changes_to(version: version))
132
+ else
133
+ at_version!(version)
134
+ self.class.without_logging { save! }
135
+ end
136
+ end
137
+
138
+ def association(name)
139
+ association = super
140
+
141
+ return association unless Logidze.associations_versioning
142
+
143
+ should_appply_logidze =
144
+ logidze_past? &&
145
+ association.klass.respond_to?(:has_logidze?) &&
146
+ !association.singleton_class.include?(Logidze::VersionedAssociation)
147
+
148
+ return association unless should_appply_logidze
149
+
150
+ association.singleton_class.prepend Logidze::VersionedAssociation
151
+
152
+ if association.is_a? ActiveRecord::Associations::CollectionAssociation
153
+ association.singleton_class.prepend(
154
+ Logidze::VersionedAssociation::CollectionAssociation
155
+ )
156
+ end
157
+
158
+ association
115
159
  end
116
160
 
117
161
  protected
@@ -122,6 +166,12 @@ module Logidze
122
166
  self
123
167
  end
124
168
 
169
+ def logidze_past?
170
+ return false unless @logidze_requested_ts
171
+
172
+ @logidze_requested_ts < Time.now.to_i * TIME_FACTOR
173
+ end
174
+
125
175
  def parse_time(ts)
126
176
  case ts
127
177
  when Numeric
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module Logidze
3
- VERSION = "0.4.1"
3
+ VERSION = "0.5.0"
4
4
  end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+ module Logidze
3
+ module VersionedAssociation
4
+ def load_target
5
+ target = super
6
+
7
+ return target if inversed
8
+
9
+ time = owner.logidze_requested_ts
10
+
11
+ if target.is_a? Array
12
+ target.map! do |object|
13
+ object.at(time)
14
+ end.compact!
15
+ else
16
+ target.at!(time)
17
+ end
18
+
19
+ target
20
+ end
21
+
22
+ def stale_target?
23
+ logidze_stale? || super
24
+ end
25
+
26
+ def logidze_stale?
27
+ return false if !loaded? || inversed
28
+
29
+ unless target.is_a?(Array)
30
+ return owner.logidze_requested_ts != target.logidze_requested_ts
31
+ end
32
+
33
+ return false if target.empty?
34
+
35
+ target.any? do |object|
36
+ owner.logidze_requested_ts != object.logidze_requested_ts
37
+ end
38
+ end
39
+
40
+ module CollectionAssociation
41
+ def ids_reader
42
+ reload unless loaded?
43
+ super
44
+ end
45
+
46
+ def empty?
47
+ reload unless loaded?
48
+ super
49
+ end
50
+ end
51
+ end
52
+ end
data/lib/logidze.rb CHANGED
@@ -6,6 +6,7 @@ require "logidze/version"
6
6
  module Logidze
7
7
  require 'logidze/history'
8
8
  require 'logidze/model'
9
+ require 'logidze/versioned_association'
9
10
  require 'logidze/has_logidze'
10
11
  require 'logidze/responsible'
11
12
 
@@ -13,6 +14,16 @@ module Logidze
13
14
 
14
15
  require 'logidze/engine' if defined?(Rails)
15
16
 
17
+ class << self
18
+ # Determines if Logidze should append a version to the log after updating an old version.
19
+ attr_accessor :append_on_undo
20
+ attr_writer :associations_versioning
21
+
22
+ def associations_versioning
23
+ @associations_versioning || false
24
+ end
25
+ end
26
+
16
27
  # Temporary disable DB triggers.
17
28
  #
18
29
  # @example
data/logidze.gemspec CHANGED
@@ -27,4 +27,5 @@ Gem::Specification.new do |spec|
27
27
  spec.add_development_dependency "simplecov", ">= 0.3.8"
28
28
  spec.add_development_dependency "ammeter", "~> 1.1.3"
29
29
  spec.add_development_dependency "pry-byebug"
30
+ spec.add_development_dependency "timecop", "~> 0.8"
30
31
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: logidze
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - palkan
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2017-02-06 00:00:00.000000000 Z
11
+ date: 2017-03-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -136,6 +136,20 @@ dependencies:
136
136
  - - ">="
137
137
  - !ruby/object:Gem::Version
138
138
  version: '0'
139
+ - !ruby/object:Gem::Dependency
140
+ name: timecop
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - "~>"
144
+ - !ruby/object:Gem::Version
145
+ version: '0.8'
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - "~>"
151
+ - !ruby/object:Gem::Version
152
+ version: '0.8'
139
153
  description: PostgreSQL JSON-based auditing
140
154
  email:
141
155
  - dementiev.vm@gmail.com
@@ -189,6 +203,7 @@ files:
189
203
  - lib/logidze/model.rb
190
204
  - lib/logidze/responsible.rb
191
205
  - lib/logidze/version.rb
206
+ - lib/logidze/versioned_association.rb
192
207
  - logidze.gemspec
193
208
  homepage: http://github.com/palkan/logidze
194
209
  licenses: