require "developer_exceptions" require "fixture_fox" require "postspec/version.rb" require "postspec/frame.rb" require "postspec/render.rb" require "postspec/state.rb" require "postspec/environment.rb" require "postspec/config.rb" include DeveloperExceptions # before everything # truncate or setup seed # # after everything # drop seed methods module Postspec class Postspec class Error < RuntimeError; end SHARE_DIR = File.expand_path(File.dirname(__FILE__)) + "/share" # Database connection. A PgConn object attr_reader :conn # Meta object. A PgMeta object attr_reader :meta # Type of the database. A PgGraph::Type object attr_reader :type # List of table types in the database except tables from hidden schemas (eg. postspec) attr_reader :tables # If true and a test case fails, postspec will commit all changes and # ignore any further tests. If rspec is called with the --fail-fast option # the test run will terminate immediately. Default true attr_reader :fail # List of ignored schemas attr_reader :ignore # State attr_reader :state # Map from UID to record ID of inserted, updated, and deleted records forward_to :state, :inserted, :updated, :deleted # Current mode. Can be one of :seed, :empty, ... forward_to :state, :mode # Transaction frame stack attr_reader :frames # Current frame def frame() @frames.top end forward_to :frame, :ids, :anchors # Prick anchors (FIXME) attr_reader :prick_anchors # Render object attr_reader :render # TODO: PgMeta object # # +mode+ can be one of :seed, :empty (TODO :reseed, :keep) def initialize(conn, reflector: nil, mode: :empty, anchors: [], fail: true, ignore: [], cache: nil) constrain conn, PgConn constrain reflector, NilClass, String, PgGraph::Reflector constrain mode, lambda { |m| [:empty, :seed].include?(m) } constrain anchors, [Hash], NilClass constrain fail, TrueClass, FalseClass constrain ignore, [String] constrain cache, String, nil @conn = conn @meta = cache ? PgMeta.cache(@conn, yaml: cache) : PgMeta.new(@conn) # Make sure the postspec schema is not included in the type model. TODO: # Consolidate this into the :ignore option of PgGraph::Type.new as is # done with the prick schema below has_postspec = @meta.schemas.key?("postspec") !has_postspec or (@meta.schemas["postspec"].hidden = true) @type = PgGraph::Type.new(@meta, reflector, ignore: ["prick"] + ignore) @render = Render.new(self) @tables = type.schemas.map(&:tables).flatten @ignore = ignore @fail = fail @failed = false @success = true # Compile-time state variable with the current search_path. Frames are # initialized with this value when they're declared (#use, #statement, # etc.) @search_path = %w(public) # Ensure postgres environment (the postspec schema). TODO: Move into Environment#initialize @environment = Environment.new(self) if @environment.exist? if last_state = State.read(conn) if last_state.ready if !last_state.clean # Last run didn't cleanup after itself @environment.clean # elsif ... HERE end if last_state.mode != mode # We have changed mode @environment.teardown(last_state.mode) @environment.setup(mode) end end else # First run after deep-cleaning end else @environment.create @environment.setup(mode) end # State of current run. Note that @state needs to be initialized after # the previous state has been read into last_state @state = State.create(conn, mode) # Compare seed ids with table_sequence_ids to tell if a table has been # tampered with # # FIXME Implement # %( # select table_uid # record_id # from table_sequence_ids() i # left join postspec.seeds s on s.table_uid = i.table_uid and s.record_id < i.record_id # ) # Frames. TODO: Move into FrameStack#initialize @frames = FrameStack.new(self) if mode == :seed @frames.push SeedFrame.new(self, @environment.uids, FixtureFox::Anchors.new(@type, anchors)) else @frames.push EmptyFrame.new(self) end @conn.execute(render.execution_unit(tables.map(&:uid), frames.top.push_sql)) @foxes_stack = [] # stack of stack of Fox objects. TODO: Why a stack? @datas_stack = [] # stack of stack of Data objects @state.ready = true State.write(conn, @state) end def terminate # puts "Postspec#terminate" @state.status = success? @state.clean = !failed? State.write(conn, @state) if frames.top @conn.execute(frames.top.pop_sql) @frames.pop end @conn.terminate end # Current fox object. This can be nil (TODO alternatively: Create a dummy fox in the root frame) def fox() @frames.top.is_a?(FixtureFox::Fox) ? @frames.top.fox : nil end # True if a transaction is in progress def transaction?() @conn.transaction? end # True if this is the primary transaction. A primary transaction is a # Postgres transaction while secondary transactions are savepoints def primary_transaction?() @frames.size == 1 end # Transactionn timestamp without time zone def timestamp() @conn.timestamp end # Transactionn timestamp with time zone def timestamptz() @conn.timestamptz end # True if no tests failed. Default true def success?() @success end # True if Postspec is in failed state. In failed state no new commands can # be issued. Postspec enters a failed state when it encounters an error and # #fail is true. Note that #failed? can be false while #success? is also # false. That happens when rspec is called without the --fail-fast option def failed?() @failed end # Set failed state but only if #fail is true def fail! @success = false @failed = true if @fail end def search_path() @search_path end def search_path=(*paths) @search_path = Array(paths).flatten.compact @search_path = %w(public) if @search_path.empty? end # Only called from RSpec::Core::ExampleGroup#set_search_path. FIXME: Why not search_path= def set_search_path(rspec, *paths) constrain paths, String, [String] self.search_path = paths # Closure variables this = self search_path = Array(paths) rspec.before(:all) { frame = this.frames.push NopFrame.new(this.frames.top, search_path) this.conn.execute(frame.push_sql) } rspec.after(:all) { frame = this.frames.pop this.conn.execute(frame.pop_sql) } end def use(rspec, *files) # Closure variables this = self search_path = self.search_path rspec.before(:all) { frame = this.frames.push FoxFrame.new(this.frames.top, search_path, this.type, files) this.conn.push_transaction if frame.transaction? begin this.conn.execute(frame.push_sql) this.conn.commit if !frame.transaction? rescue PG::Error this.fail! this.conn.cancel_transaction exit(1) end } rspec.after(:all) { # fail!(clear: !fail) FIXME ???? frame = this.frames.pop this.conn.execute(frame.pop_sql) if !this.failed? this.conn.pop_transaction(commit: this.failed?) if frame.transaction? this.conn.commit if !frame.transaction? } end # Can only be used in group context. Encloses subsequent commands in a transaction def statement(rspec, sql, fail: true, &block) this = self search_path = self.search_path rspec.before(:all) { frame = this.frames.push NopFrame.new(this.frames.top, search_path) this.conn.push_transaction if frame.transaction? begin this.conn.execute(frame.push_sql) this.conn.execute(sql) this.conn.commit if !frame.transaction? rescue PG::Error this.fail! this.conn.cancel_transaction exit(1) end } rspec.after(:all) { frame = this.frames.pop if this.conn.transaction? this.conn.pop_transaction(commit: this.failed?) if frame.transaction? this.conn.commit if !frame.transaction? else this.conn.execute(frame.pop_sql) if !this.failed? end } end # Can only be used in example context. Doesn't manipulate transactions def exec(sql) @datas[-1] = @fixture = nil @conn.execute(sql) end # Can only be used in example context. Doesn't manipulate transactions def execute(sql) @datas[-1] = @fixture = nil @conn.execute(sql) end # Execute procedure. Note that the current transaction is committed before # the procedure is run. The transaction stack is reestablished after the # test is done. TODO: Why can't it be renamed #call? TODO: Implement args def procedure(rspec, stored_procedure, *args) this = self rspec.before(:all) { this.freeze_transaction this.conn.execute("call #{stored_procedure}()") } rspec.around(:each) { |example| example.run } rspec.after(:all) { this.thaw_transaction } end # The connection object def db() @conn end # The content of the database as a PgGraph::Data object. Note that this # loads the entire database (TODO: Lazy loading) def data() @datas[-1] ||= type.instantiate(db, fox.anchors) end # The content of the fixture as a PgGraph::Data object def fixture() @fixture ||= begin if fox.ast fox.data elsif @foxes.size >= 2 @foxes[-2].data else type.instantiate end end end def transaction(&block) push_transaction yield pop_transaction end def push_transaction @foxes.push (transaction? ? @foxes.last.dup : FixtureFox::Fox.new(type, schema: search_path.first)) @datas.push nil @conn.push_transaction end def pop_transaction @foxes.pop @datas.pop fail!(clear: !fail) @conn.pop_transaction(commit: failed?) end # Starts a transaction if not already in a transaction. Does nothing otherwise def ensure_transaction push_transaction if !transaction? end def freeze_transaction() @foxes_stack.push @foxes @datas_stack.push @datas @foxes.each { @conn.pop_transaction(commit: true) } end def thaw_transaction() !@foxes_stack.empty? or raise Error, "Stack underrun" @foxes = @foxes_stack.pop @datas = @datas_stack.pop @foxes.each { |fox| @conn.push_transaction fox.data.write(@conn) } end end end