diff --git a/Gemfile b/Gemfile index 58e371e9216..d552e6cffb6 100644 --- a/Gemfile +++ b/Gemfile @@ -30,6 +30,7 @@ source 'https://rubygems.org' ruby '~> 3.2.1' +gem 'ox' gem 'actionpack-xml_parser', '~> 2.0.0' gem 'activemodel-serializers-xml', '~> 1.0.1' gem 'activerecord-import', '~> 1.4.0' diff --git a/Gemfile.lock b/Gemfile.lock index 760f85fd39a..9b964bce642 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -641,6 +641,7 @@ GEM openproject-token (2.2.0) activemodel os (1.1.4) + ox (2.14.14) paper_trail (12.3.0) activerecord (>= 5.2) request_store (~> 1.1) @@ -1071,6 +1072,7 @@ DEPENDENCIES openproject-webhooks! openproject-xls_export! overviews! + ox paper_trail (~> 12.3) parallel_tests (~> 4.0) pg (~> 1.4.0) diff --git a/modules/bim/lib/open_project/bim/bcf_json/faster_converter.rb b/modules/bim/lib/open_project/bim/bcf_json/faster_converter.rb new file mode 100644 index 00000000000..8fa8eedd5aa --- /dev/null +++ b/modules/bim/lib/open_project/bim/bcf_json/faster_converter.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenProject::Bim::BcfJson + class FasterConverter + # Convert the xml to hash as `Hash.from_xml` would, faster. + class << self + def xml_to_hash(xml) + hash = Ox.load(xml, mode: :hash, symbolize_keys: false) + make_ox_hash_look_like_loaded_by_rexml(hash) + end + + private + + def make_ox_item_look_like_loaded_by_rexml(item) + case item + when Hash + make_ox_hash_look_like_loaded_by_rexml(item) + when Array + make_ox_array_look_like_loaded_by_rexml(item) + else + item + end + end + + def make_ox_hash_look_like_loaded_by_rexml(hash) + hash.transform_values! do |v| + make_ox_item_look_like_loaded_by_rexml(v) + end + end + + def make_ox_array_look_like_loaded_by_rexml(array) + if array_of_single_hash?(array) + make_ox_hash_look_like_loaded_by_rexml(array[0]) + elsif array_of_hashes?(array) + if array_of_similar_hashes?(array) + # array of values like [{x: 1, y: 2}, {x: -3, y: 12}, ...] + array.map! { make_ox_hash_look_like_loaded_by_rexml(_1) } + else + hash = merge_hashes_together(array) + make_ox_hash_look_like_loaded_by_rexml(hash) + end + else + array.map! do |item| + make_ox_item_look_like_loaded_by_rexml(item) + end + end + end + + def array_of_single_hash?(array) + array.size == 1 && array[0].is_a?(Hash) + end + + def array_of_hashes?(array) + array.all? { _1.is_a?(Hash) } + end + + def array_of_similar_hashes?(array) + return false if array.empty? + return false unless array[0].is_a?(Hash) + + keys = array[0].keys + size = keys.size + array.all? { |h| h.is_a?(Hash) && h.size == size && h.keys == keys } + end + + def merge_hashes_together(array) + head, *tail = array + head.merge!(*tail) do |_key, oldval, newval| + case oldval + when Array then oldval << unwrap(newval) + else [oldval, newval] + end + end + end + + def unwrap(value) + case value + in [element] then element + else value + end + end + end + end +end diff --git a/modules/bim/lib/open_project/bim/bcf_json/viewpoint_reader.rb b/modules/bim/lib/open_project/bim/bcf_json/viewpoint_reader.rb index 7a1025ee56d..dd764808163 100644 --- a/modules/bim/lib/open_project/bim/bcf_json/viewpoint_reader.rb +++ b/modules/bim/lib/open_project/bim/bcf_json/viewpoint_reader.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'bigdecimal' module OpenProject::Bim @@ -26,8 +28,7 @@ module OpenProject::Bim # Retrieve the viewpoint hash without root node, if any. def viewpoint_hash @viewpoint_hash ||= begin - # Load from XML using activesupport - hash = Hash.from_xml(xml) + hash = FasterConverter.xml_to_hash(xml) hash = hash[ROOT_NODE] if hash[ROOT_NODE] # Perform destructive transformations diff --git a/modules/bim/spec/lib/open_project/bcf/bcf_json/faster_converter_spec.rb b/modules/bim/spec/lib/open_project/bcf/bcf_json/faster_converter_spec.rb new file mode 100644 index 00000000000..694319db6ed --- /dev/null +++ b/modules/bim/spec/lib/open_project/bcf/bcf_json/faster_converter_spec.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require 'spec_helper' + +RSpec.describe OpenProject::Bim::BcfJson::FasterConverter do + def pps(hash) + PP.pp(hash, +'') + end + + describe '.xml_to_hash' do + it 'deals with single tag without params' do + xml = <<~XML + + XML + expect(described_class.xml_to_hash(xml)).to eq(Hash.from_xml(xml)), 'should be identical to reference implementation' + expect(described_class.xml_to_hash(xml)) + .to eq("Component" => nil) + end + + it 'deals with single tag with params' do + xml = <<~XML + + XML + expect(described_class.xml_to_hash(xml)).to eq(Hash.from_xml(xml)), 'should be identical to reference implementation' + expect(described_class.xml_to_hash(xml)) + .to eq("Component" => { "IfcGuid" => "3_LMnKT5PDNvFeWnu3ys5Q" }) + end + + it 'deals with single tag with inner tags' do + xml = <<~XML + + Revit + 1110420 + + XML + expect(described_class.xml_to_hash(xml)).to eq(Hash.from_xml(xml)), 'should be identical to reference implementation' + expect(described_class.xml_to_hash(xml)) + .to eq( + "Component" => { + "IfcGuid" => "3_LMnKT5PDNvFeWnu3ys5Q", + "OriginatingSystem" => "Revit", + "AuthoringToolId" => "1110420" + } + ) + end + + it 'deals with multiple similar tags with inner tags' do + xml = <<~XML + + + Revit + 1110420 + + + Revit + 1110632 + + + XML + expect(described_class.xml_to_hash(xml)).to eq(Hash.from_xml(xml)), 'should be identical to reference implementation' + expect(described_class.xml_to_hash(xml)) + .to eq( + "root" => { + "Component" => [ + { "IfcGuid" => "3_LMnKT5PDNvFeWnu3ys5Q", "OriginatingSystem" => "Revit", "AuthoringToolId" => "1110420" }, + { "IfcGuid" => "3_LMnKT5PDNvFeWnu3ysAc", "OriginatingSystem" => "Revit", "AuthoringToolId" => "1110632" } + ] + } + ) + end + + it 'deals with outer tag + multiple similar inner tags' do + xml = <<~XML + + + + + XML + expect(described_class.xml_to_hash(xml)).to eq(Hash.from_xml(xml)), 'should be identical to reference implementation' + expect(described_class.xml_to_hash(xml)) + .to eq("Color" => { "Component" => [nil, nil] }) + end + + it 'deals with outer tag with params + multiple similar inner tags' do + xml = <<~XML + + + + + XML + expect(described_class.xml_to_hash(xml)).to eq(Hash.from_xml(xml)), 'should be identical to reference implementation' + expect(described_class.xml_to_hash(xml)) + .to eq("Color" => { "Color" => "3498db", "Component" => [nil, nil] }) + end + + it 'deals with outer tag with params + multiple similar inner tags with params' do + xml = <<~XML + + + + + XML + expect(described_class.xml_to_hash(xml)).to eq(Hash.from_xml(xml)), 'should be identical to reference implementation' + expect(described_class.xml_to_hash(xml)) + .to eq( + "Color" => { + "Color" => "3498db", + "Component" => [ + { "IfcGuid" => "3_LMnKT5PDNvFeWnu3ys5Q" }, + { "IfcGuid" => "3_LMnKT5PDNvFeWnu3ysAc" } + ] + } + ) + end + + it 'deals with outer tag + multiple similar inner tags with inner tags' do + xml = <<~XML + + + id:1 + + + id:2 + + + XML + expect(described_class.xml_to_hash(xml)).to eq(Hash.from_xml(xml)), 'should be identical to reference implementation' + expect(described_class.xml_to_hash(xml)) + .to eq( + "Color" => { + "Component" => [ + { "AuthoringToolId" => "id:1" }, + { "AuthoringToolId" => "id:2" } + ] + } + ) + end + + it 'deals with outer tag + mixed similar inner tags' do + xml = <<~XML + + + + id:2 + + + + XML + expect(described_class.xml_to_hash(xml)).to eq(Hash.from_xml(xml)), 'should be identical to reference implementation' + expect(described_class.xml_to_hash(xml)) + .to eq( + "Color" => { + "Component" => [ + { "IfcGuid" => "guid:1" }, + { "AuthoringToolId" => "id:2" }, + { "IfcGuid" => "guid:3" } + ] + } + ) + end + + # real data tests + Rails.root.glob("modules/bim/spec/fixtures/viewpoints/*.xml").each do |xml_file| + it "converts #{xml_file.basename} like Hash.from_xml() would" do + xml = xml_file.read + fast_hash = pps(described_class.xml_to_hash(xml)) + slow_hash = pps(Hash.from_xml(xml)) + expect(fast_hash).to eq(slow_hash) + end + end + end +end