postspec 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
data/lib/postspec.rb ADDED
@@ -0,0 +1,367 @@
1
+ require "developer_exceptions"
2
+
3
+ require "fixture_fox"
4
+
5
+ require "postspec/version.rb"
6
+ require "postspec/frame.rb"
7
+ require "postspec/render.rb"
8
+ require "postspec/state.rb"
9
+ require "postspec/environment.rb"
10
+ require "postspec/config.rb"
11
+
12
+ include DeveloperExceptions
13
+
14
+ # before everything
15
+ # truncate or setup seed
16
+ #
17
+ # after everything
18
+ # drop seed methods
19
+
20
+ module Postspec
21
+ class Postspec
22
+ class Error < RuntimeError; end
23
+
24
+ SHARE_DIR = File.expand_path(File.dirname(__FILE__)) + "/share"
25
+
26
+ # Database connection. A PgConn object
27
+ attr_reader :conn
28
+
29
+ # Meta object. A PgMeta object
30
+ attr_reader :meta
31
+
32
+ # Type of the database. A PgGraph::Type object
33
+ attr_reader :type
34
+
35
+ # List of table types in the database except tables from hidden schemas (eg. postspec)
36
+ attr_reader :tables
37
+
38
+ # If true and a test case fails, postspec will commit all changes and
39
+ # ignore any further tests. If rspec is called with the --fail-fast option
40
+ # the test run will terminate immediately. Default true
41
+ attr_reader :fail
42
+
43
+ # State
44
+ attr_reader :state
45
+
46
+ # Map from UID to record ID of inserted, updated, and deleted records
47
+ forward_to :state, :inserted, :updated, :deleted
48
+
49
+ # Current mode. Can be one of :seed, :empty, ...
50
+ forward_to :state, :mode
51
+
52
+ # Transaction frame stack
53
+ attr_reader :frames
54
+
55
+ # Current frame
56
+ def frame() @frames.top end
57
+ forward_to :frame, :ids, :anchors
58
+
59
+ # Prick anchors (FIXME)
60
+ attr_reader :prick_anchors
61
+
62
+ # Render object
63
+ attr_reader :render
64
+
65
+ # TODO: PgMeta object
66
+ #
67
+ # +mode+ can be one of :seed, :empty (TODO :reseed, :keep)
68
+ def initialize(conn, reflector: nil, mode: :empty, anchors: [], fail: true)
69
+ # puts "Postspec#initialize"
70
+ constrain conn, PgConn
71
+ constrain reflector, NilClass, String, PgGraph::Reflector
72
+ constrain mode, lambda { |m| [:empty, :seed].include?(m) }
73
+ constrain anchors, [Hash], NilClass
74
+ constrain fail, TrueClass, FalseClass
75
+
76
+ @conn = conn
77
+ @meta = PgMeta.new(@conn)
78
+
79
+ # Make sure the postspec schema is not included in the type model. TODO:
80
+ # Consolidate this into the :ignore option of PgGraph::Type.new as is
81
+ # done with the prick schema below
82
+ has_postspec = @meta.schemas.key?("postspec")
83
+ !has_postspec or (@meta.schemas["postspec"].hidden = true)
84
+
85
+ @type = PgGraph::Type.new(@meta, reflector, ignore: ["prick"])
86
+ @render = Render.new(self)
87
+ @tables = type.schemas.map(&:tables).flatten
88
+ @fail = fail
89
+ @failed = false
90
+ @success = true
91
+
92
+ # Compile-time state variable with the current search_path. Frames are
93
+ # initialized with this value when they're declared (#use, #statement,
94
+ # etc.)
95
+ @search_path = %w(public)
96
+
97
+ # Ensure postgres environment (the postspec schema). TODO: Move into Environment#initialize
98
+ @environment = Environment.new(self)
99
+ if @environment.exist?
100
+ if last_state = State.read(conn)
101
+ if last_state.ready
102
+ if !last_state.clean
103
+ # Last run didn't cleanup after itself
104
+ @environment.clean
105
+ # elsif ... HERE
106
+ end
107
+
108
+ if last_state.mode != mode
109
+ # We have changed mode
110
+ @environment.teardown(last_state.mode)
111
+ @environment.setup(mode)
112
+ end
113
+ end
114
+ else
115
+ # First run after deep-cleaning
116
+ end
117
+ else
118
+ @environment.create
119
+ @environment.setup(mode)
120
+ end
121
+
122
+ # State of current run. Note that @state needs to be initialized after
123
+ # the previous state has been read into last_state
124
+ @state = State.create(conn, mode)
125
+
126
+ # Compare seed ids with table_sequence_ids to tell if a table has been
127
+ # tampered with
128
+ #
129
+ # FIXME Implement
130
+ # %(
131
+ # select table_uid
132
+ # record_id
133
+ # from table_sequence_ids() i
134
+ # left join postspec.seeds s on s.table_uid = i.table_uid and s.record_id < i.record_id
135
+ # )
136
+
137
+ # Frames. TODO: Move into FrameStack#initialize
138
+ @frames = FrameStack.new(self)
139
+ if mode == :seed
140
+ @frames.push SeedFrame.new(self, @environment.uids, FixtureFox::Anchors.new(@type, anchors))
141
+ else
142
+ @frames.push EmptyFrame.new(self)
143
+ end
144
+ @conn.execute(render.execution_unit(tables.map(&:uid), frames.top.push_sql))
145
+
146
+ @foxes_stack = [] # stack of stack of Fox objects. TODO: Why a stack?
147
+ @datas_stack = [] # stack of stack of Data objects
148
+
149
+ @state.ready = true
150
+ State.write(conn, @state)
151
+ end
152
+
153
+ def terminate
154
+ # puts "Postspec#terminate"
155
+ @state.status = success?
156
+ @state.clean = !failed?
157
+ State.write(conn, @state)
158
+ if frames.top
159
+ @conn.execute(frames.top.pop_sql)
160
+ @frames.pop
161
+ end
162
+ @conn.terminate
163
+ end
164
+
165
+ # Current fox object. This can be nil (TODO alternatively: Create a dummy fox in the root frame)
166
+ def fox() @frames.top.is_a?(FixtureFox::Fox) ? @frames.top.fox : nil end
167
+
168
+ # True if a transaction is in progress
169
+ def transaction?() @conn.transaction? end
170
+
171
+ # True if this is the primary transaction. A primary transaction is a
172
+ # Postgres transaction while secondary transactions are savepoints
173
+ def primary_transaction?() @frames.size == 1 end
174
+
175
+ # Transactionn timestamp
176
+ def timestamp() @conn.timestamp end
177
+
178
+ # True if no tests failed. Default true
179
+ def success?() @success end
180
+
181
+ # True if Postspec is in failed state. In failed state no new commands can
182
+ # be issued. Postspec enters a failed state when it encounters an error and
183
+ # #fail is true. Note that #failed? can be false while #success? is also
184
+ # false. That happens when rspec is called without the --fail-fast option
185
+ def failed?() @failed end
186
+
187
+ # Set failed state but only if #fail is true
188
+ def fail!
189
+ @success = false
190
+ @failed = true if @fail
191
+ end
192
+
193
+ def search_path() @search_path end
194
+ def search_path=(*paths)
195
+ @search_path = Array(paths).flatten.compact
196
+ @search_path = %w(public) if @search_path.empty?
197
+ end
198
+
199
+ # Only called from RSpec::Core::ExampleGroup#set_search_path. FIXME: Why not search_path=
200
+ def set_search_path(rspec, *paths)
201
+ constrain paths, String, [String]
202
+ self.search_path = paths
203
+
204
+ # Closure variables
205
+ this = self
206
+ search_path = Array(paths)
207
+
208
+ rspec.before(:all) {
209
+ frame = this.frames.push NopFrame.new(this.frames.top, search_path)
210
+ this.conn.execute(frame.push_sql)
211
+ }
212
+
213
+ rspec.after(:all) {
214
+ frame = this.frames.pop
215
+ this.conn.execute(frame.pop_sql)
216
+ }
217
+ end
218
+
219
+ def use(rspec, *files)
220
+
221
+ # Closure variables
222
+ this = self
223
+ search_path = self.search_path
224
+
225
+ rspec.before(:all) {
226
+ frame = this.frames.push FoxFrame.new(this.frames.top, search_path, this.type, files)
227
+
228
+ this.conn.push_transaction if frame.transaction?
229
+ begin
230
+ this.conn.execute(frame.push_sql)
231
+ this.conn.commit if !frame.transaction?
232
+ rescue PG::Error
233
+ this.fail!
234
+ this.conn.cancel_transaction
235
+ exit(1)
236
+ end
237
+ }
238
+
239
+ rspec.after(:all) {
240
+ # fail!(clear: !fail) FIXME ????
241
+ frame = this.frames.pop
242
+ this.conn.execute(frame.pop_sql) if !this.failed?
243
+ this.conn.pop_transaction(commit: this.failed?) if frame.transaction?
244
+ this.conn.commit if !frame.transaction?
245
+ }
246
+ end
247
+
248
+ # Can only be used in group context. Encloses subsequent commands in a transaction
249
+ def statement(rspec, sql, fail: true, &block)
250
+ this = self
251
+ search_path = self.search_path
252
+
253
+ rspec.before(:all) {
254
+ frame = this.frames.push NopFrame.new(this.frames.top, search_path)
255
+ this.conn.push_transaction if frame.transaction?
256
+ begin
257
+ this.conn.execute(frame.push_sql)
258
+ this.conn.execute(sql)
259
+ this.conn.commit if !frame.transaction?
260
+ rescue PG::Error
261
+ this.fail!
262
+ this.conn.cancel_transaction
263
+ exit(1)
264
+ end
265
+ }
266
+
267
+ rspec.after(:all) {
268
+ frame = this.frames.pop
269
+ if this.conn.transaction?
270
+ this.conn.pop_transaction(commit: this.failed?) if frame.transaction?
271
+ this.conn.commit if !frame.transaction?
272
+ else
273
+ this.conn.execute(frame.pop_sql) if !this.failed?
274
+ end
275
+ }
276
+ end
277
+
278
+ # Can only be used in example context. Doesn't manipulate transactions
279
+ def exec(sql)
280
+ @datas[-1] = @fixture = nil
281
+ @conn.execute(sql)
282
+ end
283
+
284
+ # Can only be used in example context. Doesn't manipulate transactions
285
+ def execute(sql)
286
+ @datas[-1] = @fixture = nil
287
+ @conn.execute(sql)
288
+ end
289
+
290
+ # Execute procedure. Note that the current transaction is committed before
291
+ # the procedure is run. The transaction stack is reestablished after the
292
+ # test is done. TODO: Why can't it be renamed #call? TODO: Implement args
293
+ def procedure(rspec, stored_procedure, *args)
294
+ this = self
295
+ rspec.before(:all) {
296
+ this.freeze_transaction
297
+ this.conn.execute("call #{stored_procedure}()")
298
+ }
299
+ rspec.around(:each) { |example|
300
+ example.run
301
+ }
302
+ rspec.after(:all) {
303
+ this.thaw_transaction
304
+ }
305
+ end
306
+
307
+ # The connection object
308
+ def db() @conn end
309
+
310
+ # The content of the database as a PgGraph::Data object. Note that this
311
+ # loads the entire database (TODO: Lazy loading)
312
+ def data() @datas[-1] ||= type.instantiate(db, fox.anchors) end
313
+
314
+ # The content of the fixture as a PgGraph::Data object
315
+ def fixture()
316
+ @fixture ||= begin
317
+ if fox.ast
318
+ fox.data
319
+ elsif @foxes.size >= 2
320
+ @foxes[-2].data
321
+ else
322
+ type.instantiate
323
+ end
324
+ end
325
+ end
326
+
327
+ def transaction(&block)
328
+ push_transaction
329
+ yield
330
+ pop_transaction
331
+ end
332
+
333
+ def push_transaction
334
+ @foxes.push (transaction? ? @foxes.last.dup : FixtureFox::Fox.new(type, schema: search_path.first))
335
+ @datas.push nil
336
+ @conn.push_transaction
337
+ end
338
+
339
+ def pop_transaction
340
+ @foxes.pop
341
+ @datas.pop
342
+ fail!(clear: !fail)
343
+ @conn.pop_transaction(commit: failed?)
344
+ end
345
+
346
+ # Starts a transaction if not already in a transaction. Does nothing otherwise
347
+ def ensure_transaction
348
+ push_transaction if !transaction?
349
+ end
350
+
351
+ def freeze_transaction()
352
+ @foxes_stack.push @foxes
353
+ @datas_stack.push @datas
354
+ @foxes.each { @conn.pop_transaction(commit: true) }
355
+ end
356
+
357
+ def thaw_transaction()
358
+ !@foxes_stack.empty? or raise Error, "Stack underrun"
359
+ @foxes = @foxes_stack.pop
360
+ @datas = @datas_stack.pop
361
+ @foxes.each { |fox|
362
+ @conn.push_transaction
363
+ fox.data.write(@conn)
364
+ }
365
+ end
366
+ end
367
+ end
@@ -0,0 +1,128 @@
1
+
2
+ def postspec() Postspec.postspec end
3
+ def use(*args) postspec.use(*args) end
4
+ def statement(*args, **opts, &block) postspec.statement(*args, **opts, &block) end
5
+ def exec(*args) postspec.conn.execute(*args) end
6
+ def execute(*args, **opts, &block) postspec.execute(*args, **opts, &block) end
7
+ def fox() postspec.fox end
8
+ def db() postspec.db end
9
+ def timestamp() postspec.timestamp end
10
+ def data() postspec.data end
11
+ def inserted_records() postspec.inserted end
12
+ def updated_records() postspec.updated end
13
+ def deleted_records() postspec.deleted end
14
+ def anchors() postspec.anchors end
15
+ def fixture() postspec.fixture end
16
+ def transaction(&block) postspec.transaction(&block) end
17
+ def push_transaction() postspec.push_transaction end
18
+ def pop_transaction() postspec.pop_transaction end
19
+
20
+ RSpec.configure do |config|
21
+ config.before(:suite) do
22
+ # puts "postspec_helper before suite"
23
+ $postspec_failed = false
24
+ Postspec.configure { |postspec_config|
25
+ postspec_config.fail_fast ||= (config.fail_fast == 1)
26
+ }
27
+ end
28
+
29
+ config.after(:suite) do
30
+ # puts "postspec_helper after suite"
31
+ postspec.terminate if !postspec.failed?
32
+ end
33
+
34
+ config.around(:example) do |example|
35
+ # puts "postspec_helper around example"
36
+ if postspec.failed?
37
+ example.skip
38
+ else
39
+ postspec.conn.push_transaction
40
+ begin
41
+ # puts ">> TRYING"
42
+ result = example.run
43
+ # puts ">> RESULT: #{result.inspect} (#{result.class})"
44
+ # puts ">> EXAMPLE: #{example.exception.inspect} (#{example.exception.class})"
45
+ # puts ">> EXECUTION_RESULT: #{example.execution_result}"
46
+ if result.is_a?(PG::Error)
47
+ postspec.fail!
48
+ postspec.conn.cancel_transaction
49
+ elsif result.is_a?(Exception)
50
+ postspec.fail!
51
+ postspec.conn.pop_transaction(commit: postspec.failed?)
52
+ else
53
+ postspec.conn.pop_transaction(commit: postspec.failed?)
54
+ end
55
+ rescue
56
+ puts ">> OTHER EXCEPTIONS"
57
+ puts ">> OTHER EXCEPTIONS"
58
+ puts ">> OTHER EXCEPTIONS"
59
+ postspec.conn.cancel_transaction
60
+ postspec.fail!
61
+ raise
62
+ end
63
+ result
64
+ end
65
+ end
66
+ end
67
+
68
+ class RSpec::Core::ExampleGroup
69
+ def self.set_search_path(*args)
70
+ postspec.set_search_path(self, *args)
71
+ end
72
+
73
+ def self.use(*args)
74
+ postspec.use(self, *args)
75
+ end
76
+
77
+ def self.statement(*args)
78
+ postspec.statement(self, *args)
79
+ end
80
+
81
+ def self.procedure(*args)
82
+ postspec.procedure(self, *args)
83
+ end
84
+ end
85
+
86
+ # Monkey patch RSpec to interpret a nil argument to describe/context as "no
87
+ # output and no indentation". It is used in the implementation of #group below
88
+ #
89
+ module RSpec
90
+ module Core
91
+ module Formatters
92
+ # @private
93
+ class DocumentationFormatter
94
+ def example_group_started(notification)
95
+ description = notification.group.description.strip
96
+ if description != ""
97
+ output.puts if @group_level == 0
98
+ output.puts "#{current_indentation}#{notification.group.description.strip}"
99
+ @group_level += 1
100
+ end
101
+ end
102
+
103
+ def example_group_finished(notification)
104
+ description = notification.group.description.strip
105
+ if description != ""
106
+ @group_level -= 1 if @group_level > 0
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+
114
+ # Extend RSpec with a "group" declaration that provides a scope like
115
+ # describe/context but is silent
116
+ #
117
+ module RSpec
118
+ module Core
119
+ class ExampleGroup
120
+ class << self
121
+ def group(&block)
122
+ describe(nil, &block)
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+
@@ -0,0 +1,137 @@
1
+
2
+ drop schema if exists "postspec" cascade;
3
+ create schema postspec;
4
+
5
+ set search_path to postspec;
6
+
7
+ -- Return a map from table UID to last value of sequence. All user tables are
8
+ -- included but tables without sequences (subtables) have last value set to
9
+ -- null
10
+ create or replace function table_sequence_ids() returns table(table_uid text, record_id bigint) as $$
11
+ declare
12
+ name varchar(255);
13
+ tuple record;
14
+ begin
15
+ for tuple in
16
+ select tc.relnamespace::regnamespace::text || '.' || tc.relname as table_uid,
17
+ s.relnamespace::regnamespace::text || '.' || s.relname as sequence_uid
18
+ from pg_class tc
19
+ left join (
20
+ select d.refobjid,
21
+ sc.relname,
22
+ sc.relkind,
23
+ sc.relnamespace::regnamespace::text as relnamespace
24
+ from pg_depend d
25
+ join pg_class sc on sc.oid = d.objid
26
+ where coalesce(sc.relkind = 'S', true)
27
+ and coalesce(sc.relnamespace::regnamespace::text != 'postspec', true)
28
+ ) s on s.refobjid = tc.oid
29
+ where tc.relkind = 'r'
30
+ and tc.relnamespace::regnamespace::text not like 'pg_%'
31
+ and tc.relnamespace::regnamespace::text not in ('information_schema', 'postspec', 'prick')
32
+ loop
33
+ if tuple.sequence_uid is null then
34
+ table_uid := tuple.table_uid;
35
+ record_id := null;
36
+ return next;
37
+ else
38
+ return query execute
39
+ 'select ' || quote_literal(tuple.table_uid) || '::text as table_uid, ' ||
40
+ 'case is_called when true then last_value else last_value - 1 end as last_value ' ||
41
+ 'from ' || tuple.sequence_uid;
42
+ end if;
43
+ end loop;
44
+ return;
45
+ end
46
+ $$ language plpgsql;
47
+
48
+ create or replace function readonly_failure() returns trigger as $$
49
+ begin
50
+ raise 'Postspec: Can''t modify seed data in %', TG_TABLE_NAME::regclass::text;
51
+ return null;
52
+ end;
53
+ $$ language plpgsql immutable leakproof;
54
+
55
+ -- :call-seq:
56
+ -- register_insert(record_id)
57
+ --
58
+ create or replace function register_insert() returns trigger as $$
59
+ begin
60
+ insert into postspec.inserts (table_uid, record_id) values
61
+ (TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME, new.id);
62
+ return new;
63
+ end;
64
+ $$ language plpgsql;
65
+
66
+ -- :call-seq:
67
+ -- register_update(record_id)
68
+ --
69
+ create or replace function register_update() returns trigger as $$
70
+ begin
71
+ insert into postspec.updates (table_uid, record_id) values
72
+ (TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME, old.id);
73
+ return new;
74
+ end;
75
+ $$ language plpgsql;
76
+
77
+ -- :call-seq:
78
+ -- register_delete(record_id)
79
+ --
80
+ create or replace function register_delete() returns trigger as $$
81
+ begin
82
+ insert into postspec.deletes (table_uid, record_id) values
83
+ (TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME, old.id);
84
+ -- ('postspec.' || TG_TABLE_NAME, old.id);
85
+ return old;
86
+ end;
87
+ $$ language plpgsql;
88
+
89
+ create table runs (
90
+ id integer generated by default as identity primary key,
91
+ mode text not null,
92
+ ready boolean not null default false,
93
+ clean boolean not null default false,
94
+ status boolean,
95
+ duration numeric generated always as (
96
+ round(extract(epoch from updated_at - created_at)::numeric, 3)
97
+ ) stored,
98
+ created_at timestamp without time zone not null default (now() at time zone 'UTC'),
99
+ updated_at timestamp without time zone
100
+ );
101
+
102
+ create table seeds (
103
+ id integer generated by default as identity primary key,
104
+ table_uid text,
105
+ record_id integer
106
+ );
107
+
108
+ create table inserts (
109
+ id integer generated by default as identity primary key,
110
+ table_uid text,
111
+ record_id integer
112
+ );
113
+
114
+ create table updates (
115
+ id integer generated by default as identity primary key,
116
+ table_uid text,
117
+ record_id integer
118
+ );
119
+
120
+ create table deletes (
121
+ id integer generated by default as identity primary key,
122
+ table_uid text,
123
+ record_id integer
124
+ );
125
+
126
+ set search_path to public;
127
+
128
+ grant all on schema postspec to public;
129
+ grant all on postspec.runs to public;
130
+ grant all on postspec.seeds to public;
131
+ grant all on postspec.inserts to public;
132
+ grant all on postspec.updates to public;
133
+ grant all on postspec.deletes to public;
134
+
135
+
136
+
137
+