Skip to content

Commit

Permalink
Fast element deletion (#2063)
Browse files Browse the repository at this point in the history
* Add a fast element deletion service class

Deleting a collection of elements is rather slow at the moment. As
elements as well as contents have a `dependent: :destroy` on them, every
dependent record needs to be loaded and destroyed individually.

This class uses the knowledge we have of elements, contents and essences
in order to be able to destroy a collection of elements in a maximum of
less than 20 database calls (if the collection of elements uses a lot of
different essences, more calls happen, but there's only a limited amount
of essence classes).

There's one additional call to the database in here as a safeguard so we
do not produce orphaned elements. It's fast, and gives a sense of
security.

* Use fast element deletion class in page publisher

A page version's elements collection is comprised of all elements linked
to a page version, so we can use the fast element deletion class
introduced in the previous commit.
  • Loading branch information
mamhoff authored Apr 9, 2021
1 parent 343cc8a commit 8b915e6
Show file tree
Hide file tree
Showing 3 changed files with 115 additions and 1 deletion.
2 changes: 1 addition & 1 deletion app/models/alchemy/page/publisher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def initialize(page)
def publish!(public_on:)
Page.transaction do
version = public_version(public_on)
version.elements.not_nested.destroy_all
DeleteElements.new(version.elements).call

# We must not use .find_each here to not mess up the order of elements
page.draft_version.elements.not_nested.available.each do |element|
Expand Down
44 changes: 44 additions & 0 deletions app/services/alchemy/delete_elements.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# frozen_string_literal: true

module Alchemy
class DeleteElements
class WouldLeaveOrphansError < StandardError; end
attr_reader :elements

def initialize(elements)
@elements = elements
end

def call
if orphanable_records.present?
raise WouldLeaveOrphansError
end

contents = Alchemy::Content.where(element_id: elements.map(&:id))
contents.group_by(&:essence_type)
.transform_values! { |value| value.map(&:essence_id) }
.each do |class_name, ids|
class_name.constantize.where(id: ids).delete_all
end
contents.delete_all
delete_elements
end

private

def orphanable_records
Alchemy::Element.where(parent_element_id: [elements]).where.not(id: elements)
end

def delete_elements
case elements
when ActiveRecord::Associations::CollectionProxy
elements.delete_all(:delete_all)
when ActiveRecord::Relation
elements.delete_all
else
Alchemy::Element.where(id: elements).delete_all
end
end
end
end
70 changes: 70 additions & 0 deletions spec/services/alchemy/delete_elements_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# frozen_string_literal: true

require "rails_helper"

RSpec.describe Alchemy::DeleteElements do
let!(:parent_element) { create(:alchemy_element, :with_nestable_elements, :with_contents) }
let!(:nested_element) { parent_element.nested_elements.first }
let!(:normal_element) { create(:alchemy_element, :with_contents) }

before do
expect(Alchemy::Element.count).not_to be_zero
expect(Alchemy::Content.count).not_to be_zero
expect(Alchemy::EssenceText.count).not_to be_zero
expect(Alchemy::EssencePicture.count).not_to be_zero
expect(Alchemy::EssenceRichtext.count).not_to be_zero
end

subject { Alchemy::DeleteElements.new(elements).call }

context "with all elements" do
let(:elements) { [parent_element, nested_element, normal_element] }

it "destroys all elements" do
subject
expect(Alchemy::Element.count).to be_zero
expect(Alchemy::Content.count).to be_zero
expect(Alchemy::EssenceText.count).to be_zero
expect(Alchemy::EssencePicture.count).to be_zero
expect(Alchemy::EssenceRichtext.count).to be_zero
end

context "when calling with an ActiveRecord::Relation" do
let(:elements) { Alchemy::Element.all }

it "works" do
subject
expect(Alchemy::Element.count).to be_zero
expect(Alchemy::Content.count).to be_zero
expect(Alchemy::EssenceText.count).to be_zero
expect(Alchemy::EssencePicture.count).to be_zero
expect(Alchemy::EssenceRichtext.count).to be_zero
end
end

context "when calling it as an association" do
let(:page_version) { create(:alchemy_page_version) }
let(:elements) { page_version.elements }
before do
Alchemy::Element.update_all(page_version_id: page_version.id)
end

it "works" do
subject
expect(Alchemy::Element.count).to be_zero
expect(Alchemy::Content.count).to be_zero
expect(Alchemy::EssenceText.count).to be_zero
expect(Alchemy::EssencePicture.count).to be_zero
expect(Alchemy::EssenceRichtext.count).to be_zero
end
end
end

context "when calling with an element having nested elements that is not in the collection" do
let(:elements) { [parent_element, normal_element] }

it "raises an error and deletes nothing" do
expect { subject }.to raise_exception(Alchemy::DeleteElements::WouldLeaveOrphansError)
end
end
end

0 comments on commit 8b915e6

Please sign in to comment.