directory_diff 0.4.3 → 0.4.4

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.
Files changed (27) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +22 -3
  3. data/Gemfile +2 -0
  4. data/Rakefile +18 -0
  5. data/directory_diff.gemspec +5 -0
  6. data/lib/directory_diff/transform.rb +15 -119
  7. data/lib/directory_diff/transformer/in_memory.rb +126 -0
  8. data/lib/directory_diff/transformer/temp_table.rb +233 -0
  9. data/lib/directory_diff/version.rb +1 -1
  10. data/vendor/gems/activerecord_pg_stuff-0.0.1/.gitignore +17 -0
  11. data/vendor/gems/activerecord_pg_stuff-0.0.1/.rspec +3 -0
  12. data/vendor/gems/activerecord_pg_stuff-0.0.1/.travis.yml +9 -0
  13. data/vendor/gems/activerecord_pg_stuff-0.0.1/Gemfile +4 -0
  14. data/vendor/gems/activerecord_pg_stuff-0.0.1/LICENSE.txt +22 -0
  15. data/vendor/gems/activerecord_pg_stuff-0.0.1/README.md +72 -0
  16. data/vendor/gems/activerecord_pg_stuff-0.0.1/Rakefile +6 -0
  17. data/vendor/gems/activerecord_pg_stuff-0.0.1/activerecord_pg_stuff.gemspec +27 -0
  18. data/vendor/gems/activerecord_pg_stuff-0.0.1/lib/activerecord_pg_stuff.rb +12 -0
  19. data/vendor/gems/activerecord_pg_stuff-0.0.1/lib/activerecord_pg_stuff/connection/temporary_table.rb +22 -0
  20. data/vendor/gems/activerecord_pg_stuff-0.0.1/lib/activerecord_pg_stuff/relation/pivot.rb +54 -0
  21. data/vendor/gems/activerecord_pg_stuff-0.0.1/lib/activerecord_pg_stuff/relation/temporary_table.rb +38 -0
  22. data/vendor/gems/activerecord_pg_stuff-0.0.1/lib/activerecord_pg_stuff/version.rb +3 -0
  23. data/vendor/gems/activerecord_pg_stuff-0.0.1/spec/lib/connection_temporary_table_spec.rb +35 -0
  24. data/vendor/gems/activerecord_pg_stuff-0.0.1/spec/lib/relation_pivot_spec.rb +25 -0
  25. data/vendor/gems/activerecord_pg_stuff-0.0.1/spec/lib/relation_temporary_table_spec.rb +30 -0
  26. data/vendor/gems/activerecord_pg_stuff-0.0.1/spec/spec_helper.rb +42 -0
  27. metadata +78 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 7c6ac126c1e5f895904b1bee6f2f8c796ecf4813
4
- data.tar.gz: a3169aadf61deb623489007096313ca63f5a8a4a
3
+ metadata.gz: ea966821c6192e5570e0f3598b24f9771afb3c5b
4
+ data.tar.gz: 2c204413489b9d8c2e627204d8fae3e80593d718
5
5
  SHA512:
6
- metadata.gz: f1a5d32f44788909916340fd7106cf5024eb4d126b8a7153189509ba45299aeb461f80e7faeee9ae03dddbec21b15c136163cc2e5e5c12b0c20f3e6fa0ea553b
7
- data.tar.gz: 1e031879a48ab1090929cca737e60ebdfada9fa02031b1c667a340d05ab3b67111f839b2780b2da6e1d15a45bae01863cf6e6f5854997653f8364a95e048ea0d
6
+ metadata.gz: 6ebd1301925e3c88929d27dd896f615fa036e7a6079dc06d64a39b36a031cf30a97ea8212200ebad420455f6e5e59f85e38829a79adea1e69965a13877898e14
7
+ data.tar.gz: 113ce1ad76fa867eb54f27a5d627cae6048074c61c903568fe127b5dbbc047efe1e15a1bad4588fb1f4f32e1a2e95c10251a5a277d7902dc5c67b949ea29e737
@@ -1,5 +1,24 @@
1
1
  language: ruby
2
- rvm:
3
- - 2.3.0
4
- - 2.3.1
2
+ dist: xenial
3
+ matrix:
4
+ include:
5
+ - name: "Run Ruby 2.4.4 against pg 9"
6
+ rvm: 2.4.4
7
+ addons:
8
+ postgresql: 9.6
9
+ - name: "Run Ruby 2.4.4 against pg 10"
10
+ rvm: 2.4.4
11
+ addons:
12
+ postgresql: 10
13
+ - name: "Run Ruby 2.5.1 against pg 9"
14
+ rvm: 2.5.1
15
+ addons:
16
+ postgresql: 9.6
17
+ - name: "Run Ruby 2.5.1 against pg 10"
18
+ rvm: 2.5.1
19
+ addons:
20
+ postgresql: 10
5
21
  before_install: gem install bundler -v 1.11.2
22
+ script:
23
+ - bundle exec rake db:create
24
+ - bundle exec rake spec
data/Gemfile CHANGED
@@ -2,3 +2,5 @@ source 'https://rubygems.org'
2
2
 
3
3
  # Specify your gem's dependencies in directory_diff.gemspec
4
4
  gemspec
5
+
6
+ gem "activerecord_pg_stuff", "~> 0.0.1", path: "vendor/gems/activerecord_pg_stuff-0.0.1"
data/Rakefile CHANGED
@@ -1,6 +1,24 @@
1
1
  require "bundler/gem_tasks"
2
2
  require "rspec/core/rake_task"
3
+ require "yaml"
4
+ require "active_record"
3
5
 
4
6
  RSpec::Core::RakeTask.new(:spec)
5
7
 
6
8
  task :default => :spec
9
+
10
+ namespace :db do
11
+ db_config = YAML::load(File.open("spec/support/database.yml"))
12
+ db_config_admin = db_config.merge({"database" => "postgres", "schema_search_path" => "public"})
13
+
14
+ desc "Create the database"
15
+ task :create do
16
+ begin
17
+ ActiveRecord::Base.establish_connection(db_config_admin)
18
+ ActiveRecord::Base.connection.create_database(db_config["database"])
19
+ puts "Database created."
20
+ rescue ActiveRecord::StatementInvalid
21
+ puts "Database already exist."
22
+ end
23
+ end
24
+ end
@@ -19,6 +19,11 @@ Gem::Specification.new do |spec|
19
19
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
20
20
  spec.require_paths = ["lib"]
21
21
 
22
+ spec.add_dependency "activerecord", ">= 5.1.4"
23
+ spec.add_dependency "pg", "~> 1.1.3"
24
+ spec.add_dependency "temping", "~> 3.10.0"
25
+ spec.add_dependency "activerecord_pg_stuff", "~> 0.0.1"
26
+
22
27
  spec.add_development_dependency "bundler", "~> 1.11"
23
28
  spec.add_development_dependency "rake", "~> 10.0"
24
29
  spec.add_development_dependency "rspec", "~> 3.0"
@@ -1,134 +1,30 @@
1
+ require_relative "transformer/in_memory"
2
+ require_relative "transformer/temp_table"
3
+
1
4
  module DirectoryDiff
2
5
  class Transform
3
- attr_reader :current_directory, :new_directory
4
- attr_reader :transforms, :transforms_index
5
- attr_reader :options
6
+ attr_reader :current_directory
6
7
 
7
8
  def initialize(current_directory)
8
9
  @current_directory = current_directory
9
- @transforms = []
10
- @transforms_index = {}
11
10
  end
12
11
 
13
- def into(new_directory, options={})
14
- raise ArgumentError unless new_directory.respond_to?(:each)
15
- @new_directory = new_directory
16
- @options = options || {}
17
-
18
- current_employees.each do |email, employee|
19
- process_employee(email, employee)
20
- end
21
-
22
- unseen_employees.each do |email, employee|
23
- process_employee(email, employee)
24
- end
25
-
26
- transforms
12
+ def into(new_directory, options = {})
13
+ processor_class = processor_for(options[:processor])
14
+ processor_class.new(current_directory).into(new_directory, options)
27
15
  end
28
16
 
29
- protected
30
-
31
- def process_employee(email, assistant_owner)
32
- new_employee = find_new_employee(email)
33
- old_employee = find_current_employee(email)
34
-
35
- # cycle detection
36
- if transforms_index.has_key?(email)
37
- return
38
- end
39
- transforms_index[email] = nil
17
+ private
40
18
 
41
- if new_employee.nil?
42
- add_transform(:delete, old_employee)
43
- assistants_string = assistant_owner[3].to_s.split(",").reject do |assistant|
44
- assistant == email
45
- end.join(",")
46
- assistant_owner[3] = assistants_string == "" ? nil : assistants_string
19
+ def processor_for(processor)
20
+ case processor
21
+ when nil, :in_memory
22
+ Transformer::InMemory
23
+ when :temp_table
24
+ Transformer::TempTable
47
25
  else
48
- own_email = new_employee[1]
49
- assistant_emails = new_employee[3].to_s.split(",")
50
- assistant_emails.delete(own_email)
51
-
52
- if assistant_emails.empty?
53
- assistant_emails = new_employee[3] = nil
54
- end
55
-
56
- if assistant_emails
57
- assistant_emails.each do |assistant_email|
58
- process_employee(assistant_email, new_employee)
59
- end
60
- else
61
- # assistant_emails may be nil. we only use the
62
- # csv to *set* assistants. if it was nil, we
63
- # backfill from current employee so that the
64
- # new record appears to be the same as the
65
- # current record
66
- previous_assistants = old_employee&.fetch(3).to_s.split(",")
67
- previous_assistants.select! do |previous_assistant|
68
- find_new_employee(previous_assistant)
69
- end
70
-
71
- if previous_assistants.empty?
72
- new_employee[3] = nil
73
- else
74
- new_employee[3] = previous_assistants.join(",")
75
- end
76
- end
77
-
78
- if old_employee.nil?
79
- add_transform(:insert, new_employee)
80
- elsif new_employee[0, 4] == old_employee[0, 4]
81
- add_transform(:noop, new_employee) unless options[:skip_noop]
82
- else
83
- add_transform(:update, new_employee)
84
- end
85
- end
86
- end
87
-
88
- def add_transform(op, employee)
89
- return if employee.nil?
90
- email = employee[1]
91
- existing_operation = transforms_index[email]
92
- if existing_operation.nil?
93
- operation = [op, *employee]
94
- transforms_index[email] = operation
95
- transforms << operation
26
+ raise ArgumentError, "unsupported processor #{processor.inspect}"
96
27
  end
97
28
  end
98
-
99
- def find_new_employee(email)
100
- new_employees[email]
101
- end
102
-
103
- def find_current_employee(email)
104
- current_employees[email]
105
- end
106
-
107
- def new_employees
108
- @new_employees ||= build_index(new_directory)
109
- end
110
-
111
- def current_employees
112
- @current_employees ||= build_index(current_directory)
113
- end
114
-
115
- def unseen_employees
116
- emails = new_employees.keys - current_employees.keys
117
- emails.map do |email|
118
- [email, new_employees[email]]
119
- end
120
- end
121
-
122
- def build_index(directory)
123
- accum = {}
124
- directory.each do |employee|
125
- # Item at index 1 is email, so downcase it
126
- employee[1] = employee[1].downcase unless employee[1].nil?
127
- email = employee[1]
128
- accum[email] = employee
129
- end
130
-
131
- accum
132
- end
133
29
  end
134
30
  end
@@ -0,0 +1,126 @@
1
+ module DirectoryDiff
2
+ module Transformer
3
+ class InMemory
4
+ attr_reader :current_directory, :new_directory
5
+ attr_reader :transforms, :transforms_index
6
+ attr_reader :options
7
+
8
+ def initialize(current_directory)
9
+ @current_directory = current_directory
10
+ @transforms = []
11
+ @transforms_index = {}
12
+ end
13
+
14
+ def into(new_directory, options={})
15
+ raise ArgumentError unless new_directory.respond_to?(:each)
16
+ @new_directory = new_directory
17
+ @options = options || {}
18
+
19
+ new_employees.each do |email, employee|
20
+ process_employee(email, employee)
21
+ end
22
+
23
+ unseen_employees.each do |email, employee|
24
+ process_employee(email, employee)
25
+ end
26
+
27
+ transforms
28
+ end
29
+
30
+ protected
31
+
32
+ def process_employee(email, assistant_owner)
33
+ new_employee = find_new_employee(email)
34
+ old_employee = find_current_employee(email)
35
+
36
+ # cycle detection
37
+ if transforms_index.has_key?(email)
38
+ return
39
+ end
40
+ transforms_index[email] = nil
41
+
42
+ if new_employee.nil?
43
+ add_transform(:delete, old_employee)
44
+ assistants_string = assistant_owner[3].to_s.split(",").reject do |assistant|
45
+ assistant == email
46
+ end.join(",")
47
+ assistant_owner[3] = assistants_string == "" ? nil : assistants_string
48
+ else
49
+ own_email = new_employee[1]
50
+ assistant_emails = new_employee[3].to_s.split(",")
51
+ assistant_emails.delete(own_email)
52
+
53
+ assistant_emails.each do |assistant_email|
54
+ process_employee(assistant_email, new_employee)
55
+ end
56
+
57
+ # assistant_emails may be nil. we only use the csv to *set*
58
+ # assistants. if it was nil, we backfill from current employee so that
59
+ # the new record appears to be the same as the current record
60
+ if assistant_emails.empty?
61
+ original_assistant_value = nil
62
+ new_employee[3] = old_employee&.fetch(3)
63
+ else
64
+ original_assistant_value = new_employee[3]
65
+ end
66
+
67
+ if old_employee.nil?
68
+ add_transform(:insert, new_employee)
69
+ elsif new_employee[0, 4] == old_employee[0, 4]
70
+ # restore assistant value after cleanup like missing assistants and own email
71
+ new_employee[3] = original_assistant_value
72
+ add_transform(:noop, new_employee) unless options[:skip_noop]
73
+ else
74
+ add_transform(:update, new_employee)
75
+ end
76
+ end
77
+ end
78
+
79
+ def add_transform(op, employee)
80
+ return if employee.nil?
81
+ email = employee[1]
82
+ existing_operation = transforms_index[email]
83
+ if existing_operation.nil?
84
+ operation = [op, *employee]
85
+ transforms_index[email] = operation
86
+ transforms << operation
87
+ end
88
+ end
89
+
90
+ def find_new_employee(email)
91
+ new_employees[email]
92
+ end
93
+
94
+ def find_current_employee(email)
95
+ current_employees[email]
96
+ end
97
+
98
+ def new_employees
99
+ @new_employees ||= build_index(new_directory)
100
+ end
101
+
102
+ def current_employees
103
+ @current_employees ||= build_index(current_directory)
104
+ end
105
+
106
+ def unseen_employees
107
+ emails = current_employees.keys - new_employees.keys
108
+ emails.map do |email|
109
+ [email, current_employees[email]]
110
+ end
111
+ end
112
+
113
+ def build_index(directory)
114
+ accum = {}
115
+ directory.each do |employee|
116
+ # Item at index 1 is email, so downcase it
117
+ employee[1] = employee[1].downcase unless employee[1].nil?
118
+ email = employee[1]
119
+ accum[email] = employee
120
+ end
121
+
122
+ accum
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,233 @@
1
+ require "activerecord_pg_stuff"
2
+
3
+ Arel::Predications.module_eval do
4
+ def contains(other)
5
+ Arel::Nodes::InfixOperation.new(:"@>", self, other)
6
+ end
7
+ end
8
+
9
+ module DirectoryDiff
10
+ module Transformer
11
+ class TempTable
12
+ attr_reader :current_directory, :operations
13
+
14
+ # @params current_directory a relation that filters out only the records
15
+ # that represent the current directory. This is
16
+ # mostly likely an Employee relation. This
17
+ # relation will be pulled into a temporary table.
18
+ def initialize(current_directory)
19
+ @current_directory = current_directory
20
+ @operations = []
21
+ end
22
+
23
+ # @param new_directory a table containing only the new records to compare
24
+ # against, most likely a temp table.
25
+ def into(new_directory, options = {})
26
+ projection = <<-SQL
27
+ name,
28
+ lower(email) email,
29
+ coalesce(phone_number, '') phone_number,
30
+ array_remove(
31
+ regexp_split_to_array(
32
+ coalesce(assistants, ''),
33
+ '\s*,\s*'
34
+ )::varchar[],
35
+ ''
36
+ ) assistants
37
+ SQL
38
+ current_directory.select(projection).temporary_table do |temp_current_directory|
39
+ # Remove dupe email rows, keeping the last one
40
+ latest_unique_sql = <<-SQL
41
+ SELECT
42
+ DISTINCT ON (lower(email)) name,
43
+ lower(email) email,
44
+ coalesce(phone_number, '') phone_number,
45
+ array_remove(
46
+ regexp_split_to_array(
47
+ coalesce(assistants, ''),
48
+ '\s*,\s*'
49
+ )::varchar[],
50
+ ''
51
+ ) assistants,
52
+ extra,
53
+ ROW_NUMBER () OVER ()
54
+ FROM
55
+ #{new_directory.arel_table.name}
56
+ ORDER BY
57
+ lower(email),
58
+ row_number desc
59
+ SQL
60
+
61
+ new_directory.select('*')
62
+ .from(Arel.sql("(#{latest_unique_sql}) as t"))
63
+ .order("row_number").temporary_table do |deduped_csv|
64
+ # Get Arel tables for referencing fields, table names
65
+ employees = temp_current_directory.table
66
+ csv = deduped_csv.table
67
+
68
+ # Reusable Arel predicates
69
+ csv_employee_join = csv[:email].eq(employees[:email])
70
+ attributes_unchanged = employees[:name].eq(csv[:name])
71
+ .and(employees[:phone_number].eq(csv[:phone_number]))
72
+ .and(employees[:assistants].contains(csv[:assistants]))
73
+
74
+ # Creates joins between the temp table and the csv table and
75
+ # vice versa
76
+ # Cribbed from https://gist.github.com/mildmojo/3724189
77
+ csv_to_employees = csv.join(employees, Arel::Nodes::OuterJoin)
78
+ .on(csv_employee_join)
79
+ .join_sources
80
+ employees_to_csv = employees.join(csv, Arel::Nodes::OuterJoin)
81
+ .on(csv_employee_join)
82
+ .join_sources
83
+
84
+ # Representation of the joined csv-employees, with csv on the left
85
+ csv_records = deduped_csv.joins(csv_to_employees).order('row_number asc')
86
+ # Representation of the joined employees-csv, with employees on the
87
+ # left
88
+ employee_records = temp_current_directory.joins(employees_to_csv)
89
+
90
+ # Cleanup some bad records
91
+ # 1. Assistant email is set on an employee, but no assistant record
92
+ # in csv. Remove the assistant email.
93
+ # 2. Assistant email is employee's own email. Remove the assistant
94
+ # email.
95
+ # TODO move this into the temp table creation above
96
+ # https://www.db-fiddle.com/f/gxg6qABP1LygYvvgRvyH2N/1
97
+ cleanup_sql = <<-SQL
98
+ with
99
+ unnested_assistants as
100
+ (
101
+ select
102
+ email,
103
+ name,
104
+ unnest(assistants) assistant
105
+ from #{csv.name}
106
+ ),
107
+ own_email_removed as
108
+ (
109
+ select
110
+ a.*
111
+ from unnested_assistants a
112
+ where a.email != a.assistant
113
+ ),
114
+ missing_assistants_removed as
115
+ (
116
+ select
117
+ a.*
118
+ from own_email_removed a
119
+ left outer join #{csv.name} b on a.assistant = b.email
120
+ where
121
+ (a.assistant is null and b.email is null)
122
+ or (a.assistant is not null and b.email is not null)
123
+ ),
124
+ only_valid_assistants as
125
+ (
126
+ select
127
+ a.email,
128
+ a.name,
129
+ array_remove(
130
+ array_agg(b.assistant),
131
+ null
132
+ ) assistants
133
+ from #{csv.name} a
134
+ left outer join missing_assistants_removed b
135
+ using (email)
136
+ group by
137
+ a.email, a.name
138
+ )
139
+ update #{csv.name}
140
+ set assistants = only_valid_assistants.assistants
141
+ from only_valid_assistants
142
+ where #{csv.name}.email = only_valid_assistants.email
143
+ SQL
144
+ deduped_csv.connection.execute(cleanup_sql)
145
+
146
+ # new records are records in the new directory that don't exist in
147
+ # the current directory
148
+ new_records = csv_records.select("'insert'::varchar operation, row_number")
149
+ .select(:name, :email, :phone_number, :assistants, :extra)
150
+ .where({ employees.name => { email: nil } })
151
+ # deleted records are records in the current directory that don't
152
+ # exist in the new directory
153
+ deleted_records = employee_records.select("'delete'::varchar operation, row_number")
154
+ .select(:name, :email, :phone_number, :assistants, :extra)
155
+ .where({ csv.name => { email: nil } })
156
+ # changed records are records that have difference in name, phone
157
+ # number and/or assistants
158
+ changed_records = csv_records.select("'update'::varchar operation, row_number")
159
+ .select(:name, :email, :phone_number, :assistants, :extra)
160
+ .where.not(attributes_unchanged)
161
+ # unchanged records are records that are exactly the same in both
162
+ # directories (without considering the extra field)
163
+ unchanged_records = csv_records.select("'noop'::varchar operation, row_number")
164
+ .select(:name, :email, :phone_number, :assistants, :extra)
165
+ .where(attributes_unchanged)
166
+
167
+ # create temp table for holding operations
168
+ operations_temp_table = "temporary_operations_#{self.object_id}"
169
+ deduped_csv.connection.with_temporary_table operations_temp_table, new_records.to_sql do |name|
170
+ dec = ActiveRecordPgStuff::Relation::TemporaryTable::Decorator.new csv_records.klass, name
171
+ rel = ActiveRecord::Relation.new dec, table: dec.arel_table
172
+ rel.readonly!
173
+
174
+ rel.connection.execute("insert into #{name}(operation, row_number, name, email, phone_number, assistants, extra) #{deleted_records.to_sql}")
175
+ rel.connection.execute("insert into #{name}(operation, row_number, name, email, phone_number, assistants, extra) #{changed_records.to_sql}")
176
+
177
+ if options[:skip_noop] != true
178
+ rel.connection.execute("insert into #{name}(operation, row_number, name, email, phone_number, assistants, extra) #{unchanged_records.to_sql}")
179
+ end
180
+
181
+ rel.order(:row_number).each do |operation|
182
+ add_operation(operation)
183
+ end
184
+ end
185
+ end
186
+ end
187
+
188
+ prioritize_assistants(operations)
189
+ end
190
+
191
+ private
192
+
193
+ def add_operation(operation)
194
+ op = [
195
+ operation.operation.to_sym,
196
+ operation.name,
197
+ operation.email,
198
+ operation.phone_number.presence,
199
+ serialize_pg_array(operation.assistants)
200
+ ]
201
+ op << operation.extra unless operation[:extra].nil?
202
+ operations << op
203
+ end
204
+
205
+ def serialize_pg_array(pg_array)
206
+ return if pg_array.nil?
207
+ pg_array = pg_array[1...-1] # remove the curly braces
208
+ pg_array.presence
209
+ end
210
+
211
+ def prioritize_assistants(operations)
212
+ prioritized_operations = []
213
+ operations.each do |operation|
214
+ process_operation(operation, operations, prioritized_operations, Set.new)
215
+ end
216
+ prioritized_operations
217
+ end
218
+
219
+ def process_operation(operation, operations, prioritized_operations, tail)
220
+ (_, _, email, _, assistants) = operation
221
+ return if prioritized_operations.find { |_, _, e| e == email }
222
+
223
+ (assistants || '').split(',').each do |assistant_email|
224
+ next if tail.include?(assistant_email)
225
+ assistant_operation = operations.find { |_, _, email| email == assistant_email }
226
+ process_operation(assistant_operation, operations, prioritized_operations, tail.add(email))
227
+ end
228
+
229
+ prioritized_operations << operation
230
+ end
231
+ end
232
+ end
233
+ end
@@ -1,3 +1,3 @@
1
1
  module DirectoryDiff
2
- VERSION = "0.4.3"
2
+ VERSION = "0.4.4"
3
3
  end
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
@@ -0,0 +1,3 @@
1
+ --color
2
+ -fd
3
+ --order=rand
@@ -0,0 +1,9 @@
1
+ rvm:
2
+ - 1.9.3
3
+ - 2.0.0
4
+
5
+ env:
6
+ global:
7
+ - DATABASE_URL=postgresql://postgres@localhost
8
+
9
+ script: bundle exec rake
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in activerecord_pg_stuff.gemspec
4
+ gemspec
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 Dmitry Galinsky
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,72 @@
1
+ # ActiveRecordPgStuff
2
+
3
+ Adds support for working with temporary tables and pivot tables (PostgreSQL only).
4
+
5
+ * [![Build Status](https://travis-ci.org/dima-exe/activerecord_pg_stuff.png?branch=master)](https://travis-ci.org/dima-exe/activerecord_pg_stuff)
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ gem 'activerecord_pg_stuff'
12
+
13
+ And then execute:
14
+
15
+ $ bundle
16
+
17
+ Or install it yourself as:
18
+
19
+ $ gem install activerecord_pg_stuff
20
+
21
+ ## Usage
22
+
23
+ #### Temporary tables
24
+
25
+ Example:
26
+
27
+ User.select(:id, :email).temporary_table do |rel|
28
+ rel.pluck(:id)
29
+ end
30
+
31
+ #### Pivot tables
32
+
33
+ Before using, you need to create the extension:
34
+
35
+ CREATE EXTENSION tablefunc
36
+
37
+ Example:
38
+
39
+ CREATE TABLE payments (id integer, amount integer, seller_id integer, created_at timestamp)
40
+ INSERT INTO payments
41
+ VALUES (1, 1, 1, '2012-10-12 10:00 UTC'),
42
+ (2, 3, 1, '2012-11-12 10:00 UTC'),
43
+ (3, 5, 2, '2012-09-12 10:00 UTC'),
44
+ (4, 7, 2, '2012-10-12 10:00 UTC'),
45
+ (5, 11, 2, '2012-11-12 10:00 UTC'),
46
+ (6, 13, 2, '2012-11-12 10:00 UTC')
47
+
48
+ Payment
49
+ .select("SUM(amount) AS amount", :seller_id, "DATE_TRUNC('month', created_at) AS month")
50
+ .group("seller_id, DATE_TRUNC('month', created_at)")
51
+ .temporary_table do |rel|
52
+ # :month - for row
53
+ # :seller_id - for column
54
+ # :amount - for value
55
+ rel.pivot(:month, :seller_id, :amount)
56
+ end
57
+
58
+ expect(result.headers).to eq [nil, 1, 2]
59
+
60
+ expect(result.rows).to eq [
61
+ [ Time.utc(2012, 9, 1), nil, 5 ],
62
+ [ Time.utc(2012, 10, 1), 1, 7 ],
63
+ [ Time.utc(2012, 11, 1), 3, 24 ],
64
+ ]
65
+
66
+ ## Contributing
67
+
68
+ 1. Fork it
69
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
70
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
71
+ 4. Push to the branch (`git push origin my-new-feature`)
72
+ 5. Create new Pull Request
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require 'rspec/core/rake_task'
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => [:spec]
@@ -0,0 +1,27 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'activerecord_pg_stuff/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "activerecord_pg_stuff"
8
+ spec.version = ActiveRecordPgStuff::VERSION
9
+ spec.authors = ["Dmitry Galinsky"]
10
+ spec.email = ["dima.exe@gmail.com"]
11
+ spec.description = %q{ Adds support for working with temporary tables and pivot tables (PostgreSQL only) }
12
+ spec.summary = %q{ Adds support for working with temporary tables and pivot tables (PostgreSQL only) }
13
+ spec.homepage = ""
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files`.split($/)
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_runtime_dependency "activerecord", ">= 4.0"
22
+
23
+ spec.add_development_dependency "bundler", "~> 1.3"
24
+ spec.add_development_dependency "rake"
25
+ spec.add_development_dependency "rspec"
26
+ spec.add_development_dependency "pg"
27
+ end
@@ -0,0 +1,12 @@
1
+ require 'active_record'
2
+ require 'active_record/connection_adapters/postgresql_adapter'
3
+
4
+ require File.expand_path("../activerecord_pg_stuff/version", __FILE__)
5
+ require File.expand_path("../activerecord_pg_stuff/connection/temporary_table", __FILE__)
6
+ require File.expand_path("../activerecord_pg_stuff/relation/temporary_table", __FILE__)
7
+ require File.expand_path("../activerecord_pg_stuff/relation/pivot", __FILE__)
8
+
9
+ ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.send :include, ActiveRecordPgStuff::Connection::TemporaryTable
10
+
11
+ ActiveRecord::Relation.send :include, ActiveRecordPgStuff::Relation::TemporaryTable
12
+ ActiveRecord::Relation.send :include, ActiveRecordPgStuff::Relation::Pivot
@@ -0,0 +1,22 @@
1
+ module ActiveRecordPgStuff
2
+ module Connection
3
+
4
+ module TemporaryTable
5
+
6
+ def with_temporary_table(name, sql, &block)
7
+ transaction do
8
+ begin
9
+ sql = sql.gsub(/\n/, ' ').gsub(/ +/, ' ').strip
10
+ sql = "CREATE TEMPORARY TABLE #{name} ON COMMIT DROP AS #{sql}"
11
+ execute sql
12
+ yield name
13
+ ensure
14
+ execute("DROP TABLE IF EXISTS #{name}") rescue nil
15
+ end
16
+ end
17
+ end
18
+
19
+ end
20
+
21
+ end
22
+ end
@@ -0,0 +1,54 @@
1
+ module ActiveRecordPgStuff
2
+ module Relation
3
+
4
+ class PivotResult
5
+ attr_reader :headers, :rows
6
+
7
+ def initialize(header_result, result)
8
+ @headers = [nil] + result_to_array(header_result).map(&:first)
9
+ @rows = result_to_array(result)
10
+ end
11
+
12
+ def each_row(&block)
13
+ rows.each(&block)
14
+ end
15
+
16
+ private
17
+ def result_to_array(result)
18
+ result.to_hash.map do |h|
19
+ result.columns.inject([]) do |a, col|
20
+ a << result.column_types[col].type_cast(h[col])
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+ module Pivot
27
+
28
+ def pivot(row_id, col_id, val_id)
29
+
30
+ types_sql = %{ SELECT column_name, data_type FROM information_schema.columns WHERE table_name = #{connection.quote self.table_name} AND column_name IN (#{connection.quote row_id},#{connection.quote val_id}) }
31
+ types = connection.select_all types_sql
32
+ types = types.to_a.map(&:values).inject({}) do |a, v|
33
+ a[v[0]] = v[1]
34
+ a
35
+ end
36
+ row_type = types[row_id.to_s]
37
+ val_type = types[val_id.to_s]
38
+
39
+ cols = connection.select_all self.except(:select).select("DISTINCT #{col_id}").order(col_id).to_sql
40
+ cols_list = cols.rows.map(&:first).map do |c|
41
+ "#{col_id}_#{c} #{val_type}"
42
+ end.join(", ")
43
+
44
+ rel_1 = connection.quote self.select(row_id, col_id, val_id).order(row_id).to_sql
45
+ rel_2 = connection.quote self.except(:select).select("DISTINCT #{col_id}").order(col_id).to_sql
46
+ sql = %{ SELECT * FROM crosstab(#{rel_1}, #{rel_2}) AS (row_id #{row_type}, #{cols_list}) }
47
+ PivotResult.new cols, connection.select_all(sql)
48
+ end
49
+
50
+ end
51
+
52
+ end
53
+ end
54
+
@@ -0,0 +1,38 @@
1
+ module ActiveRecordPgStuff
2
+ module Relation
3
+
4
+ module TemporaryTable
5
+
6
+ class Decorator
7
+ attr_reader :table_name, :arel_table, :quoted_table_name
8
+
9
+ def initialize(object, table_name)
10
+ @table_name = table_name
11
+ @object = object
12
+ @arel_table = Arel::Table.new(table_name)
13
+ @quoted_table_name = @object.connection.quote_table_name(table_name)
14
+ end
15
+
16
+ def method_missing(name, *args, &block)
17
+ @object.send(name, *args, &block)
18
+ end
19
+
20
+ def respond_to?(name, *args)
21
+ @object.respond_to?(name, *args)
22
+ end
23
+ end
24
+
25
+ def temporary_table
26
+ tname = "temporary_#{self.table_name}_#{self.object_id}"
27
+ self.klass.connection.with_temporary_table tname, self.to_sql do |name|
28
+ dec = Decorator.new self.klass, name
29
+ rel = ActiveRecord::Relation.new dec, table: dec.arel_table
30
+ rel.readonly!
31
+ yield rel
32
+ end
33
+ end
34
+
35
+ end
36
+
37
+ end
38
+ end
@@ -0,0 +1,3 @@
1
+ module ActiveRecordPgStuff
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,35 @@
1
+ require 'spec_helper'
2
+
3
+ describe ActiveRecordPgStuff::Connection::TemporaryTable do
4
+
5
+ context "with_temporary_table" do
6
+ let(:sql) { "SELECT * FROM sellers WHERE id IN(1,2)" }
7
+
8
+ it "should create temporary table with 'name' and 'sql' and drop it after block executed" do
9
+ rs = conn.with_temporary_table 'sellers_tmp', sql do |name|
10
+ conn.select_all("SELECT * FROM #{name}").to_a.map(&:values)
11
+ end
12
+ expect(rs).to eq [%w{1 foo}, %w{2 bar}]
13
+ expect {
14
+ conn.execute 'SELECT * FROM sellers_tmp'
15
+ }.to raise_error(ActiveRecord::StatementInvalid, /PG::UndefinedTable/)
16
+ end
17
+
18
+ it "should create nested temporary tables" do
19
+ rs = conn.with_temporary_table 'sellers_tmp', sql do |name|
20
+ sql = "SELECT * FROM #{name} WHERE id = 1"
21
+ conn.with_temporary_table 'sellers_tmp_nested', sql do |name_nested|
22
+ conn.select_all("SELECT * FROM #{name_nested}").to_a.map(&:values)
23
+ end
24
+ end
25
+ expect(rs).to eq [%w{1 foo}]
26
+ expect {
27
+ conn.execute 'SELECT * FROM sellers_tmp'
28
+ }.to raise_error(ActiveRecord::StatementInvalid, /PG::UndefinedTable/)
29
+ expect {
30
+ conn.execute 'SELECT * FROM sellers_tmp_nested'
31
+ }.to raise_error(ActiveRecord::StatementInvalid, /PG::UndefinedTable/)
32
+ end
33
+
34
+ end
35
+ end
@@ -0,0 +1,25 @@
1
+ require 'spec_helper'
2
+
3
+ describe ActiveRecordPgStuff::Relation::TemporaryTable do
4
+
5
+ context "pivot" do
6
+
7
+ let(:rel) { Payment }
8
+
9
+ it "should create pivot table from relation" do
10
+ rs = rel.select(:seller_id, "SUM(amount) AS amount", "DATE_TRUNC('month', created_at) AS created_at")
11
+ .group("seller_id, DATE_TRUNC('month', created_at)").temporary_table do |tmp|
12
+ tmp.pivot :created_at, :seller_id, :amount
13
+ end
14
+
15
+ expect(rs.headers).to eq [nil,1,2]
16
+ expect(rs.rows).to eq [
17
+ [ Time.utc(2012, 9, 1), nil, 5 ],
18
+ [ Time.utc(2012, 10, 1), 1, 7 ],
19
+ [ Time.utc(2012, 11, 1), 3, 24 ],
20
+ ]
21
+ end
22
+
23
+ end
24
+
25
+ end
@@ -0,0 +1,30 @@
1
+ require 'spec_helper'
2
+
3
+ describe ActiveRecordPgStuff::Relation::TemporaryTable do
4
+
5
+ context "temporary_table" do
6
+
7
+ let(:rel) { Seller.where(id: [1,2]) }
8
+
9
+ it "should create temporary table from relation" do
10
+ rs = rel.temporary_table do |tmp|
11
+ tmp.where(id: [1,2]).load
12
+ end
13
+ expect(rs.map(&:class)).to eq [Seller, Seller]
14
+ expect(rs.map(&:class).map(&:table_name)).to eq %w{ sellers sellers }
15
+ expect(rs.map(&:id)).to eq [1,2]
16
+ expect(rs.map(&:readonly?)).to eq [true, true]
17
+ end
18
+
19
+ it "should create nested temporary tables from relation" do
20
+ rs = rel.select("id * 10 AS id").temporary_table do |tmp|
21
+ tmp.where(id: [10]).temporary_table do |nested_tmp|
22
+ nested_tmp.where(id: [10,20]).load
23
+ end
24
+ end
25
+ expect(rs.map(&:id)).to eq [10]
26
+ end
27
+
28
+ end
29
+
30
+ end
@@ -0,0 +1,42 @@
1
+ Bundler.require(:test)
2
+ require 'rspec/autorun'
3
+ require 'logger'
4
+ require File.expand_path("../../lib/activerecord_pg_stuff", __FILE__)
5
+
6
+ ActiveRecord::Base.logger = Logger.new(STDOUT)
7
+
8
+ RSpec.configure do |c|
9
+ c.before(:suite) do
10
+ ActiveRecord::Base.establish_connection
11
+ end
12
+
13
+ c.before(:all) do
14
+ conn.execute 'CREATE EXTENSION IF NOT EXISTS tablefunc'
15
+ conn.execute "CREATE TABLE sellers (id integer, name varchar)"
16
+ conn.execute "INSERT INTO sellers VALUES(1, 'foo'), (2, 'bar'), (3, 'baz')"
17
+
18
+ conn.execute "CREATE TABLE payments (id integer, amount integer, seller_id integer, created_at timestamp)"
19
+ conn.execute "INSERT INTO payments VALUES(1, 1, 1, '2012-10-12 10:00 UTC')"
20
+ conn.execute "INSERT INTO payments VALUES(2, 3, 1, '2012-11-12 10:00 UTC')"
21
+ conn.execute "INSERT INTO payments VALUES(3, 5, 2, '2012-09-12 10:00 UTC')"
22
+ conn.execute "INSERT INTO payments VALUES(4, 7, 2, '2012-10-12 10:00 UTC')"
23
+ conn.execute "INSERT INTO payments VALUES(5, 11, 2, '2012-11-12 10:00 UTC')"
24
+ conn.execute "INSERT INTO payments VALUES(6, 13, 2, '2012-11-12 10:00 UTC')"
25
+ end
26
+
27
+ c.after(:all) do
28
+ conn.execute("DROP TABLE sellers")
29
+ conn.execute("DROP TABLE payments")
30
+ end
31
+ end
32
+
33
+ def conn
34
+ ActiveRecord::Base.connection
35
+ end
36
+
37
+ class Seller < ActiveRecord::Base
38
+ end
39
+
40
+ class Payment < ActiveRecord::Base
41
+ end
42
+
metadata CHANGED
@@ -1,15 +1,71 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: directory_diff
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.3
4
+ version: 0.4.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kamal Mahyuddin
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2017-09-08 00:00:00.000000000 Z
11
+ date: 2018-10-05 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 5.1.4
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 5.1.4
27
+ - !ruby/object:Gem::Dependency
28
+ name: pg
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 1.1.3
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 1.1.3
41
+ - !ruby/object:Gem::Dependency
42
+ name: temping
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 3.10.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.10.0
55
+ - !ruby/object:Gem::Dependency
56
+ name: activerecord_pg_stuff
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 0.0.1
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 0.0.1
13
69
  - !ruby/object:Gem::Dependency
14
70
  name: bundler
15
71
  requirement: !ruby/object:Gem::Requirement
@@ -74,7 +130,26 @@ files:
74
130
  - directory_diff.gemspec
75
131
  - lib/directory_diff.rb
76
132
  - lib/directory_diff/transform.rb
133
+ - lib/directory_diff/transformer/in_memory.rb
134
+ - lib/directory_diff/transformer/temp_table.rb
77
135
  - lib/directory_diff/version.rb
136
+ - vendor/gems/activerecord_pg_stuff-0.0.1/.gitignore
137
+ - vendor/gems/activerecord_pg_stuff-0.0.1/.rspec
138
+ - vendor/gems/activerecord_pg_stuff-0.0.1/.travis.yml
139
+ - vendor/gems/activerecord_pg_stuff-0.0.1/Gemfile
140
+ - vendor/gems/activerecord_pg_stuff-0.0.1/LICENSE.txt
141
+ - vendor/gems/activerecord_pg_stuff-0.0.1/README.md
142
+ - vendor/gems/activerecord_pg_stuff-0.0.1/Rakefile
143
+ - vendor/gems/activerecord_pg_stuff-0.0.1/activerecord_pg_stuff.gemspec
144
+ - vendor/gems/activerecord_pg_stuff-0.0.1/lib/activerecord_pg_stuff.rb
145
+ - vendor/gems/activerecord_pg_stuff-0.0.1/lib/activerecord_pg_stuff/connection/temporary_table.rb
146
+ - vendor/gems/activerecord_pg_stuff-0.0.1/lib/activerecord_pg_stuff/relation/pivot.rb
147
+ - vendor/gems/activerecord_pg_stuff-0.0.1/lib/activerecord_pg_stuff/relation/temporary_table.rb
148
+ - vendor/gems/activerecord_pg_stuff-0.0.1/lib/activerecord_pg_stuff/version.rb
149
+ - vendor/gems/activerecord_pg_stuff-0.0.1/spec/lib/connection_temporary_table_spec.rb
150
+ - vendor/gems/activerecord_pg_stuff-0.0.1/spec/lib/relation_pivot_spec.rb
151
+ - vendor/gems/activerecord_pg_stuff-0.0.1/spec/lib/relation_temporary_table_spec.rb
152
+ - vendor/gems/activerecord_pg_stuff-0.0.1/spec/spec_helper.rb
78
153
  homepage: https://github.com/envoy/directory_diff
79
154
  licenses:
80
155
  - MIT
@@ -95,7 +170,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
95
170
  version: '0'
96
171
  requirements: []
97
172
  rubyforge_project:
98
- rubygems_version: 2.6.12
173
+ rubygems_version: 2.5.2
99
174
  signing_key:
100
175
  specification_version: 4
101
176
  summary: Envoy employee directory diffing.