pg_morph 0.3.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: cef0e13fb0248806b82ff74afe1306cf9fa80260
4
+ data.tar.gz: 617f2272d8aa391e06daca0ada34805c64d447d7
5
+ SHA512:
6
+ metadata.gz: 15155e9dbf5d53573b9eee074d94090a7489a3b3603a81bca75928554cb8908884013eebf403049ca69e1bfac9b886753ae90aaecfb00725b08aaed732b94551
7
+ data.tar.gz: 4837b92da2446cdd827cc31c83f65c17b340c6ef83beb04591b589948b1276d78c1f9a94aad5a22c3b88593fddbb583b12d06c62b874922ba1fefc3f419298e3
data/.gitignore CHANGED
@@ -1,4 +1,5 @@
1
1
  spec/dummy/log
2
2
  spec/dummy/config/*.yml
3
3
  tags
4
+ /coverage/
4
5
  *.gem
@@ -0,0 +1,13 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.1
4
+ before_script:
5
+ - psql -c 'create database pg_morph_test;' -U postgres
6
+ script:
7
+ - bundle exec rake db:migrate RAILS_ENV=test
8
+ - CODECLIMATE_REPO_TOKEN=60392238805181ab599e01b29566617c47bffed0083cbfd119aa3c230f9d611d bundle exec rake
9
+ addons:
10
+ postgresql: 9.3
11
+ notifications:
12
+ email:
13
+ - hanka@lunarlogic.io
@@ -0,0 +1,5 @@
1
+ ### v1.0.0
2
+
3
+ This version adds additional layer on top of all tables and partitions, which is table view. Thanks to that there is no need for any after insert triggers and ActiveRecord don't have any problems with missing id after creating a new record.
4
+
5
+ This version is not compatible with previous one.
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- pg_morph (0.1.0)
4
+ pg_morph (1.0.0)
5
5
  activerecord (~> 3)
6
6
 
7
7
  GEM
@@ -37,13 +37,34 @@ GEM
37
37
  arel (3.0.3)
38
38
  awesome_print (1.2.0)
39
39
  builder (3.0.4)
40
+ celluloid (0.15.2)
41
+ timers (~> 1.1.0)
42
+ codeclimate-test-reporter (0.4.6)
43
+ simplecov (>= 0.7.1, < 1.0.0)
40
44
  coderay (1.1.0)
41
45
  diff-lcs (1.2.5)
46
+ docile (1.1.5)
42
47
  erubis (2.7.0)
48
+ ffi (1.9.3)
49
+ formatador (0.2.4)
50
+ guard (2.2.4)
51
+ formatador (>= 0.2.4)
52
+ listen (~> 2.1)
53
+ lumberjack (~> 1.0)
54
+ pry (>= 0.9.12)
55
+ thor (>= 0.18.1)
56
+ guard-rspec (4.2.0)
57
+ guard (>= 2.1.1)
58
+ rspec (>= 2.14, < 4.0)
43
59
  hike (1.2.3)
44
60
  i18n (0.6.11)
45
61
  journey (1.0.4)
46
62
  json (1.8.1)
63
+ listen (2.3.1)
64
+ celluloid (>= 0.15.2)
65
+ rb-fsevent (>= 0.9.3)
66
+ rb-inotify (>= 0.9)
67
+ lumberjack (1.0.4)
47
68
  mail (2.5.4)
48
69
  mime-types (~> 1.16)
49
70
  treetop (~> 1.4.8)
@@ -79,8 +100,15 @@ GEM
79
100
  rdoc (~> 3.4)
80
101
  thor (>= 0.14.6, < 2.0)
81
102
  rake (10.3.2)
103
+ rb-fsevent (0.9.3)
104
+ rb-inotify (0.9.2)
105
+ ffi (>= 0.5.0)
82
106
  rdoc (3.12.2)
83
107
  json (~> 1.4)
108
+ rspec (2.14.1)
109
+ rspec-core (~> 2.14.0)
110
+ rspec-expectations (~> 2.14.0)
111
+ rspec-mocks (~> 2.14.0)
84
112
  rspec-core (2.14.8)
85
113
  rspec-expectations (2.14.5)
86
114
  diff-lcs (>= 1.1.3, < 2.0)
@@ -93,6 +121,11 @@ GEM
93
121
  rspec-core (~> 2.14.0)
94
122
  rspec-expectations (~> 2.14.0)
95
123
  rspec-mocks (~> 2.14.0)
124
+ simplecov (0.9.1)
125
+ docile (~> 1.1.0)
126
+ multi_json (~> 1.0)
127
+ simplecov-html (~> 0.8.0)
128
+ simplecov-html (0.8.0)
96
129
  slop (3.6.0)
97
130
  sprockets (2.2.2)
98
131
  hike (~> 1.2)
@@ -101,6 +134,7 @@ GEM
101
134
  tilt (~> 1.1, != 1.3.0)
102
135
  thor (0.19.1)
103
136
  tilt (1.4.1)
137
+ timers (1.1.0)
104
138
  treetop (1.4.15)
105
139
  polyglot
106
140
  polyglot (>= 0.3.1)
@@ -111,9 +145,11 @@ PLATFORMS
111
145
 
112
146
  DEPENDENCIES
113
147
  awesome_print (~> 1.2)
148
+ codeclimate-test-reporter (~> 0.4)
149
+ guard-rspec (~> 4.2)
114
150
  pg (~> 0.17)
115
151
  pg_morph!
116
152
  pry (~> 0.10)
117
153
  rails (~> 3)
118
154
  rake (~> 10.3)
119
- rspec-rails
155
+ rspec-rails (~> 2.14)
@@ -0,0 +1,9 @@
1
+ # A sample Guardfile
2
+ # More info at https://github.com/guard/guard#readme
3
+
4
+ guard :rspec, cmd: 'bundle exec rspec --color --format documentation' do
5
+ watch(%r{^spec/.+_spec\.rb$})
6
+ watch(%r{^lib/(.+)\.rb$}) { |m| ["spec/lib/#{m[1]}_spec.rb", "spec/lib/#{m[1]}_integration_spec.rb"] }
7
+ watch('spec/spec_helper.rb') { "spec" }
8
+ end
9
+
data/README.md CHANGED
@@ -1,8 +1,16 @@
1
+ [![Build Status](https://travis-ci.org/LunarLogic/pg_morph.svg?branch=view)](https://travis-ci.org/LunarLogic/pg_morph)
2
+ [![Code Climate](https://codeclimate.com/github/LunarLogic/pg_morph/badges/gpa.svg)](https://codeclimate.com/github/LunarLogic/pg_morph)
3
+ [![Test Coverage](https://codeclimate.com/github/LunarLogic/pg_morph/badges/coverage.svg)](https://codeclimate.com/github/LunarLogic/pg_morph)
4
+
1
5
  # PgMorph
2
6
  # ![PgMorph logo](docs/pg_morph.png)
3
7
 
4
8
  PgMorph gives you a way to handle DB consistency for polymorphic relations and is based on postgreSQL inheritance and partitioning features.
5
9
 
10
+ ## Requirements
11
+
12
+ postgresql >= 9.2
13
+
6
14
  ## Installation
7
15
 
8
16
  Add this line to your application's Gemfile:
@@ -33,7 +41,8 @@ By adding migration:
33
41
  add_polymorphic_foreign_key :likes, :comments, column: :likeable
34
42
  ```
35
43
 
36
- PgMorph creates a partition table named `likes_comments` which inherits from `likes` table, sets foreign key on it and redirects all inserts to `likes` to this partition table if `likeable_type` is `Like`. It's done by using before insert trigger.
44
+ At first PgMorph wants to use `likes` table view instead of normal table as a master one. To prevent braking all potential relations with other tables it renames `likes` table to `likes_base` and then creates a view of `likes_base` named `likes`.
45
+ PgMorph then creates a partition table named `likes_comments` which inherits from `likes_base` table, sets foreign key on it and redirects all inserts to `likes` - since this is the table name AR knows aobut - to this partition table if `likeable_type` is `Comment`. It's done by using before insert trigger.
37
46
 
38
47
  You will have to add polymorphic foreign key on all related tables and each time new relation is added, before insert trigger function will be updated to reflect all defined relations and redirect new records to proper partitions.
39
48
 
@@ -47,11 +56,14 @@ remove_polymorphic_foreign_key :likes, :comments, column: :likeable
47
56
 
48
57
  Because it means that whole partition table would be removed, you will be forbidden to do that if partition table contains any data.
49
58
 
50
- ## Issues
59
+ ## Caveats
60
+
61
+ While updating records check constraints which are set on each partition does not allow to change association type, so if in examplary model some like is for a comment, it can't be reassigned to a post, postgres will raise an exception.
51
62
 
52
- ActiveRecord uses `INSERT ... RETURNING id` query which was impossible to keep while while using regular tables without some trick. In ideal situation there should be inly one insert to partition table, omitting main table, but than `id` of newly created record would become `nil` which would frustrate most of us. To preserve `id` of new record main table is not omitted, two records are being made and in after insert trigger duplicated record from master table is removed.
63
+ ## Development plan
53
64
 
54
- This extra database operations may be skipped by using view for main table, however it requires more work to make it so transparent for ActiveRecord as it is now, and is going to be done in next release.
65
+ - support moving records between partitions while updating relation type
66
+ - your suggestions?
55
67
 
56
68
  ## Contributing
57
69
 
data/Rakefile CHANGED
@@ -1,4 +1,11 @@
1
1
  require 'rake/testtask'
2
+ require 'rspec/core/rake_task'
3
+ require_relative './spec/dummy/config/application.rb'
4
+
5
+ task :default => :spec
6
+ RSpec::Core::RakeTask.new
7
+
8
+ Dummy::Application.load_tasks
2
9
 
3
10
  desc 'test pg_morph'
4
11
  Rake::TestTask.new do |t|
@@ -7,11 +7,11 @@ module PgMorph
7
7
 
8
8
  polymorphic = PgMorph::Polymorphic.new(parent_table, child_table, options)
9
9
 
10
- sql = polymorphic.create_proxy_table_sql
10
+ sql = polymorphic.rename_base_table_sql
11
+ sql << polymorphic.create_base_table_view_sql
12
+ sql << polymorphic.create_proxy_table_sql
11
13
  sql << polymorphic.create_before_insert_trigger_fun_sql
12
14
  sql << polymorphic.create_before_insert_trigger_sql
13
- sql << polymorphic.create_after_insert_trigger_fun_sql
14
- sql << polymorphic.create_after_insert_trigger_sql
15
15
 
16
16
  execute(sql)
17
17
  end
@@ -22,8 +22,9 @@ module PgMorph
22
22
  polymorphic = PgMorph::Polymorphic.new(parent_table, child_table, options)
23
23
 
24
24
  sql = polymorphic.remove_before_insert_trigger_sql
25
- sql << polymorphic.remove_partition_table
26
- sql << polymorphic.remove_after_insert_trigger_sql
25
+ sql << polymorphic.remove_proxy_table
26
+ sql << polymorphic.remove_base_table_view_sql
27
+ sql << polymorphic.rename_base_table_back_sql
27
28
 
28
29
  execute(sql)
29
30
  end
@@ -25,13 +25,5 @@ module PgMorph
25
25
  "#{parent_table}_#{column_name}_insert_trigger"
26
26
  end
27
27
 
28
- def after_insert_fun_name
29
- "delete_from_#{parent_table}_master_fun"
30
- end
31
-
32
- def after_insert_trigger_name
33
- "#{parent_table}_after_insert_trigger"
34
- end
35
-
36
28
  end
37
29
  end
@@ -1,30 +1,63 @@
1
1
  module PgMorph
2
2
 
3
3
  class Polymorphic
4
+ BASE_TABLE_SUFIX = :base
5
+
4
6
  include PgMorph::Naming
5
- attr_reader :parent_table, :child_table, :column_name
7
+ attr_reader :parent_table, :child_table, :column_name, :base_table
6
8
 
7
9
  def initialize(parent_table, child_table, options)
8
10
  @parent_table = parent_table
9
11
  @child_table = child_table
10
12
  @column_name = options[:column]
13
+ @base_table = options[:base_table] || :"#{parent_table}_#{BASE_TABLE_SUFIX}"
11
14
 
12
15
  raise PgMorph::Exception.new("Column not specified") unless @column_name
13
16
  end
14
17
 
18
+ def rename_base_table_sql
19
+ return '' unless can_rename_to_base_table?
20
+ %Q{
21
+ ALTER TABLE #{parent_table} RENAME TO #{base_table};
22
+ }
23
+ end
24
+
25
+ def can_rename_to_base_table?
26
+ return true unless ActiveRecord::Base.connection.table_exists? base_table
27
+
28
+ parent_table_set = ActiveRecord::Base.connection.columns(parent_table).
29
+ map{|column| column.as_json.except('null')}
30
+ base_table_set = ActiveRecord::Base.connection.columns(base_table).
31
+ map{|column| column.as_json.except('null')}
32
+
33
+ return false if parent_table_set == base_table_set
34
+ raise PgMorph::Exception.new('table name mismatch!')
35
+ end
36
+
37
+ def create_base_table_view_sql
38
+ %Q{
39
+ CREATE OR REPLACE VIEW #{parent_table} AS SELECT * FROM #{base_table};
40
+ }
41
+ end
42
+
15
43
  def create_proxy_table_sql
16
44
  %Q{
17
45
  CREATE TABLE #{proxy_table} (
18
46
  CHECK (#{column_name_type} = '#{type}'),
19
47
  PRIMARY KEY (id),
20
48
  FOREIGN KEY (#{column_name_id}) REFERENCES #{child_table}(id)
21
- ) INHERITS (#{parent_table});
49
+ ) INHERITS (#{base_table});
22
50
  }
23
51
  end
24
52
 
25
53
  def create_before_insert_trigger_fun_sql
26
54
  before_insert_trigger_content do
27
- create_trigger_body.strip
55
+ %Q{
56
+ IF NEW.id IS NULL THEN
57
+ NEW.id := nextval('#{parent_table}_id_seq');
58
+ END IF;
59
+ #{create_trigger_body.strip}
60
+ }
28
61
  end
29
62
  end
30
63
 
@@ -32,44 +65,29 @@ module PgMorph
32
65
  fun_name = before_insert_fun_name
33
66
  trigger_name = before_insert_trigger_name
34
67
 
35
- create_trigger_sql(parent_table, trigger_name, fun_name, 'BEFORE INSERT')
36
- end
37
-
38
- def create_after_insert_trigger_sql
39
- fun_name = after_insert_fun_name
40
- trigger_name = after_insert_trigger_name
41
-
42
- create_trigger_sql(parent_table, trigger_name, fun_name, 'AFTER INSERT')
43
- end
44
-
45
- def create_after_insert_trigger_fun_sql
46
- fun_name = after_insert_fun_name
47
- create_trigger_fun(fun_name) do
48
- %Q{DELETE FROM ONLY #{parent_table} WHERE id = NEW.id;}
49
- end
68
+ create_trigger_sql(parent_table, trigger_name, fun_name, 'INSTEAD OF INSERT')
50
69
  end
51
70
 
52
71
  def remove_before_insert_trigger_sql
53
72
  trigger_name = before_insert_trigger_name
54
73
  fun_name = before_insert_fun_name
55
-
56
- prosrc = get_function(fun_name)
57
- raise PG::Error.new("There is no such function #{fun_name}()\n") unless prosrc
58
-
59
- scan = prosrc.scan(/(( +(ELS)?IF.+\n)(\s+INSERT INTO.+;\n))/)
60
- cleared = scan.reject { |x| x[0].match("#{proxy_table}") }
74
+ cleared = check_more_partitions
61
75
 
62
76
  if cleared.present?
63
- cleared[0][0].sub!('ELSIF', 'IF')
64
- before_insert_trigger_content do
65
- cleared.map { |m| m[0] }.join('').strip
66
- end
77
+ update_before_insert_trigger_sql(cleared)
67
78
  else
68
79
  drop_trigger_and_fun_sql(trigger_name, parent_table, fun_name)
69
80
  end
70
81
  end
71
82
 
72
- def remove_partition_table
83
+ def update_before_insert_trigger_sql(cleared)
84
+ cleared[0][0].sub!('ELSIF', 'IF')
85
+ before_insert_trigger_content do
86
+ cleared.map { |m| m[0] }.join('').strip
87
+ end
88
+ end
89
+
90
+ def remove_proxy_table
73
91
  table_empty = ActiveRecord::Base.connection.select_value("SELECT COUNT(*) FROM #{parent_table}_#{child_table}").to_i.zero?
74
92
  if table_empty
75
93
  %Q{ DROP TABLE IF EXISTS #{proxy_table}; }
@@ -78,20 +96,34 @@ module PgMorph
78
96
  end
79
97
  end
80
98
 
81
- def remove_after_insert_trigger_sql
82
- prosrc = get_function(before_insert_fun_name)
83
- scan = prosrc.scan(/(( +(ELS)?IF.+\n)(\s+INSERT INTO.+;\n))/)
84
- cleared = scan.reject { |x| x[0].match("#{proxy_table}") }
85
-
86
- return '' if cleared.present?
87
- fun_name = after_insert_fun_name
88
- trigger_name = after_insert_trigger_name
99
+ def remove_base_table_view_sql
100
+ if check_more_partitions.present?
101
+ ''
102
+ else
103
+ %Q{ DROP VIEW #{parent_table}; }
104
+ end
105
+ end
89
106
 
90
- drop_trigger_and_fun_sql(trigger_name, parent_table, fun_name)
107
+ def rename_base_table_back_sql
108
+ if check_more_partitions.present?
109
+ ''
110
+ else
111
+ %Q{ ALTER TABLE #{base_table} RENAME TO #{parent_table}; }
112
+ end
91
113
  end
92
114
 
93
115
  private
94
116
 
117
+ def check_more_partitions
118
+ fun_name = before_insert_fun_name
119
+
120
+ prosrc = get_function(fun_name)
121
+ raise PG::Error.new("There is no such function #{fun_name}()\n") unless prosrc
122
+
123
+ scan = prosrc.scan(/(( +(ELS)?IF.+\n)(\s+INSERT INTO.+;\n))/)
124
+ scan.reject { |x| x[0].match("#{proxy_table}") }
125
+ end
126
+
95
127
  def create_trigger_fun(fun_name, &block)
96
128
  %Q{
97
129
  CREATE OR REPLACE FUNCTION #{fun_name}() RETURNS TRIGGER AS $$
@@ -115,21 +147,29 @@ module PgMorph
115
147
  prosrc = get_function(before_insert_fun_name)
116
148
 
117
149
  if prosrc
118
- scan = prosrc.scan(/(( +(ELS)?IF.+\n)(\s+INSERT INTO.+;\n))/)
119
- raise PG::Error.new("Condition for #{proxy_table} table already exists in trigger function") if scan[0][0].match proxy_table
120
- %Q{
121
- #{scan.map { |m| m[0] }.join.strip}
122
- ELSIF (NEW.#{column_name}_type = '#{child_table.to_s.singularize.camelize}') THEN
123
- INSERT INTO #{parent_table}_#{child_table} VALUES (NEW.*);
124
- }
150
+ update_existing_trigger_body(prosrc)
125
151
  else
126
- %Q{
127
- IF (NEW.#{column_name}_type = '#{child_table.to_s.singularize.camelize}') THEN
128
- INSERT INTO #{parent_table}_#{child_table} VALUES (NEW.*);
129
- }
152
+ create_new_trigger_body
130
153
  end
131
154
  end
132
155
 
156
+ def update_existing_trigger_body(prosrc)
157
+ scan = prosrc.scan(/(( +(ELS)?IF.+\n)(\s+INSERT INTO.+;\n))/)
158
+ raise PG::Error.new("Condition for #{proxy_table} table already exists in trigger function") if scan[0][0].match proxy_table
159
+ %Q{
160
+ #{scan.map { |m| m[0] }.join.strip}
161
+ ELSIF (NEW.#{column_name}_type = '#{child_table.to_s.singularize.camelize}') THEN
162
+ INSERT INTO #{parent_table}_#{child_table} VALUES (NEW.*);
163
+ }
164
+ end
165
+
166
+ def create_new_trigger_body
167
+ %Q{
168
+ IF (NEW.#{column_name}_type = '#{child_table.to_s.singularize.camelize}') THEN
169
+ INSERT INTO #{parent_table}_#{child_table} VALUES (NEW.*);
170
+ }
171
+ end
172
+
133
173
  def create_trigger_sql(parent_table, trigger_name, fun_name, when_to_call)
134
174
  %Q{
135
175
  DROP TRIGGER IF EXISTS #{trigger_name} ON #{parent_table};
@@ -1,3 +1,3 @@
1
1
  module PgMorph
2
- VERSION = "0.3.0"
2
+ VERSION = "1.0.0"
3
3
  end