logidze 0.4.1 → 0.5.0

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
  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: