Files
IfcOpenShell/src/ifcparse/tests/test_ifcopenshell_parse.cpp
T
Dion Moult 91622b97b3 ifcparse: keep unresolved references in the attribute slots
The parser could not resolve a #name when it read it, because the
instance may be defined further down the file, so it left the slot empty
and appended (owner, attribute, name) to a side table that a second pass
walked. The table held one entry per reference for the whole read: 64 MB
on a 58 MB model, the high-water mark of opening.

Now the reference stays where the tokenizer put it: the attribute slot
holds the instance_reference, or the reference_or_simple_type aggregate
for a list (mixed with inline typed values or not), until every instance
has been read, and resolve_instance_references() walks each instance's
slots and swaps names for instances. Ordering is what the tokenizer
produced; nothing is re-derived. A missing name becomes null in a scalar
and is dropped from an aggregate, as before; the error keeps its offset.
The three transient alternatives are appended to the attribute pack and
to argument_type in lock step and are never visible once a file is
loaded. Simple type instances read inline (IfcPropertySetDefinitionSet)
have their own slots, so their references need no diversion.

The table remains for the header entities and for streaming consumers of
instance_streamer::references(), which leave resolve_references_in_place
off.

TXG 58 MB / 210_King 147 MB / OKgate22 231 MB, single thread: time
unchanged (1.05 / 2.69 / 4.99 s), memory after the parse 287 -> 274,
698 -> 654, 1086 -> 1036 MB, peak 400 -> 365, 965 -> 871, 1471 -> 1347 MB.

This commit was written by an AI coding tool and has not been verified by
a human.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wcN7XquTfUi4vsKQ4KchL
2026-09-14 19:33:48 +10:00

437 lines
20 KiB
C++

// This file was generated with the assistance of an AI coding tool.
#include <catch2/catch_test_macros.hpp>
#include <ifcparse/exception.h>
#include <ifcparse/file.h>
#include <ifcparse/parse.h>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <vector>
TEST_CASE("SPF strings can be encoded and decoded", "[ifcparse]") {
const std::string decoded = "Caf\xC3\xA9" "'s \\";
const std::string encoded = R"('Caf\X2\00E9\X0\''s \\')";
CHECK(ifcopenshell::encode_spf_string(decoded) == encoded);
CHECK(ifcopenshell::decode_spf_string(encoded) == decoded);
CHECK(ifcopenshell::decode_spf_string(encoded.substr(1, encoded.size() - 2)) == decoded);
}
TEST_CASE("IfcPropertySetDefinitionSet references are resolved without replacing their owner", "[ifcparse]") {
const std::string fixture = std::string(IFCOPENSHELL_TEST_FIXTURES) + "/ColumnPSetsOfSets.ifc";
ifcopenshell::file file(fixture);
REQUIRE(file.good());
const auto relationship = file.instance_by_id(139);
REQUIRE(relationship);
CHECK(relationship.id() == 139);
CHECK(relationship.declaration().name() == "IfcRelDefinesByProperties");
const express::base definition_set = relationship.get_attribute_value(5);
REQUIRE(definition_set);
CHECK(definition_set.declaration().name() == "IfcPropertySetDefinitionSet");
const std::vector<express::base> definitions = definition_set.get_attribute_value(0);
REQUIRE(definitions.size() == 2);
CHECK(definitions[0].id() == 136);
CHECK(definitions[1].id() == 138);
}
TEST_CASE("Bypassed entity types include their subtypes", "[ifcparse]") {
const std::string fixture = std::string(IFCOPENSHELL_TEST_FIXTURES) + "/ColumnPSetsOfSets.ifc";
ifcopenshell::file file(ifcopenshell::uninitialized_tag{});
file.bypass_type("IfcRepresentationItem");
REQUIRE(file.initialize(fixture));
CHECK(file.instances_by_type("IfcRepresentationItem").empty());
CHECK(file.instances_by_type("IfcCartesianPoint").empty());
}
TEST_CASE("Aggregate inverse updates preserve reference multiplicity", "[ifcparse]") {
ifcopenshell::file file(ifcopenshell::schema_by_name("IFC4"));
const auto* segment_declaration = file.schema()->declaration_by_name("IfcCompositeCurveSegment");
auto curve = file.create(file.schema()->declaration_by_name("IfcCompositeCurve"));
auto segment_a = file.create(segment_declaration);
auto segment_b = file.create(segment_declaration);
auto segment_c = file.create(segment_declaration);
auto segment_d = file.create(segment_declaration);
const auto inverse_count = [&file](const express::base& instance) {
return file.instances_by_reference(instance.id()).size();
};
curve.set_attribute_value(0, std::vector<express::base>{segment_a, segment_a, segment_b, segment_c});
CHECK(inverse_count(segment_a) == 2);
CHECK(inverse_count(segment_b) == 1);
CHECK(inverse_count(segment_c) == 1);
CHECK(inverse_count(segment_d) == 0);
curve.set_attribute_value(0, std::vector<express::base>{segment_a, segment_a, segment_b, segment_c, segment_d});
CHECK(inverse_count(segment_a) == 2);
CHECK(inverse_count(segment_b) == 1);
CHECK(inverse_count(segment_c) == 1);
CHECK(inverse_count(segment_d) == 1);
curve.set_attribute_value(0, std::vector<express::base>{segment_a, segment_a, segment_b, segment_c});
CHECK(inverse_count(segment_a) == 2);
CHECK(inverse_count(segment_b) == 1);
CHECK(inverse_count(segment_c) == 1);
CHECK(inverse_count(segment_d) == 0);
const std::vector<express::base> reordered{segment_c, segment_a, segment_b, segment_a};
curve.set_attribute_value(0, reordered);
CHECK((std::vector<express::base>)curve.get_attribute_value(0) == reordered);
CHECK(inverse_count(segment_a) == 2);
CHECK(inverse_count(segment_b) == 1);
CHECK(inverse_count(segment_c) == 1);
CHECK(inverse_count(segment_d) == 0);
curve.set_attribute_value(0, std::vector<express::base>{segment_a, segment_b, segment_b, segment_d});
CHECK(inverse_count(segment_a) == 1);
CHECK(inverse_count(segment_b) == 2);
CHECK(inverse_count(segment_c) == 0);
CHECK(inverse_count(segment_d) == 1);
curve.set_attribute_value(0, std::vector<express::base>{segment_c, segment_c, segment_d});
CHECK(inverse_count(segment_a) == 0);
CHECK(inverse_count(segment_b) == 0);
CHECK(inverse_count(segment_c) == 2);
CHECK(inverse_count(segment_d) == 1);
}
TEST_CASE("Tokens without a string representation do not recurse in to_string()", "[ifcparse]") {
// to_string() used to delegate to as_string() for every token type it did
// not handle explicitly, while as_string() builds its exception message
// with to_string(). An EOF marker or an instance name therefore recursed
// between the two until the stack was exhausted.
ifcopenshell::token eof;
REQUIRE(eof.type == ifcopenshell::token::Token_NONE);
CHECK_THROWS_AS(eof.to_string(), ifcopenshell::invalid_token_exception);
CHECK_THROWS_AS(eof.as_string(), ifcopenshell::invalid_token_exception);
ifcopenshell::token identifier(0, ifcopenshell::token::Token_IDENTIFIER, (int64_t)123);
CHECK(identifier.to_string() == "#123");
CHECK_THROWS_AS(identifier.as_string(), ifcopenshell::invalid_token_exception);
}
TEST_CASE("Files that contain no tokens are rejected rather than crashing", "[ifcparse]") {
// The header parser asks the lexer for a keyword before checking for EOF,
// so input that lexes to zero tokens reaches token::as_string() on the EOF
// marker. Parsing must fail cleanly instead of overflowing the stack.
const std::vector<std::string> inputs{" ", "\r\n\t ", "/* only a comment */"};
for (const auto& contents : inputs) {
INFO("input: " << contents);
ifcopenshell::logger log;
std::istringstream input(contents);
ifcopenshell::file file(input, (int)contents.size(), log);
CHECK(file.good().value() != ifcopenshell::file_open_status::SUCCESS);
}
}
TEST_CASE("Inverse lookups stay consistent across interleaved adds, removals and reads", "[ifcparse]") {
ifcopenshell::file file(ifcopenshell::schema_by_name("IFC4"));
const auto* point_declaration = file.schema()->declaration_by_name("IfcCartesianPoint");
const auto* polyline_declaration = file.schema()->declaration_by_name("IfcPolyline");
auto target = file.create(point_declaration);
auto other = file.create(point_declaration);
const auto referencing_ids = [&file](const express::base& instance) {
std::vector<int> ids;
for (const auto& referencing : file.instances_by_reference(instance.id())) {
ids.push_back(referencing.id());
}
std::sort(ids.begin(), ids.end());
return ids;
};
// Reading an inverse after every write is the pattern that used to
// re-sort the whole index per iteration. Enough iterations to fold the
// delta into the base several times over.
std::vector<express::base> polylines;
std::vector<int> expected;
for (int i = 0; i < 600; ++i) {
auto polyline = file.create(polyline_declaration);
polyline.set_attribute_value(0, std::vector<express::base>{target});
polylines.push_back(polyline);
expected.push_back(polyline.id());
REQUIRE(file.instances_by_reference(target.id()).size() == (size_t)i + 1);
}
CHECK(referencing_ids(target) == expected);
CHECK(file.get_inverse_indices_by_id(target.id()) == std::vector<int>(600, 0));
CHECK(file.get_total_inverses(target.id()) == 600);
// Repointing an attribute removes the old record and adds a new one,
// whether the record lives in the base or in the delta.
for (int i = 0; i < 600; i += 7) {
polylines[i].set_attribute_value(0, std::vector<express::base>{other});
expected.erase(std::find(expected.begin(), expected.end(), polylines[i].id()));
}
CHECK(referencing_ids(target) == expected);
CHECK(file.instances_by_reference(other.id()).size() == 86);
// The same source referencing the target through two attributes yields two records.
const auto* trimmed_curve_declaration = file.schema()->declaration_by_name("IfcTrimmedCurve");
auto trimmed = file.create(trimmed_curve_declaration);
trimmed.set_attribute_value(1, std::vector<express::base>{target});
trimmed.set_attribute_value(2, std::vector<express::base>{target});
CHECK(file.instances_by_reference(target.id()).size() == expected.size() + 2);
CHECK(file.get_total_inverses(target.id()) == expected.size() + 1);
trimmed.set_attribute_value(2, std::vector<express::base>{other});
expected.push_back((int)trimmed.id());
CHECK(referencing_ids(target) == expected);
// Deleting a referencing instance drops its records; deleting the
// target drops the records into it.
file.remove_entity(polylines[1]);
expected.erase(std::find(expected.begin(), expected.end(), polylines[1].id()));
CHECK(referencing_ids(target) == expected);
file.remove_entity(other);
CHECK(file.instances_by_reference(other.id()).empty());
// Removing most of the base tombstones it past the compaction threshold.
for (int i = 2; i < 600; ++i) {
if (i % 7 != 0) {
file.remove_entity(polylines[i]);
}
}
CHECK(referencing_ids(target) == std::vector<int>{(int)trimmed.id()});
}
TEST_CASE("Deleting an instance unregisters the records its own attributes contributed", "[ifcparse]") {
ifcopenshell::file file(ifcopenshell::schema_by_name("IFC4"));
const auto* point_declaration = file.schema()->declaration_by_name("IfcCartesianPoint");
const auto* polyline_declaration = file.schema()->declaration_by_name("IfcPolyline");
const auto* trimmed_curve_declaration = file.schema()->declaration_by_name("IfcTrimmedCurve");
auto target = file.create(point_declaration);
auto second = file.create(point_declaration);
// A reference registered before the first lookup lands in the base tier,
// one registered after it in the delta.
auto base_referencer = file.create(polyline_declaration);
base_referencer.set_attribute_value(0, std::vector<express::base>{target, target});
REQUIRE(file.instances_by_reference(target.id()).size() == 2);
auto delta_referencer = file.create(polyline_declaration);
delta_referencer.set_attribute_value(0, std::vector<express::base>{target, second});
REQUIRE(file.instances_by_reference(target.id()).size() == 3);
// Duplicate references in one aggregate contribute two records; deleting
// the source must drop both.
file.remove_entity(base_referencer);
CHECK(file.instances_by_reference(target.id()).size() == 1);
// Referencing the same instance through two attributes contributes a
// record per attribute; deleting the source must drop them all.
auto trimmed = file.create(trimmed_curve_declaration);
trimmed.set_attribute_value(1, std::vector<express::base>{second});
trimmed.set_attribute_value(2, std::vector<express::base>{second});
CHECK(file.instances_by_reference(second.id()).size() == 3);
file.remove_entity(trimmed);
CHECK(file.instances_by_reference(second.id()).size() == 1);
// Deleting the target first prunes it out of the source's attribute, so
// deleting the source afterwards finds nothing left to unregister.
file.remove_entity(target);
file.remove_entity(delta_referencer);
CHECK(file.instances_by_reference(second.id()).empty());
CHECK(file.get_total_inverses(second.id()) == 0);
}
TEST_CASE("Batch deletion prunes surviving referencers and leaves no stale records", "[ifcparse]") {
ifcopenshell::file file(ifcopenshell::schema_by_name("IFC4"));
const auto* point_declaration = file.schema()->declaration_by_name("IfcCartesianPoint");
const auto* polyline_declaration = file.schema()->declaration_by_name("IfcPolyline");
auto kept_point = file.create(point_declaration);
std::vector<express::base> doomed_points;
for (int i = 0; i < 50; ++i) {
doomed_points.push_back(file.create(point_declaration));
}
// The survivor references every doomed point plus the kept one; a doomed
// referencer references the kept point.
auto survivor = file.create(polyline_declaration);
auto survivor_points = doomed_points;
survivor_points.push_back(kept_point);
survivor.set_attribute_value(0, survivor_points);
auto doomed_referencer = file.create(polyline_declaration);
doomed_referencer.set_attribute_value(0, std::vector<express::base>{kept_point});
REQUIRE(file.instances_by_reference(kept_point.id()).size() == 2);
file.batch();
for (auto& point : doomed_points) {
file.remove_entity(point);
}
file.remove_entity(doomed_referencer);
file.unbatch();
CHECK((std::vector<express::base>)survivor.get_attribute_value(0) == std::vector<express::base>{kept_point});
CHECK(file.instances_by_reference(kept_point.id()).size() == 1);
CHECK(file.get_total_inverses(kept_point.id()) == 1);
for (auto& point : doomed_points) {
CHECK(file.instances_by_reference(point.id()).empty());
}
CHECK(file.instances_by_reference(doomed_referencer.id()).empty());
}
TEST_CASE("Only a 22-character GlobalId is indexed", "[ifcparse]") {
const std::string data =
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('','',(''),(''),'','','');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n"
"#1=IFCWALL('0YvctVUKr0kugbFTf53O9L',$,$,$,$,$,$,$,$);\n"
"#2=IFCWALL('id',$,$,$,$,$,$,$,$);\n"
"ENDSEC;\nEND-ISO-10303-21;\n";
std::string copy(data);
ifcopenshell::file file(copy.data(), (int)copy.size());
REQUIRE(file.good());
CHECK(file.instance_by_guid("0YvctVUKr0kugbFTf53O9L").id() == 1);
CHECK_THROWS(file.instance_by_guid("id"));
CHECK_THROWS(file.instance_by_guid("0YvctVUKr0kugbFTf53O9M"));
// A wall created after the open follows the same rule.
express::base wall = file.instance_by_id(2);
wall.set_attribute_value(0, std::string("1F$7lN9$r5MOA_lpAoNM52"));
CHECK(file.instance_by_guid("1F$7lN9$r5MOA_lpAoNM52").id() == 2);
}
TEST_CASE("The index token policy ends every token where the full policy does, without decoding", "[ifcparse]") {
// Doubled quotes, a \S\' escape (an apostrophe as the page character,
// which a byte scan would take for the end of the string), a \X2\
// escape, a comment, binaries, enumerations, numbers and names.
const std::string data =
"#1=IFCWALL('it''s','a\\S\\'b','\\X2\\00E9\\X0\\c',/* #9 */ #2, \"0A\", .T., -1.5E-3, 42, $, *, (IFCLABEL('x'), #3));\n";
ifcopenshell::file_reader<ifcopenshell::full_buffer_impl> full_reader(data, ifcopenshell::caller_fed_tag{});
ifcopenshell::file_reader<ifcopenshell::full_buffer_impl> index_reader(data, ifcopenshell::caller_fed_tag{});
ifcopenshell::spf_lexer<ifcopenshell::file_reader<ifcopenshell::full_buffer_impl>> full(&full_reader), index(&index_reader);
size_t count = 0;
std::vector<unsigned> names;
while (true) {
ifcopenshell::token a = full.next(), b = index.next<ifcopenshell::index_tokens>();
REQUIRE((bool)a == (bool)b);
if (!a) {
break;
}
++count;
CHECK(a.start_pos == b.start_pos);
CHECK(full_reader.tell() == index_reader.tell());
if (a.is_identifier()) {
REQUIRE(b.is_identifier());
CHECK(a.as_identifier() == b.as_identifier());
names.push_back(b.as_identifier());
} else if (a.is_keyword()) {
REQUIRE(b.is_keyword());
CHECK(a.as_string() == b.as_string());
} else if (a.is_operator()) {
REQUIRE(b.is_operator());
CHECK(a.value_char == b.value_char);
} else if (a.is_string()) {
CHECK(b.type == ifcopenshell::token::Token_STRING);
} else {
CHECK(b.type == ifcopenshell::token::Token_LITERAL);
}
full.reset_pool();
index.reset_pool();
}
CHECK(count == 34);
CHECK(names == std::vector<unsigned>{1, 2, 3});
// And the full policy decoded the escapes.
ifcopenshell::file_reader<ifcopenshell::full_buffer_impl> again(data, ifcopenshell::caller_fed_tag{});
ifcopenshell::spf_lexer<ifcopenshell::file_reader<ifcopenshell::full_buffer_impl>> lexer(&again);
lexer.next(); lexer.next(); lexer.next(); lexer.next();
CHECK(lexer.next().as_string() == "it's");
lexer.next();
CHECK(lexer.next().as_string() == "a\xc2\xa7" "b");
}
namespace {
const char* const reference_resolution_spf =
"ISO-10303-21;\n"
"HEADER;\n"
"FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1');\n"
"FILE_NAME('','',(''),(''),'','','');\n"
"FILE_SCHEMA(('IFC4'));\n"
"ENDSEC;\n"
"DATA;\n"
"#1=IFCCARTESIANPOINT((0.,0.,0.));\n"
"#2=IFCCARTESIANPOINT((1.,0.,0.));\n"
"#3=IFCCARTESIANPOINT((0.,1.,0.));\n"
"#4=IFCPOLYLINE((#1,#2,#3));\n"
"#5=IFCTRIMMEDCURVE(#4,(IFCPARAMETERVALUE(0.),#1),(IFCPARAMETERVALUE(1.)),.T.,.PARAMETER.);\n"
"#6=IFCPROPERTYSINGLEVALUE('A',$,IFCLABEL('x'),$);\n"
"#7=IFCPROPERTYSET('0YvctVUKr0kugbFTf53O9L',$,'Pset',$,(#6,#999));\n"
"#8=IFCWALL('1F$7lN9$r5MOA_lpAoNM52',$,$,$,$,$,$,$,$);\n"
"#9=IFCRELDEFINESBYPROPERTIES('2F$7lN9$r5MOA_lpAoNM53',$,$,$,(#8),#7);\n"
"#10=IFCBSPLINESURFACEWITHKNOTS(1,1,((#1,#2),(#3,#999)),.UNSPECIFIED.,.F.,.F.,.U.,(2,2),(2,2),(0.,1.),(0.,1.),.UNSPECIFIED.);\n"
"#11=IFCRELAGGREGATES('3F$7lN9$r5MOA_lpAoNM54',$,$,$,#999,(#8));\n"
"ENDSEC;\n"
"END-ISO-10303-21;\n";
}
TEST_CASE("References are resolved in place: scalars, lists, nested lists, mixed selects and missing names", "[ifcparse]") {
std::string data(reference_resolution_spf);
ifcopenshell::file file(data.data(), (int)data.size());
REQUIRE(file.good());
const std::vector<express::base> points = file.instance_by_id(4).get_attribute_value(0);
REQUIRE(points.size() == 3);
CHECK(points[0].id() == 1);
CHECK(points[2].id() == 3);
// A select-typed list mixing an inline typed value with a reference.
const std::vector<express::base> trim1 = file.instance_by_id(5).get_attribute_value(1);
REQUIRE(trim1.size() == 2);
CHECK(trim1[0].declaration().name() == "IfcParameterValue");
CHECK(trim1[1].id() == 1);
const std::vector<express::base> trim2 = file.instance_by_id(5).get_attribute_value(2);
REQUIRE(trim2.size() == 1);
CHECK(trim2[0].declaration().name() == "IfcParameterValue");
// A missing name is dropped from a list and nulls a scalar.
const std::vector<express::base> properties = file.instance_by_id(7).get_attribute_value(4);
REQUIRE(properties.size() == 1);
CHECK(properties[0].id() == 6);
CHECK(file.instance_by_id(11).get_attribute_value(4).isNull());
const std::vector<express::base> related = file.instance_by_id(11).get_attribute_value(5);
REQUIRE(related.size() == 1);
CHECK(related[0].id() == 8);
const express::base definition = file.instance_by_id(9).get_attribute_value(5);
REQUIRE(definition);
CHECK(definition.id() == 7);
const std::vector<std::vector<express::base>> control_points = file.instance_by_id(10).get_attribute_value(2);
REQUIRE(control_points.size() == 2);
REQUIRE(control_points[0].size() == 2);
CHECK(control_points[0][1].id() == 2);
REQUIRE(control_points[1].size() == 1);
CHECK(control_points[1][0].id() == 3);
// Inverses were registered for every reference, resolved or not.
CHECK(file.instances_by_reference(1).size() == 3);
CHECK(file.instances_by_reference(8).size() == 2);
}
TEST_CASE("References to bypassed instances are dropped from slots and from mixed lists", "[ifcparse]") {
const auto path = std::filesystem::temp_directory_path() / "ifcopenshell_reference_resolution_test.ifc";
{
std::ofstream out(path);
out << reference_resolution_spf;
}
ifcopenshell::file file(ifcopenshell::uninitialized_tag{});
file.bypass_type("IfcCartesianPoint");
REQUIRE(file.initialize(path.string()));
std::filesystem::remove(path);
const std::vector<express::base> points = file.instance_by_id(4).get_attribute_value(0);
CHECK(points.empty());
const std::vector<express::base> trim1 = file.instance_by_id(5).get_attribute_value(1);
REQUIRE(trim1.size() == 1);
CHECK(trim1[0].declaration().name() == "IfcParameterValue");
const std::vector<std::vector<express::base>> control_points = file.instance_by_id(10).get_attribute_value(2);
REQUIRE(control_points.size() == 2);
CHECK(control_points[0].empty());
CHECK(control_points[1].empty());
}