postspec 0.1.1

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.
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
+