require 'minitest/autorun' require 'prism' require 'tmpdir' # Store has no concept of paranoia at all (see Paranoia#paranoid_scope - it is # just a plain `relation.where(deleted_at: nil)` bolted on by Model.all/.count). # Store#lookup_by_index only ever requires an index's OWN columns to be present # in the equality Hash it is given, never the reverse, so a `status`-only index # is used even when the real condition tree also carries `deleted_at: nil` from # the paranoid scope - the index itself returns every row with that status, # soft-deleted rows included, and Relation#rows re-checks the FULL condition # against every index-returned candidate before returning (see # ConditionNode#indexed_candidates). Store#add_column's backfill # (rebuild_indexes_covering) iterates every row in definition[:rows], which # still holds soft-deleted rows too (paranoia never physically removes them), # so a column added after some rows were already trashed still gets those rows # correctly indexed. None of this required a code change - it was already true # by construction - but nothing had exercised paranoia and secondary indexes # together until now (see prism-postep.md session 82 "kierunki dalsze"). class OrmParanoiaIndexTest < Minitest::Test def setup @dir = Dir.mktmpdir('prism-paranoia-index') @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) @store.create_table(:tickets, %w[id title status deleted_at]) ticket_class = Class.new(Prism::Orm::Model) do table_name :tickets acts_as_paranoid end ticket_class.store = @store Object.const_set(:ParanoidTicket, ticket_class) end def teardown FileUtils.remove_entry(@dir) Object.send(:remove_const, :ParanoidTicket) if Object.const_defined?(:ParanoidTicket) end def test_indexed_where_on_a_paranoid_model_still_excludes_soft_deleted_rows @store.add_index(:tickets, :status) ParanoidTicket.create!(title: 'a', status: 'open') trashed = ParanoidTicket.create!(title: 'b', status: 'open') trashed.destroy visible = ParanoidTicket.where(status: 'open').to_a assert_equal ['a'], visible.map { |ticket| ticket['title'] } # The raw index is paranoia-blind by design: both rows come back for # status "open", the soft-deleted one included. Correctness comes from # Relation#rows re-filtering index candidates through the whole # condition (deleted_at: nil included), not from the index excluding it. raw_hits = @store.lookup_by_index(:tickets, { 'status' => 'open' }) assert_equal ['a', 'b'], raw_hits.map { |row| row['title'] }.sort end def test_add_column_backfill_indexes_a_soft_deleted_row_the_same_as_a_live_one @store.remove_column(:tickets, :status) id_live = @store.insert(:tickets, title: 'a') id_trashed = @store.insert(:tickets, title: 'b') @store.update(:tickets, id_trashed, 'deleted_at' => Time.now.utc) # Index the column BEFORE it exists (like OrmIndexColumnMigrationTest's # own add_column-backfill tests) so add_column's rebuild is the only # thing that can possibly populate this index, for either row. @store.add_index(:tickets, :status) @store.add_column(:tickets, :status, default: 'open') raw_hits = @store.lookup_by_index(:tickets, { 'status' => 'open' }) assert_equal [id_live, id_trashed].sort, raw_hits.map { |row| row['id'] }.sort, 'add_column backfill must reindex every existing row, deleted or not' assert_equal ['a'], ParanoidTicket.where(status: 'open').to_a.map { |ticket| ticket['title'] }, 'the paranoid scope on top of that same index lookup must still hide the trashed row' end def test_restore_brings_a_row_back_into_the_paranoid_scoped_index_lookup @store.add_index(:tickets, :status) trashed = ParanoidTicket.create!(title: 'b', status: 'open') trashed.destroy assert_equal [], ParanoidTicket.where(status: 'open').to_a trashed.restore assert_equal ['b'], ParanoidTicket.where(status: 'open').to_a.map { |ticket| ticket['title'] } end end