From d55d8d1dac87bfd6f3e13a62140ee43dfe54e80e Mon Sep 17 00:00:00 2001
From: Furgo <148809153+furgo16@users.noreply.github.com>
Date: Sat, 13 Dec 2025 14:02:13 +0100
Subject: [PATCH] BIM: Report command MVP (#24078)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* BIM: BIM Report MVP
* BIM: add Arch.selectObjects() API call and tests
* BIM: adopted ArchSchedule spreadsheet update and linking patterns
* BIM: SELECT with dynamic headers and dynamic data population
* BIM: fix deletion, dependencies, and serialization issues
* BIM: transition from individual query to multiple-query statements
* BIM: Mostly fix serialization issues
* BIM: Report editor UI/UX improvements
- Make edits directly on table (Description field only, for now)
- Sync table edits with query editor (Description field only, for now)
- Fix Property group name for spreadsheet object
- Add tooltips for all task panel widgets
- Put Description input and label on the same row
- Edit statement is now a static title
- Open report editor upon creation
- Remove overview table's redundant edit button
- Put report query options in a box
- Open spreadsheet after accepting report changes
* BIM: Aggregarion and grouping - implement GROUP BY with COUNT(*)
* BIM: Aggregation and grouping - implement SUM, MIN, MAX functions
* BIM: Aggregation and grouping - implement validation
* BIM: add reporting presets support
* BIM: align description and query preset inputs vertically
* BIM: write units on their own spreadsheet cell
* BIM: update test suite to new SQL engine behaviour
* BIM: fix various bugs: return values should not be stringized, non-grouped query with mixed extractors, handle nested properties
* BIM: expand Report test suite, fix property count vs count all bug
* BIM: add report engine SQL's IN clause support
* BIM: enable running Report presets tests from the build directory
* BIM: make spreadsheet more useful for analysis, units on headers
* BIM: update BIM Report icons
* BIM: Add key option columns to report overview table
* BIM: add syntax highlighting support to report query editor
* BIM: Add lark build dependency for SQL parser generator
* BIM: Install the generated parser
* BIM: implement TYPE() function
* BIM: simplify function registry, make it OOP
* BIM: add CHILDREN function
* BIM: improve SQL engine error descriptions
* BIM: Implement ORDER BY clause
* BIM: Implement column aliasing (AS...)
* BIM: improve error reporting to pinpoint exact token names
* BIM: implement CONCAT, LOWER, UPPER functions. Improve exception handling
* BIM: refactor to improve initialization readability and maintenance
* BIM: Improve query editor syntax highlighting
* BIM: Address CodeQL warnings
* BIM: Enable scalar functions in WHERE clause and refactor transformer
- Enable the use of scalar functions (e.g., `LOWER`, `TYPE`) in `WHERE` clause comparisons.
- Refactor the Lark transformer to correctly handle `NUMBER`, `NULL`, and `ASTERISK` terminals using dedicated methods.
- Refactor error handling to raise a custom `BimSqlSyntaxError` exception instead of returning values on failure.
- Improve syntax error messages to be more specific and user-friendly by inspecting the failing token.
- Fix regressions in `AggregateFunction` and `TypeFunction` handling that were introduced during the refactoring.
- Update the test suite to assert for specific exceptions, aligning with the new error handling API.
* BIM: Implement arithmetic operations in SELECT clause
- Update grammar with `expr`, `term`, and `factor` rules to support operator precedence.
- Introduce `ArithmeticOperation` class to represent calculations as a recursive tree.
- Add transformer methods to build the calculation tree from the grammar rules.
- Implement recursive evaluation that correctly normalizes `Quantity` and `float` types before calculation.
* BIM: Add CONVERT() function for unit conversions
- Implement a new ConvertFunction class to handle unit conversions in the SELECT clause.
- Leverage the core Quantity.getValueAs() FreeCAD API method to perform the conversion logic.
- Add a unit test to verify both successful conversions and graceful failure on incompatible units.
* BIM: add self-documenting supported SQL syntax helper
* BIM: document internal and external API functions
* BIM: Finalize and rename public ArchSql API, update all consumers
- Rename public API functions for clarity and consistency
(run_query_for_objects -> select, get_sql_keywords -> getSqlKeywords,
etc.).
- Establish Arch.py as the official API facade by exposing all public
SQL functions and exceptions via a safe wildcard import from ArchSql,
governed by __all__.
- Refactor all consumers (ArchReport.py, bimtests/TestArchReport.py) to
use the new function names and access the API exclusively through the
Arch facade (e.g., Arch.select).
- Finalize the error handling architecture:
- Arch.select now logs and re-raises exceptions for consumers to
handle.
- Arch.count remains "safe," catching exceptions and returning an
error tuple for the UI.
- Refactor the test suite to correctly assert the behavior of the new
"unsafe" (select) and "safe" (count) APIs, including verifying
specific exception messages.
* BIM: add results preview on demand on the query editor
* BIM: add documentation quick reference button
* BIM: Handle query errors gracefully in Report preview UI
Refactor the SQL engine's error handling to improve the Report preview
UI. Invalid queries from the "Preview Results" button no longer print
tracebacks to the console. Instead, errors are now caught and displayed
contextually within the preview table, providing clear feedback without
console noise.
- `ArchSql.select()` no longer logs errors to the console; it now only
raises a specific exception on failure, delegating handling to the
caller.
- The `ReportTaskPanel`'s preview button handler now wraps its call to
`Arch.select()` in a `try...except` block.
- Caught exceptions are formatted and displayed in the preview table
widget.
- Add new unit test
* BIM: consolidate internal API into a more semantically and functionally meaningful function
* BIM: Implement two-phase execution for SQL engine performance
Refactors the ArchSql engine to improve performance and internal API
semantics. The `Arch.count()` function, used for UI validation, now
executes faster by avoiding unnecessary data extraction.
This is achieved by splitting query execution into two phases. The first
phase performs fast filtering and grouping (`FROM`/`WHERE`/`GROUP BY`).
The second, slower phase processes the `SELECT` columns. The
`Arch.count()` function now only runs the first phase, while
`Arch.select()` runs both.
- Introduces `_run_query(query_string, mode)` as the mode-driven
internal entry point for the SQL engine
- The `SelectStatement` class is refactored with new internal methods:
`_get_grouped_data()` (Phase 1) and `_process_select_columns()`
(Phase 2).
- `Arch.count()` now uses a fast path that includes a "sample execution"
on a single object to correctly validate the full query without
performance loss.
- The internal `execute()` method of `SelectStatement` is now a
coordinator for the two-phase process.
* BIM: Implement autocompletion in Report SQL editor
Introduces an autocompletion feature for the SQL query editor in the BIM
Report task panel. The completer suggests SQL keywords, functions, and
property names to improve query writing speed and accuracy.
- Adds a custom `SqlQueryEditor` subclass that manages all
autocompletion logic within its `keyPressEvent`.
- The completion model is populated with static SQL keywords and
functions, plus all unique top-level property names dynamically
scanned from every object in the document.
- A blocklist is used to filter out common non-queryable properties
(e.g., `Visibility`, `Proxy`) from the suggestions.
- The editor manually calculates the completer popup's width based on
content (`sizeHintForColumn`) to resolve a Qt rendering issue and
ensure suggestions are always visible.
- The first suggestion is now pre-selected, allowing a single `Tab`
press to accept the completion.
* BIM: remove unused import
* BIM: support SQL comments in queries
* BIM: support non-ASCII characters in queries
* BIM: Allow ORDER BY to accept a comma-separated list of columns for multi-level sorting.
* BIM: fix two-way overview/editor sync
* BIM: refactor to simplify editor modification events
* BIM: add tooltips to overview table
* BIM: add tooltips to query editor, enrich syntax data with signature and snippets
* BIM: implement PARENT function
* BIM: Enable property access on SQL function results
Previously, the SQL engine could only access properties directly from
the main object in a given row. This made it impossible to query
attributes of objects returned by functions, such as getting the name of
a parent with PARENT(*). This commit evolves the SQL grammar to handle
chained member access (.) with correct operator precedence. The parser's
transformer and execution logic were updated to recursively resolve
these chains, enabling more intuitive queries like SELECT
PARENT(*).Label and standard nested property access like Shape.Volume.
* BIM: refactor function registration, internationalize API metadata
* BIM: remove outdated Report alias
* BIM: refactor selectObjects to use latest API, move implementation to ArchSql
* BIM: improve friendly token names for error reporting
* BIM: implement chained functions e.g. PARENT(*).PARENT(*)
* BIM: add further tests for property access, fix bug with non-literal AS clause argument
* BIM: Implement full expression support for GROUP BY
The SQL engine's GROUP BY clause was previously limited to simple
property names, failing on queries that used functions (e.g., `GROUP BY
TYPE(*)`). This has been fixed by allowing the SQL transformer and
validator to correctly process function expressions.
The `SelectStatement.validate()` method now uses a canonical signature
to compare SELECT columns against GROUP BY expressions, ensuring
correctness for both simple properties and complex functions.
New regression tests have also been added to validate `GROUP BY`
functionality with chained functions (PPA) and multiple columns.
* BIM: Make arithmetic engine robust against missing properties
Previously, a query containing an arithmetic expression in the `WHERE`
clause would cause a fatal error if it processed an object that was
missing one of the properties used in the calculation.
This has been fixed by making the `ArithmeticOperation` class NULL-safe.
The engine now correctly handles `None` values returned by property
lookups, propagating them through the calculation as per standard SQL
behavior.
As a result, the engine no longer crashes on such queries. It now
gracefully evaluates all objects, simply filtering out those for which
the arithmetic expression cannot be resolved to a valid number. This
significantly improves the robustness of the query engine.
* BIM: Finalize ORDER BY logic and fix query regressions
- The `ORDER BY` clause is now governed by a single, predictable rule:
it can only refer to column names or aliases that exist in the final
`SELECT` list. The engine's transformer and execution logic have been
updated to enforce and correctly implement this, fixing several test
failures related to sorting by aliases and expressions.
- Updated test suite to align with the new engine rules. Add new
dedicated unit tests that explicitly document the supported (aliased
expression) and unsupported (raw expression) syntax for the `ORDER BY`
clause.
* BIM: Implement backend for pipelined report execution
Adds the backend architecture for multi-step, pipelined queries. The
core SQL engine can now execute a statement against the results of a
previous one, enabling complex sequential filtering.
- The internal `_run_query` function was refactored to be
pipeline-aware, allowing it to operate on a pre-filtered list of
source objects.
- A new `execute_pipeline` orchestrator was added to manage the data
flow between statements, controlled by a new `is_pipelined` flag in
the `ReportStatement` data model.
- The public API was extended with `selectObjectsFromPipeline` for
scripting, and `count()` was enhanced to support contextual validation
for the UI.
Pipelines phase 2
Pipelines phase 3
* BIM: refactor to avoid circular imports
* BIM: Address key CodeQl check errors/notes
* BIM: Refactor Task Panel UI/UX with explicit editing workflow
Refactor the report editor UI to improve usability and prevent data
loss. Editing a statement is now an explicit action, triggered by a new
"Edit Selected" button. This prevents accidental changes and enables a
new transactional workflow with "Save" and "Discard" buttons for each
edit session. A checkbox for "Apply & Next" has been added to streamline
editing multiple statements.
The results preview feature has been redesigned into a self-contained,
closable pane controlled by a "Show Preview" toggle. This pane includes
its own contextual "Refresh" button, reducing UI clutter. All action
button groups have been consistently right-aligned to improve layout and
workflow.
* BIM: Integrate pipeline execution and stabilize UI workflow
Completes the implementation of the pipelined statements feature by
integrating the new backend orchestrator with the ArchReport object. It
also includes a series of critical bug fixes that were discovered during
end-to-end testing, resulting in a more stable user experience.
The primary feature integration consists of refactoring the
_ArchReport.execute method. It now uses the ArchSql.execute_pipeline
generator, enabling the report to correctly process multi-step pipelines
and honor the user-configured data flow.
Severalbugs and regressions were fixed:
- Backend: A major flaw was fixed where FROM-clause functions (like
CHILDREN) were not pipeline-aware. The engine's GROUP BY validator was
also corrected to enforce its original, safer design of not supporting
aliases.
- UI Workflow: A feedback loop that caused the editor cursor to reset
was resolved. The transactional logic for the Save, Discard, and "Save
& Add New" actions was corrected to prevent data loss and ensure
predictable behavior. The Add and Duplicate actions no longer auto-open
the editor, creating a more consistent workflow.
- UI State: Fixed regressions related to the explicit editing model,
including incorrect statement loading (Edit Selected) and state
management when a report is reloaded from a file.
* BIM: add presets manager
* BIM: Fix 'still touched after recompute' bug in some of the tests
* BIM: cleanup tests, fixed presets tests to new presets locations
* BIM: Move test model to its own module for reusability
* BIM: Move GUI tests to their own module
- Disable two of the tests: they pass, but cause a segmentation fault on
teardown. They need to be investigated before enabling them on CI.
* BIM: fix bug in interpreting CONVERT string from GROUP BY
* BIM: Migrate signal connections from lambdas to Qt slots
Refactors all signal connections in the `ReportTaskPanel` to use the
`@QtCore.Slot()` decorator instead of lambda wrappers.
- Resolves CodeQL warning "Unnecessary lambda".
- Adheres to PySide/Qt best practices for signal/slot handling.
- Improves code clarity by using explicit, named methods for
connections.
- Prevents potential runtime `TypeError` exceptions by correctly
managing slot signatures for signals that emit arguments (e.g.,
`clicked(bool)`).
- Introduces simple slot wrappers where necessary to cleanly separate UI
event handling from core logic.
* BIM: Address CodeQl warnings
* BIM: Add CHILDREN_RECURSIVE(subquery, max_depth) SQL function to find all descendants of an object set.
- Create internal _get_bim_type and _is_bim_group helpers to remove all Draft module dependencies from the SQL engine.
- Implement a new _traverse_architectural_hierarchy function using a deque-based BFS algorithm to prevent infinite loops.
- The CHILDREN and CHILDREN_RECURSIVE functions now use the new traversal engine.
- The traversal engine now transparently navigates generic groups but excludes them from the results.
- Correct ParentFunction logic to validate containment by checking the parent's .Group list instead of the child's .InList.
- Add unit tests for recursive traversal, depth limiting, and transparent group handling.
- Update test_group_by_multiple_mixed_columns to match the corrected behavior of the TYPE(*) function.
* BIM: Add __repr__ and __str__ for the ArchReport proxy
* BIM: Align report quantity headers with internal units
- Change default `Quantity` headers to use the property's internal unit
(e.g., `mm`) instead of the user's global preferred unit (e.g., `m`).
- Fixes inconsistency where the header unit (e.g., `m²`) did not match
the raw data's unit (e.g., `mm²`), making the default behavior
predictable.
- Implement by parsing `str(Quantity)` as a workaround for the C++
`Unit::getString()` method not being exposed to the Python API.
- Add a unit test that temporarily changes the global schema to verify
the fix is independent of user preferences.
* BIM: remove dual import ArchSql and from ArchSql import
* BIM: Fix IfcRole => IfcType
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* CI: add Lark as a build dependency
* BIM: Replace QRegExp with QRegularExpression for regex patterns
QRegExp is no longer available in PySide6. Make the code compatible with
both PySide2/Qt5 and PySide6/Qt6.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* CI: move lark dependency to pixi's requirements.host
* BIM: Correct numeric comparisons in SQL WHERE clause
Refactor the `BooleanComparison.evaluate` method to handle numeric and
string-based comparisons separately. Previously, all comparisons were
being performed on string-converted values, causing numerically
incorrect results for `Quantity` objects formatted with "smart" units
(e.g., `"10.0 m"` was incorrectly evaluated as less than `"8000"`).
- The `evaluate` method now normalizes `Quantity` objects to their raw
`.Value` first.
- If both operands are numeric, a direct numerical comparison is
performed for all operators (`=`, `!=`, `>`, `<`, `>=`, `<=`).
- String-based comparisons (for `like`, or as a fallback for other
types) are handled in a separate path.
- Add unit test to lock down this behavior.
* BIM: autocompleter improvements
- Add a trailing space when autocompleting the keywords that need it
- Autocomplete multi-word keywords as a unit
* BIM: Improve live validation user experience
- Do not throw syntax error when in the middle of autocompleting a
keyword
- Introduce typing state and show incomplete query status
* BIM: change double-click action to edit in statements overview
Also allow quick editing the statement description with F2
* BIM: Improve user strings for consistency
* BIM: show count of returned objects in statements table
* BIM: disable preview on invalid queries
* BIM: define slot so that tests can run headless
* BIM: Update unit test to adapt to new behaviour
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
---
package/fedora/freecad.spec | 2 +-
package/rattler-build/recipe.yaml | 1 +
package/ubuntu/install-apt-packages.sh | 1 +
src/Mod/BIM/Arch.py | 51 +
src/Mod/BIM/ArchReport.py | 2457 +++++++++++++++
src/Mod/BIM/ArchSql.py | 2630 +++++++++++++++++
src/Mod/BIM/CMakeLists.txt | 41 +
src/Mod/BIM/InitGui.py | 1 +
.../ArchReport/QueryPresets/all_spaces.json | 5 +
.../ArchReport/QueryPresets/all_walls.json | 5 +
.../QueryPresets/count_by_ifc_class.json | 5 +
.../ReportPresets/room_schedule.json | 24 +
.../ReportPresets/wall_quantities.json | 24 +
src/Mod/BIM/Resources/Arch.qrc | 1 +
src/Mod/BIM/Resources/ArchSql.lark | 116 +
.../BIM/Resources/ArchSqlParserGenerator.py | 52 +
src/Mod/BIM/Resources/icons/BIM_Report.svg | 244 ++
src/Mod/BIM/TestArch.py | 1 +
src/Mod/BIM/TestArchGui.py | 1 +
src/Mod/BIM/bimcommands/BimReport.py | 27 +
src/Mod/BIM/bimtests/TestArchBase.py | 34 +
src/Mod/BIM/bimtests/TestArchReport.py | 2113 +++++++++++++
src/Mod/BIM/bimtests/TestArchReportGui.py | 193 ++
src/Mod/BIM/bimtests/fixtures/BimFixtures.py | 214 ++
src/Mod/BIM/bimtests/fixtures/__init__.py | 6 +
25 files changed, 8248 insertions(+), 1 deletion(-)
create mode 100644 src/Mod/BIM/ArchReport.py
create mode 100644 src/Mod/BIM/ArchSql.py
create mode 100644 src/Mod/BIM/Presets/ArchReport/QueryPresets/all_spaces.json
create mode 100644 src/Mod/BIM/Presets/ArchReport/QueryPresets/all_walls.json
create mode 100644 src/Mod/BIM/Presets/ArchReport/QueryPresets/count_by_ifc_class.json
create mode 100644 src/Mod/BIM/Presets/ArchReport/ReportPresets/room_schedule.json
create mode 100644 src/Mod/BIM/Presets/ArchReport/ReportPresets/wall_quantities.json
create mode 100644 src/Mod/BIM/Resources/ArchSql.lark
create mode 100644 src/Mod/BIM/Resources/ArchSqlParserGenerator.py
create mode 100644 src/Mod/BIM/Resources/icons/BIM_Report.svg
create mode 100644 src/Mod/BIM/bimcommands/BimReport.py
create mode 100644 src/Mod/BIM/bimtests/TestArchReport.py
create mode 100644 src/Mod/BIM/bimtests/TestArchReportGui.py
create mode 100644 src/Mod/BIM/bimtests/fixtures/BimFixtures.py
create mode 100644 src/Mod/BIM/bimtests/fixtures/__init__.py
diff --git a/package/fedora/freecad.spec b/package/fedora/freecad.spec
index 904de5ca6f..604dd17179 100644
--- a/package/fedora/freecad.spec
+++ b/package/fedora/freecad.spec
@@ -51,7 +51,7 @@ BuildRequires: gtest-devel gmock-devel
%endif
# Development Libraries
-BuildRequires:boost-devel Coin4-devel eigen3-devel freeimage-devel fmt-devel libglvnd-devel libicu-devel libspnav-devel libXmu-devel med-devel mesa-libEGL-devel mesa-libGLU-devel netgen-mesher-devel netgen-mesher-devel-private opencascade-devel openmpi-devel python3 python3-devel python3-matplotlib python3-pivy python3-pybind11 python3-pyside6-devel python3-shiboken6-devel pyside6-tools qt6-qttools-static qt6-qtsvg-devel vtk-devel xerces-c-devel yaml-cpp-devel
+BuildRequires:boost-devel Coin4-devel eigen3-devel freeimage-devel fmt-devel libglvnd-devel libicu-devel libspnav-devel libXmu-devel med-devel mesa-libEGL-devel mesa-libGLU-devel netgen-mesher-devel netgen-mesher-devel-private opencascade-devel openmpi-devel python3 python3-devel python3-lark python3-matplotlib python3-pivy python3-pybind11 python3-pyside6-devel python3-shiboken6-devel pyside6-tools qt6-qttools-static qt6-qtsvg-devel vtk-devel xerces-c-devel yaml-cpp-devel
#pcl-devel
%if %{without bundled_smesh}
BuildRequires: smesh-devel
diff --git a/package/rattler-build/recipe.yaml b/package/rattler-build/recipe.yaml
index 08dc2f366d..6b8a903203 100644
--- a/package/rattler-build/recipe.yaml
+++ b/package/rattler-build/recipe.yaml
@@ -102,6 +102,7 @@ requirements:
- fmt
- freetype
- hdf5
+ - lark
- libboost-devel
- matplotlib-base
- noqt5
diff --git a/package/ubuntu/install-apt-packages.sh b/package/ubuntu/install-apt-packages.sh
index abcabf02b7..9122e692eb 100755
--- a/package/ubuntu/install-apt-packages.sh
+++ b/package/ubuntu/install-apt-packages.sh
@@ -55,6 +55,7 @@ packages=(
python3-dev
python3-defusedxml
python3-git
+ python3-lark
python3-markdown
python3-matplotlib
python3-packaging
diff --git a/src/Mod/BIM/Arch.py b/src/Mod/BIM/Arch.py
index b275c17915..7d3837f3e1 100644
--- a/src/Mod/BIM/Arch.py
+++ b/src/Mod/BIM/Arch.py
@@ -67,6 +67,7 @@ translate = FreeCAD.Qt.translate
# simply importing the Arch module, as if they were part of this module.
from ArchCommands import *
from ArchWindowPresets import *
+from ArchSql import *
# TODO: migrate this one
# Currently makeStructure, makeStructuralSystem need migration
@@ -2384,3 +2385,53 @@ def _initializeArchObject(
return None
return obj
+
+
+def makeReport(name=None):
+ """
+ Creates a BIM Report object in the active document.
+
+ Parameters
+ ----------
+ name : str, optional
+ The name to assign to the created report object. Defaults to None.
+
+ Returns
+ -------
+ App::FeaturePython
+ The created report object.
+ """
+
+ # Use the helper to create the main object. Note that we pass the
+ # correct class and module names.
+ report_obj = _initializeArchObject(
+ objectType="App::FeaturePython",
+ baseClassName="_ArchReport",
+ internalName="ArchReport",
+ defaultLabel=name if name else translate("Arch", "Report"),
+ moduleName="ArchReport",
+ viewProviderName="ViewProviderReport",
+ )
+
+ # The helper returns None if there's no document, so we can exit early.
+ if not report_obj:
+ return None
+
+ # Initialize the Statements property
+ # Report object proxy needs its Statements list initialized before getSpreadSheet is called,
+ # as getSpreadSheet calls execute() which now relies on obj.Statements.
+ # Initialize with one default statement to provide a starting point for the user.
+ default_stmt = ReportStatement(description=translate("Arch", "New Statement"))
+ report_obj.Statements = [default_stmt.dumps()]
+
+ # Initialize a spreadsheet if the report requests one. The report is responsible for how the
+ # association is stored (we use a non-dependent ``ReportName`` on the sheet and persist the
+ # report's ``Target`` link when the report creates the sheet).
+ if hasattr(report_obj, "Proxy") and hasattr(report_obj.Proxy, "getSpreadSheet"):
+ _ = report_obj.Proxy.getSpreadSheet(report_obj, force=True)
+
+ if FreeCAD.GuiUp:
+ # Automatically open the task panel for the new report
+ FreeCADGui.ActiveDocument.setEdit(report_obj.Name, 0)
+
+ return report_obj
diff --git a/src/Mod/BIM/ArchReport.py b/src/Mod/BIM/ArchReport.py
new file mode 100644
index 0000000000..7e53338f03
--- /dev/null
+++ b/src/Mod/BIM/ArchReport.py
@@ -0,0 +1,2457 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+#
+# Copyright (c) 2025 The FreeCAD Project
+
+import FreeCAD
+import os
+import json
+
+if FreeCAD.GuiUp:
+ from PySide import QtCore, QtWidgets, QtGui
+ from PySide.QtCore import QT_TRANSLATE_NOOP
+ import FreeCADGui
+ from draftutils.translate import translate
+
+ # Create an alias for the Slot decorator for use within the GUI-only classes.
+ Slot = QtCore.Slot
+else:
+
+ def translate(ctxt, txt):
+ return txt
+
+ def QT_TRANSLATE_NOOP(ctxt, txt):
+ return txt
+
+ # In headless mode, create a dummy decorator named 'Slot'. This allows the
+ # Python interpreter to parse the @Slot syntax in GUI-only classes without
+ # raising a NameError because QtCore is not imported.
+ def Slot(*args, **kwargs):
+ def decorator(func):
+ return func
+
+ return decorator
+
+
+import ArchSql
+from ArchSql import ReportStatement
+
+if FreeCAD.GuiUp:
+ ICON_STATUS_OK = FreeCADGui.getIcon(":/icons/edit_OK.svg")
+ ICON_STATUS_WARN = FreeCADGui.getIcon(":/icons/Warning.svg")
+ ICON_STATUS_ERROR = FreeCADGui.getIcon(":/icons/delete.svg")
+ ICON_STATUS_INCOMPLETE = FreeCADGui.getIcon(":/icons/button_invalid.svg")
+ ICON_EDIT = FreeCADGui.getIcon(":/icons/edit-edit.svg")
+ ICON_ADD = FreeCADGui.getIcon(":/icons/list-add.svg")
+ ICON_REMOVE = FreeCADGui.getIcon(":/icons/list-remove.svg")
+ ICON_DUPLICATE = FreeCADGui.getIcon(":/icons/edit-copy.svg")
+
+
+def _get_preset_paths(preset_type):
+ """
+ Gets the file paths for bundled (system) and user preset directories.
+
+ Parameters
+ ----------
+ preset_type : str
+ The type of preset, either 'query' or 'report'.
+
+ Returns
+ -------
+ tuple
+ A tuple containing (system_preset_dir, user_preset_dir).
+ """
+ if preset_type == "query":
+ subdir = "QueryPresets"
+ elif preset_type == "report":
+ subdir = "ReportPresets"
+ else:
+ return None, None
+
+ # Path to the bundled presets installed with FreeCAD
+ system_path = os.path.join(
+ FreeCAD.getResourceDir(), "Mod", "BIM", "Presets", "ArchReport", subdir
+ )
+ # Path to the user's custom presets in their AppData directory
+ user_path = os.path.join(FreeCAD.getUserAppDataDir(), "BIM", "Presets", "ArchReport", subdir)
+
+ return system_path, user_path
+
+
+def _get_presets(preset_type):
+ """
+ Loads all bundled and user presets from the filesystem.
+
+ This function scans the mirrored system and user directories, loading each
+ valid .json file. It is resilient to errors in user-created files.
+
+ Parameters
+ ----------
+ preset_type : str
+ The type of preset to load, either 'query' or 'report'.
+
+ Returns
+ -------
+ dict
+ A dictionary mapping the preset's filename (its stable ID) to a
+ dictionary containing its display name, data, and origin.
+ Example:
+ {
+ "room-schedule.json": {"name": "Room Schedule", "data": {...}, "is_user": False},
+ "c2f5b1a0...json": {"name": "My Custom Report", "data": {...}, "is_user": True}
+ }
+ """
+ system_dir, user_dir = _get_preset_paths(preset_type)
+ presets = {}
+
+ def scan_directory(directory, is_user_preset):
+ if not os.path.isdir(directory):
+ return
+
+ for filename in os.listdir(directory):
+ if not filename.endswith(".json"):
+ continue
+
+ file_path = os.path.join(directory, filename)
+ try:
+ with open(file_path, "r", encoding="utf8") as f:
+ data = json.load(f)
+
+ if "name" not in data:
+ # Graceful handling: use filename as fallback, log a warning
+ display_name = os.path.splitext(filename)[0]
+ FreeCAD.Console.PrintWarning(
+ f"BIM Report: Preset file '{file_path}' is missing a 'name' key. Using filename as fallback.\n"
+ )
+ else:
+ display_name = data["name"]
+
+ # Apply translation only to bundled system presets
+ if not is_user_preset:
+ display_name = translate("Arch", display_name)
+
+ presets[filename] = {"name": display_name, "data": data, "is_user": is_user_preset}
+
+ except json.JSONDecodeError:
+ # Graceful handling: skip malformed file, log a detailed error
+ FreeCAD.Console.PrintError(
+ f"BIM Report: Could not parse preset file at '{file_path}'. It may contain a syntax error.\n"
+ )
+ except Exception as e:
+ FreeCAD.Console.PrintError(
+ f"BIM Report: An unexpected error occurred while loading preset '{file_path}': {e}\n"
+ )
+
+ # Scan system presets first, then user presets. User presets will not
+ # overwrite system presets as their filenames (UUIDs) are unique.
+ scan_directory(system_dir, is_user_preset=False)
+ scan_directory(user_dir, is_user_preset=True)
+
+ return presets
+
+
+def _save_preset(preset_type, name, data):
+ """
+ Saves a preset to a new, individual .json file with a UUID-based filename.
+
+ This function handles name collision checks and ensures the user's preset
+ is saved in their personal AppData directory.
+
+ Parameters
+ ----------
+ preset_type : str
+ The type of preset, either 'query' or 'report'.
+ name : str
+ The desired human-readable display name for the preset.
+ data : dict
+ The dictionary of preset data to be saved as JSON.
+ """
+ import uuid
+
+ _, user_path = _get_preset_paths(preset_type)
+ if not user_path:
+ return
+
+ os.makedirs(user_path, exist_ok=True)
+
+ # --- Name Collision Handling ---
+ existing_presets = _get_presets(preset_type)
+ existing_display_names = {p["name"] for p in existing_presets.values() if p["is_user"]}
+
+ final_name = name
+ counter = 1
+ while final_name in existing_display_names:
+ final_name = f"{name} ({counter:03d})"
+ counter += 1
+
+ # The display name is stored inside the JSON content
+ data_to_save = data.copy()
+ data_to_save["name"] = final_name
+
+ # The filename is a stable, unique identifier
+ filename = f"{uuid.uuid4()}.json"
+ file_path = os.path.join(user_path, filename)
+
+ try:
+ with open(file_path, "w", encoding="utf8") as f:
+ json.dump(data_to_save, f, indent=2)
+ FreeCAD.Console.PrintMessage(
+ f"BIM Report: Preset '{final_name}' saved successfully to '{file_path}'.\n"
+ )
+ except Exception as e:
+ FreeCAD.Console.PrintError(f"BIM Report: Could not save preset to '{file_path}': {e}\n")
+
+
+def _rename_preset(preset_type, filename, new_name):
+ """Renames a user preset by updating the 'name' key in its JSON file."""
+ _, user_path = _get_preset_paths(preset_type)
+ file_path = os.path.join(user_path, filename)
+
+ if not os.path.exists(file_path):
+ FreeCAD.Console.PrintError(
+ f"BIM Report: Cannot rename preset. File not found: {file_path}\n"
+ )
+ return
+
+ try:
+ with open(file_path, "r", encoding="utf8") as f:
+ data = json.load(f)
+
+ data["name"] = new_name
+
+ with open(file_path, "w", encoding="utf8") as f:
+ json.dump(data, f, indent=2)
+ except Exception as e:
+ FreeCAD.Console.PrintError(f"BIM Report: Failed to rename preset file '{file_path}': {e}\n")
+
+
+def _delete_preset(preset_type, filename):
+ """Deletes a user preset file from disk."""
+ _, user_path = _get_preset_paths(preset_type)
+ file_path = os.path.join(user_path, filename)
+
+ if not os.path.exists(file_path):
+ FreeCAD.Console.PrintError(
+ f"BIM Report: Cannot delete preset. File not found: {file_path}\n"
+ )
+ return
+
+ try:
+ os.remove(file_path)
+ except Exception as e:
+ FreeCAD.Console.PrintError(f"BIM Report: Failed to delete preset file '{file_path}': {e}\n")
+
+
+if FreeCAD.GuiUp:
+
+ class SqlQueryEditor(QtWidgets.QPlainTextEdit):
+ """
+ A custom QPlainTextEdit that provides autocompletion features.
+
+ This class integrates QCompleter and handles key events to provide
+ content-based sizing for the popup and a better user experience,
+ such as accepting completions with the Tab key.
+ """
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._completer = None
+ self.setMouseTracking(True) # Required to receive mouseMoveEvents
+ self.api_docs = {}
+ self.clauses = set()
+ self.functions = {}
+
+ def set_api_documentation(self, api_docs: dict):
+ """Receives the API documentation from the panel and caches it."""
+ self.api_docs = api_docs
+ self.clauses = set(api_docs.get("clauses", []))
+ # Create a flat lookup dictionary for fast access
+ for category, func_list in api_docs.get("functions", {}).items():
+ for func_data in func_list:
+ self.functions[func_data["name"]] = {
+ "category": category,
+ "signature": func_data["signature"],
+ "description": func_data["description"],
+ }
+
+ def mouseMoveEvent(self, event: QtGui.QMouseEvent):
+ """Overrides the mouse move event to show tooltips."""
+ cursor = self.cursorForPosition(event.pos())
+ cursor.select(QtGui.QTextCursor.WordUnderCursor)
+ word = cursor.selectedText().upper()
+
+ tooltip_text = self._get_tooltip_for_word(word)
+
+ if tooltip_text:
+ QtWidgets.QToolTip.showText(event.globalPos(), tooltip_text, self)
+ else:
+ QtWidgets.QToolTip.hideText()
+
+ super().mouseMoveEvent(event)
+
+ def _get_tooltip_for_word(self, word: str) -> str:
+ """Builds the HTML-formatted tooltip string for a given word."""
+ if not word:
+ return ""
+
+ # Check if the word is a function
+ if word in self.functions:
+ func_data = self.functions[word]
+ # Format a rich HTML tooltip for functions
+ return (
+ f"
"
+ )
+
+ # Check if the word is a clause
+ if word in self.clauses:
+ # Format a simple, translatable tooltip for clauses
+ # The string itself is marked for translation here.
+ return f"{translate('Arch', 'SQL Clause')}"
+
+ return ""
+
+ def setCompleter(self, completer):
+ if self._completer:
+ self._completer.activated.disconnect(self.insertCompletion)
+
+ self._completer = completer
+ if not self._completer:
+ return
+
+ self._completer.setWidget(self)
+ self._completer.setCompletionMode(QtWidgets.QCompleter.PopupCompletion)
+ self._completer.activated.connect(self.insertCompletion)
+
+ def completer(self):
+ return self._completer
+
+ def insertCompletion(self, completion):
+ if self._completer.widget() is not self:
+ return
+
+ tc = self.textCursor()
+ tc.select(QtGui.QTextCursor.WordUnderCursor)
+ tc.insertText(completion)
+ self.setTextCursor(tc)
+
+ def textUnderCursor(self):
+ tc = self.textCursor()
+ tc.select(QtGui.QTextCursor.WordUnderCursor)
+ return tc.selectedText()
+
+ def keyPressEvent(self, event):
+ # Pass key events to the completer first if its popup is visible.
+ if self._completer and self._completer.popup().isVisible():
+ if event.key() in (
+ QtCore.Qt.Key_Enter,
+ QtCore.Qt.Key_Return,
+ QtCore.Qt.Key_Escape,
+ QtCore.Qt.Key_Tab,
+ QtCore.Qt.Key_Backtab,
+ ):
+ event.ignore()
+ return
+
+ # Let the parent handle the key press to ensure normal typing works.
+ super().keyPressEvent(event)
+
+ # --- Autocompletion Trigger Logic ---
+
+ # A Ctrl+Space shortcut can also be used to trigger completion.
+ is_shortcut = (
+ event.modifiers() & QtCore.Qt.ControlModifier and event.key() == QtCore.Qt.Key_Space
+ )
+
+ completion_prefix = self.textUnderCursor()
+
+ # Don't show completer for very short prefixes unless forced by shortcut.
+ if not is_shortcut and len(completion_prefix) < 2:
+ self._completer.popup().hide()
+ return
+
+ # Show the completer if the prefix has changed.
+ if completion_prefix != self._completer.completionPrefix():
+ self._completer.setCompletionPrefix(completion_prefix)
+ # Select the first item by default for a better UX.
+ self._completer.popup().setCurrentIndex(
+ self._completer.completionModel().index(0, 0)
+ )
+
+ # --- Sizing and Positioning Logic (The critical fix) ---
+ cursor_rect = self.cursorRect()
+
+ # Calculate the required width based on the content of the popup.
+ popup_width = (
+ self._completer.popup().sizeHintForColumn(0)
+ + self._completer.popup().verticalScrollBar().sizeHint().width()
+ )
+ cursor_rect.setWidth(popup_width)
+
+ # Show the completer.
+ self._completer.complete(cursor_rect)
+
+
+class _ArchReportDocObserver:
+ """Document observer that triggers report execution on recompute."""
+
+ def __init__(self, doc, report):
+ self.doc = doc
+ self.report = report
+
+ def slotRecomputedDocument(self, doc):
+ if doc != self.doc:
+ return
+ self.report.Proxy.execute(self.report)
+
+
+class _ArchReport:
+
+ def __init__(self, obj):
+ self.setProperties(obj)
+ # Keep a reference to the host object so helper methods can persist data
+ self.obj = obj
+ obj.Proxy = self
+ self.Type = "ArchReport"
+ self.spreadsheet = None
+ self.docObserver = None
+ self.spreadsheet_current_row = 1 # Internal state for multi-statement reports
+ # This list holds the "live" ReportStatement objects for runtime use (UI, execute)
+ self.live_statements = []
+ # On creation, immediately hydrate the live list from the persistent property
+ self.hydrate_live_statements(obj)
+ # If no persisted statements were present, create one default statement
+ # so the UI shows a starter entry (matching previous behavior).
+ if not self.live_statements:
+ default_stmt = ReportStatement(description=translate("Arch", "New Statement"))
+ self.live_statements.append(default_stmt)
+ # Persist the default starter statement so future loads see it
+ try:
+ self.commit_statements()
+ except Exception:
+ # Be resilient during early initialization when document context
+ # may not be fully available; ignore commit failure.
+ pass
+
+ def onDocumentRestored(self, obj):
+ """Called after the object properties are restored from a file."""
+ # Rebuild the live list of objects from the newly loaded persistent data
+ self.obj = obj
+ self.hydrate_live_statements(obj)
+ self.setProperties(obj) # This will ensure observer is re-attached
+
+ def hydrate_live_statements(self, obj):
+ """(Re)builds the live list of objects from the stored list of dicts."""
+ self.live_statements = []
+ if hasattr(obj, "Statements") and obj.Statements:
+ for s_data in obj.Statements:
+ statement = ReportStatement()
+ statement.loads(s_data) # Use existing loads method
+ self.live_statements.append(statement)
+
+ def commit_statements(self):
+ """
+ Persists the live statements to the document object.
+
+ This method serializes the list of live ReportStatement objects
+ (self.live_statements) into a list of dictionaries and saves it
+ to the persistent obj.Statements property. This is the official
+ programmatic way to commit changes.
+ """
+ self.obj.Statements = [s.dumps() for s in self.live_statements]
+
+ def setProperties(self, obj):
+ # Ensure the `Statements` property exists (list of ReportStatement objects)
+ if not "Statements" in obj.PropertiesList:
+ obj.addProperty(
+ "App::PropertyPythonObject",
+ "Statements",
+ "Report",
+ QT_TRANSLATE_NOOP(
+ "App::Property",
+ "The list of SQL statements to execute (managed by the Task Panel)",
+ ),
+ locked=True,
+ )
+ obj.Statements = [] # Initialize with an empty list
+
+ if not "Target" in obj.PropertiesList:
+ obj.addProperty(
+ "App::PropertyLink",
+ "Target",
+ "Report",
+ QT_TRANSLATE_NOOP("App::Property", "The spreadsheet for the results"),
+ )
+ if not "AutoUpdate" in obj.PropertiesList:
+ obj.addProperty(
+ "App::PropertyBool",
+ "AutoUpdate",
+ "Report",
+ QT_TRANSLATE_NOOP(
+ "App::Property", "If True, update report when document recomputes"
+ ),
+ )
+ obj.AutoUpdate = True
+
+ self.onChanged(obj, "AutoUpdate")
+ # Make the Statements property read-only in the GUI to guide users to the TaskPanel.
+ # Mode 1: Read-Only. It does not affect scripting access.
+ if FreeCAD.GuiUp:
+ obj.setEditorMode("Statements", 1)
+
+ def setReportPropertySpreadsheet(self, sp, obj):
+ """Associate a spreadsheet with a report.
+
+ Ensures the spreadsheet has a non-dependent string property
+ ``ReportName`` with the report's object name, and sets the
+ report's ``Target`` link to the spreadsheet for future writes.
+
+ Parameters
+ - sp: the Spreadsheet::Sheet object to associate
+ - obj: the report object (proxy owner)
+ """
+ if not hasattr(sp, "ReportName"):
+ sp.addProperty(
+ "App::PropertyString",
+ "ReportName",
+ "Report",
+ QT_TRANSLATE_NOOP(
+ "App::Property", "The name of the BIM Report that uses this spreadsheet"
+ ),
+ )
+ sp.ReportName = obj.Name
+ obj.Target = sp
+
+ def getSpreadSheet(self, obj, force=False):
+ """Find or (optionally) create the spreadsheet associated with a report.
+
+ The association is persisted via the sheet's ``ReportName`` string.
+
+ Parameters
+ - obj: the report object
+ - force: if True, create a new spreadsheet when none is found
+ """
+ sp = getattr(self, "spreadsheet", None)
+ if sp and getattr(sp, "ReportName", None) == obj.Name:
+ return sp
+
+ for o in FreeCAD.ActiveDocument.Objects:
+ if o.TypeId == "Spreadsheet::Sheet" and getattr(o, "ReportName", None) == obj.Name:
+ self.spreadsheet = o
+ return self.spreadsheet
+
+ if force:
+ sheet = FreeCAD.ActiveDocument.addObject("Spreadsheet::Sheet", "ReportResult")
+ self.setReportPropertySpreadsheet(sheet, obj)
+ self.spreadsheet = sheet
+ return self.spreadsheet
+ else:
+ return None
+
+ def onChanged(self, obj, prop):
+ if prop == "AutoUpdate":
+ if obj.AutoUpdate:
+ if getattr(self, "docObserver", None) is None:
+ self.docObserver = _ArchReportDocObserver(FreeCAD.ActiveDocument, obj)
+ FreeCAD.addDocumentObserver(self.docObserver)
+ else:
+ if getattr(self, "docObserver", None) is not None:
+ FreeCAD.removeDocumentObserver(self.docObserver)
+ self.docObserver = None
+
+ if prop == "Statements":
+ # If the persistent data is changed externally (e.g., by a script),
+ # re-hydrate the live list to ensure consistency.
+ self.hydrate_live_statements(obj)
+
+ def __getstate__(self):
+ """Returns minimal internal state of the proxy for serialization."""
+ # The main 'Statements' data is persisted on the obj property, not here.
+ return {
+ "Type": self.Type,
+ }
+
+ def __setstate__(self, state):
+ """Restores minimal internal state of the proxy from serialized data."""
+ self.Type = state.get("Type", "ArchReport")
+ self.spreadsheet = None
+ self.docObserver = None
+
+ def _write_cell(self, spreadsheet, cell_address, value):
+ """Intelligently writes a value to a spreadsheet cell based on its type."""
+ # Handle FreeCAD Quantity objects by extracting their raw numerical value.
+ if isinstance(value, FreeCAD.Units.Quantity):
+ spreadsheet.set(cell_address, str(value.Value))
+ elif isinstance(value, (int, float)):
+ # Write other numbers directly without quotes for calculations.
+ spreadsheet.set(cell_address, str(value))
+ elif value is None:
+ # Write an empty literal string for None.
+ spreadsheet.set(cell_address, "''")
+ else:
+ # Write all other types (e.g., strings) as literal strings.
+ spreadsheet.set(cell_address, f"'{value}")
+
+ def setSpreadsheetData(
+ self,
+ obj,
+ headers,
+ data_rows,
+ start_row,
+ use_description_as_header=False,
+ description_text="",
+ include_column_names=True,
+ add_empty_row_after=False,
+ print_results_in_bold=False,
+ force=False,
+ ):
+ """Write headers and rows into the report's spreadsheet, starting from a specific row."""
+ sp = obj.Target # Always use obj.Target directly as it's the explicit link
+ if not sp: # ensure spreadsheet exists, this is an error condition
+ FreeCAD.Console.PrintError(
+ f"Report '{getattr(obj, 'Label', '')}': No target spreadsheet found.\n"
+ )
+ return start_row # Return current row unchanged
+
+ # Determine the effective starting row for this block of data
+ current_row = start_row
+
+ # --- "Analyst-First" Header Generation ---
+ # Pre-scan the first data row to find the common unit for each column.
+ unit_map = {} # e.g., {1: 'mm', 2: 'mm'}
+
+ if data_rows:
+ for i, cell_value in enumerate(data_rows[0]):
+ if isinstance(cell_value, FreeCAD.Units.Quantity):
+ # TODO: Replace this with a direct API call when available. The C++ Base::Unit
+ # class has a `getString()` method that returns the simple unit symbol (e.g.,
+ # "mm^2"), but it is not exposed to the Python API. The most reliable workaround
+ # is to stringify the entire Quantity (e.g., "1500.0 mm") and parse the unit
+ # from that string.
+ quantity_str = str(cell_value)
+ parts = quantity_str.split(" ", 1)
+ if len(parts) > 1:
+ unit_map[i] = parts[1]
+
+ # Create the final headers, appending units where found.
+ final_headers = []
+ for i, header_text in enumerate(headers):
+ if i in unit_map:
+ final_headers.append(f"{header_text} ({unit_map[i]})")
+ else:
+ final_headers.append(header_text)
+
+ # Add header for this statement if requested
+ if use_description_as_header and description_text.strip():
+ # Merging the header across columns (A to last data column)
+ last_col_char = chr(ord("A") + len(final_headers) - 1) if final_headers else "A"
+ sp.set(f"A{current_row}", f"'{description_text}")
+ sp.mergeCells(f"A{current_row}:{last_col_char}{current_row}")
+ sp.setStyle(f"A{current_row}", "bold", "add")
+ current_row += 1 # Advance row for data or column names
+
+ # Write column names if requested
+ if include_column_names and final_headers:
+ for col_idx, header_text in enumerate(final_headers):
+ sp.set(f"{chr(ord('A') + col_idx)}{current_row}", f"'{header_text}")
+ sp.setStyle(
+ f'A{current_row}:{chr(ord("A") + len(final_headers) - 1)}{current_row}',
+ "bold",
+ "add",
+ )
+ current_row += 1 # Advance row for data
+
+ # Write data rows
+ for row_data in data_rows:
+ for col_idx, cell_value in enumerate(row_data):
+ cell_address = f"{chr(ord('A') + col_idx)}{current_row}"
+ self._write_cell(sp, cell_address, cell_value)
+ if print_results_in_bold:
+ sp.setStyle(cell_address, "bold", "add")
+ current_row += 1 # Advance row for next data row
+
+ # Add empty row if specified
+ if add_empty_row_after:
+ current_row += 1 # Just increment row, leave it blank
+
+ return current_row # Return the next available row
+
+ def execute(self, obj):
+ """Executes all statements and writes the results to the target spreadsheet."""
+ if not self.live_statements:
+ return
+
+ sp = self.getSpreadSheet(obj, force=True)
+ if not sp:
+ FreeCAD.Console.PrintError(
+ f"Report '{getattr(obj, 'Label', '')}': No target spreadsheet found.\n"
+ )
+ return
+ sp.clearAll()
+
+ # Reset the row counter for a new report build.
+ self.spreadsheet_current_row = 1
+
+ # The execute_pipeline function is a generator that yields the results
+ # of each standalone statement or the final result of a pipeline chain.
+ for statement, headers, results_data in ArchSql.execute_pipeline(self.live_statements):
+ # For each yielded result block, write it to the spreadsheet.
+ # The setSpreadsheetData helper already handles all the formatting.
+ self.spreadsheet_current_row = self.setSpreadsheetData(
+ obj,
+ headers,
+ results_data,
+ start_row=self.spreadsheet_current_row,
+ use_description_as_header=statement.use_description_as_header,
+ description_text=statement.description,
+ include_column_names=statement.include_column_names,
+ add_empty_row_after=statement.add_empty_row_after,
+ print_results_in_bold=statement.print_results_in_bold,
+ )
+
+ sp.recompute()
+ sp.purgeTouched()
+
+ def __repr__(self):
+ """Provides an unambiguous representation for developers."""
+ return f""
+
+ def __str__(self):
+ """
+ Provides a detailed, human-readable string representation of the report,
+ including the full SQL query for each statement.
+ """
+ num_statements = len(self.live_statements)
+ header = f"BIM Report: '{self.obj.Label}' ({num_statements} statements)"
+
+ lines = [header]
+ if not self.live_statements:
+ return header
+
+ for i, stmt in enumerate(self.live_statements, 1):
+ lines.append("") # Add a blank line for spacing
+
+ # Build the flag string for the statement header
+ flags = []
+ if stmt.is_pipelined:
+ flags.append("Pipelined")
+ if stmt.use_description_as_header:
+ flags.append("Header")
+ flag_str = f" ({', '.join(flags)})" if flags else ""
+
+ # Add the statement header
+ lines.append(f"=== Statement [{i}]: {stmt.description}{flag_str} ===")
+
+ # Add the formatted SQL query
+ if stmt.query_string.strip():
+ query_lines = stmt.query_string.strip().split("\n")
+ for line in query_lines:
+ lines.append(f" {line}")
+ else:
+ lines.append(" (No query defined)")
+
+ return "\n".join(lines)
+
+
+class ViewProviderReport:
+ """The ViewProvider for the ArchReport object."""
+
+ def __init__(self, vobj):
+ vobj.Proxy = self
+ self.vobj = vobj
+
+ def getIcon(self):
+ return ":/icons/Arch_Schedule.svg"
+
+ def doubleClicked(self, vobj):
+ return self.setEdit(vobj, 0)
+
+ def setEdit(self, vobj, mode):
+ if mode == 0:
+ if FreeCAD.GuiUp:
+ panel = ReportTaskPanel(vobj.Object)
+ try:
+ FreeCADGui.Control.showDialog(panel)
+ except RuntimeError as e:
+ # Avoid raising into the caller (e.g., double click handler)
+ FreeCAD.Console.PrintError(f"Could not open Report editor: {e}\n")
+ return False
+ return True
+ return False
+
+ def attach(self, vobj):
+ """Called by the C++ loader when the view provider is rehydrated."""
+ self.vobj = vobj # Ensure self.vobj is set for consistent access
+
+ def claimChildren(self):
+ """
+ Makes the Target spreadsheet appear as a child in the Tree view,
+ by relying on the proxy's getSpreadSheet method for robust lookup.
+ """
+ obj = self.vobj.Object
+ spreadsheet = obj.Proxy.getSpreadSheet(obj)
+ return [spreadsheet] if spreadsheet else []
+
+ def dumps(self):
+ return None
+
+ def loads(self, state):
+ return None
+
+
+class ReportTaskPanel:
+ """Multi-statement task panel for editing a Report.
+
+ Exposes `self.form` as a QWidget so it works with FreeCADGui.Control.showDialog(panel).
+ Implements accept() and reject() to save or discard changes.
+ """
+
+ # A static blocklist of common, non-queryable properties to exclude
+ # from the autocompletion list to reduce noise.
+ PROPERTY_BLOCKLIST = {
+ "ExpressionEngine",
+ "Label2",
+ "Proxy",
+ "ShapeColor",
+ "Visibility",
+ "LineColor",
+ "LineWidth",
+ "PointColor",
+ "PointSize",
+ }
+
+ def __init__(self, report_obj):
+ # Create two top-level widgets so FreeCAD will wrap each into a TaskBox.
+ # Box 1 (overview) contains the statements table and management buttons.
+ # Box 2 (editor) contains the query editor and options.
+ self.obj = report_obj
+ self.current_edited_statement_index = -1 # To track which statement is in editor
+ self.is_dirty = False # To track uncommitted changes
+
+ # Overview widget (TaskBox 1)
+ self.overview_widget = QtWidgets.QWidget()
+ self.overview_widget.setWindowTitle(translate("Arch", "Report Statements"))
+ self.statements_overview_widget = self.overview_widget # preserve older name
+ self.statements_overview_layout = QtWidgets.QVBoxLayout(self.statements_overview_widget)
+
+ # Table for statements: Description | Header | Cols | Status
+ self.table_statements = QtWidgets.QTableWidget()
+ self.table_statements.setColumnCount(5) # Description, Pipe, Header, Cols, Status
+ self.table_statements.setHorizontalHeaderLabels(
+ [
+ translate("Arch", "Description"),
+ translate("Arch", "Pipe"),
+ translate("Arch", "Header"),
+ translate("Arch", "Cols"),
+ translate("Arch", "Status"),
+ ]
+ )
+
+ # Add informative tooltips to the headers
+ self.table_statements.horizontalHeaderItem(2).setToolTip(
+ translate("Arch", "A user-defined description for this statement.")
+ )
+ self.table_statements.horizontalHeaderItem(1).setToolTip(
+ translate(
+ "Arch",
+ "If checked, this statement will use the results of the previous statement as its data source.",
+ )
+ )
+ self.table_statements.horizontalHeaderItem(2).setToolTip(
+ translate(
+ "Arch",
+ "If checked, the Description will be used as a section header in the report.",
+ )
+ )
+ self.table_statements.horizontalHeaderItem(3).setToolTip(
+ translate(
+ "Arch",
+ "If checked, the column names (e.g., 'Label', 'Area') will be included in the report.",
+ )
+ )
+ self.table_statements.horizontalHeaderItem(4).setToolTip(
+ translate("Arch", "Indicates the status of the SQL query.")
+ )
+
+ # Description stretches, others sized to contents
+ self.table_statements.horizontalHeader().setSectionResizeMode(
+ 0, QtWidgets.QHeaderView.Stretch
+ ) # Description
+ self.table_statements.horizontalHeader().setSectionResizeMode(
+ 1, QtWidgets.QHeaderView.ResizeToContents
+ ) # Pipe
+ self.table_statements.horizontalHeader().setSectionResizeMode(
+ 2, QtWidgets.QHeaderView.ResizeToContents
+ )
+ self.table_statements.horizontalHeader().setSectionResizeMode(
+ 3, QtWidgets.QHeaderView.ResizeToContents
+ )
+ self.table_statements.horizontalHeader().setSectionResizeMode(
+ 4, QtWidgets.QHeaderView.ResizeToContents
+ )
+ self.table_statements.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
+ self.table_statements.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection)
+ self.table_statements.setDragDropMode(QtWidgets.QAbstractItemView.InternalMove)
+ self.table_statements.setDragDropOverwriteMode(False)
+ # Allow in-place editing of the description with F2, but disable the
+ # default double-click editing so we can repurpose it.
+ self.table_statements.setEditTriggers(QtWidgets.QAbstractItemView.EditKeyPressed)
+ self.table_statements.verticalHeader().sectionMoved.connect(self._on_row_moved)
+ self.statements_overview_layout.addWidget(self.table_statements)
+
+ # Template controls for full reports
+ self.template_layout = QtWidgets.QHBoxLayout()
+ self.template_dropdown = NoScrollHijackComboBox()
+ self.template_dropdown.setToolTip(
+ translate("Arch", "Load a full report template, replacing all current statements.")
+ )
+ # Enable per-item tooltips in the dropdown view
+ self.template_dropdown.view().setToolTip("")
+ self.btn_manage_templates = QtWidgets.QPushButton(translate("Arch", "Manage..."))
+ self.btn_manage_templates.setToolTip(
+ translate("Arch", "Rename, delete, or edit saved report templates.")
+ )
+ self.btn_save_template = QtWidgets.QPushButton(translate("Arch", "Save as Template..."))
+ self.btn_save_template.setToolTip(
+ translate("Arch", "Save the current set of statements as a new report template.")
+ )
+ self.btn_save_template.setIcon(FreeCADGui.getIcon(":/icons/document-save.svg"))
+ self.template_layout.addWidget(self.template_dropdown)
+ self.template_layout.addWidget(self.btn_manage_templates)
+ self.template_layout.addWidget(self.btn_save_template)
+ template_label = QtWidgets.QLabel(translate("Arch", "Report Templates:"))
+ self.statements_overview_layout.addWidget(template_label)
+ self.statements_overview_layout.addLayout(self.template_layout)
+
+ # Statement Management Buttons
+ self.statement_buttons_layout = QtWidgets.QHBoxLayout()
+ self.btn_add_statement = QtWidgets.QPushButton(ICON_ADD, translate("Arch", "Add Statement"))
+ self.btn_add_statement.setToolTip(
+ translate("Arch", "Add a new blank statement to the report.")
+ )
+ self.btn_remove_statement = QtWidgets.QPushButton(
+ ICON_REMOVE, translate("Arch", "Remove Selected")
+ )
+ self.btn_remove_statement.setToolTip(
+ translate("Arch", "Remove the selected statement from the report.")
+ )
+ self.btn_duplicate_statement = QtWidgets.QPushButton(
+ ICON_DUPLICATE, translate("Arch", "Duplicate Selected")
+ )
+ self.btn_duplicate_statement.setToolTip(
+ translate("Arch", "Create a copy of the selected statement.")
+ )
+ self.btn_edit_selected = QtWidgets.QPushButton(
+ ICON_EDIT, translate("Arch", "Edit Selected")
+ )
+ self.btn_edit_selected.setToolTip(
+ translate("Arch", "Load the selected statement into the editor below.")
+ )
+
+ self.statement_buttons_layout.addWidget(self.btn_add_statement)
+ self.statement_buttons_layout.addWidget(self.btn_remove_statement)
+ self.statement_buttons_layout.addWidget(self.btn_duplicate_statement)
+ self.statement_buttons_layout.addStretch()
+ self.statement_buttons_layout.addWidget(self.btn_edit_selected)
+ self.statements_overview_layout.addLayout(self.statement_buttons_layout)
+
+ # Editor widget (TaskBox 2) -- starts collapsed until a statement is selected
+ self.editor_widget = QtWidgets.QWidget()
+ self.editor_widget.setWindowTitle(translate("Arch", "Statement Editor"))
+ # Keep compatibility name used elsewhere
+ self.editor_box = self.editor_widget
+ self.editor_layout = QtWidgets.QVBoxLayout(self.editor_box)
+
+ # --- Form Layout for Aligned Inputs ---
+ self.form_layout = QtWidgets.QFormLayout()
+ self.form_layout.setContentsMargins(0, 0, 0, 0) # Use the main layout's margins
+
+ # Description Row
+ self.description_edit = QtWidgets.QLineEdit()
+ self.form_layout.addRow(translate("Arch", "Description:"), self.description_edit)
+
+ # Preset Controls Row (widgets are placed in a QHBoxLayout for the second column)
+ self.preset_controls_layout = QtWidgets.QHBoxLayout()
+ self.query_preset_dropdown = NoScrollHijackComboBox()
+ self.query_preset_dropdown.setToolTip(
+ translate("Arch", "Load a saved query preset into the editor.")
+ )
+ # Enable per-item tooltips in the dropdown view
+ self.query_preset_dropdown.view().setToolTip("")
+ self.btn_manage_queries = QtWidgets.QPushButton(translate("Arch", "Manage..."))
+ self.btn_manage_queries.setToolTip(
+ translate("Arch", "Rename, delete, or edit your saved query presets.")
+ )
+ self.btn_save_query_preset = QtWidgets.QPushButton(translate("Arch", "Save..."))
+ self.btn_save_query_preset.setToolTip(
+ translate("Arch", "Save the current query as a new preset.")
+ )
+ self.preset_controls_layout.addWidget(self.query_preset_dropdown)
+ self.preset_controls_layout.addWidget(self.btn_manage_queries)
+ self.preset_controls_layout.addWidget(self.btn_save_query_preset)
+ self.form_layout.addRow(translate("Arch", "Query Presets:"), self.preset_controls_layout)
+
+ self.editor_layout.addLayout(self.form_layout)
+
+ # SQL Query editor
+ self.sql_label = QtWidgets.QLabel(translate("Arch", "SQL Query:"))
+ self.sql_query_edit = SqlQueryEditor()
+ self.sql_query_status_label = QtWidgets.QLabel(translate("Arch", "Ready"))
+ self.sql_query_status_label.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse)
+ # Enable word wrapping to prevent long error messages from expanding the panel.
+ self.sql_query_status_label.setWordWrap(True)
+ # Set a dynamic minimum height of 2 lines to prevent layout shifting
+ # when the label's content changes from 1 to 2 lines.
+ font_metrics = QtGui.QFontMetrics(self.sql_query_status_label.font())
+ two_lines_height = 2.5 * font_metrics.height()
+ self.sql_query_status_label.setMinimumHeight(two_lines_height)
+
+ # --- Attach Syntax Highlighter ---
+ self.sql_highlighter = SqlSyntaxHighlighter(self.sql_query_edit.document())
+
+ # --- Setup Autocompletion ---
+ self.completer = QtWidgets.QCompleter(self.sql_query_edit)
+ self.completion_model = self._build_completion_model()
+ self.completer.setModel(self.completion_model)
+ self.completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive)
+ # We use a custom keyPressEvent in SqlQueryEditor to handle Tab/Enter
+ self.sql_query_edit.setCompleter(self.completer)
+
+ self.editor_layout.addWidget(self.sql_label)
+ self.editor_layout.addWidget(self.sql_query_edit)
+ self.editor_layout.addWidget(self.sql_query_status_label)
+
+ # --- Debugging Actions (Show Preview, Help) ---
+ self.debugging_actions_layout = QtWidgets.QHBoxLayout()
+
+ self.btn_toggle_preview = QtWidgets.QPushButton(translate("Arch", "Show Preview"))
+ self.btn_toggle_preview.setIcon(FreeCADGui.getIcon(":/icons/Std_ToggleVisibility.svg"))
+ self.btn_toggle_preview.setToolTip(
+ translate("Arch", "Show a preview pane to test the current query in isolation.")
+ )
+ self.btn_toggle_preview.setCheckable(True) # Make it a toggle button
+
+ self.btn_show_cheatsheet = QtWidgets.QPushButton(translate("Arch", "SQL Cheatsheet"))
+ self.btn_show_cheatsheet.setIcon(FreeCADGui.getIcon(":/icons/help-browser.svg"))
+ self.btn_show_cheatsheet.setToolTip(
+ translate("Arch", "Show a cheatsheet of the supported SQL syntax.")
+ )
+
+ self.editor_layout.addLayout(self.debugging_actions_layout)
+ self.debugging_actions_layout.addStretch() # Add stretch first for right-alignment
+ self.debugging_actions_layout.addWidget(self.btn_show_cheatsheet)
+ self.debugging_actions_layout.addWidget(self.btn_toggle_preview)
+
+ # --- Self-Contained Preview Pane ---
+ self.preview_pane = QtWidgets.QWidget()
+ preview_pane_layout = QtWidgets.QVBoxLayout(self.preview_pane)
+ preview_pane_layout.setContentsMargins(0, 5, 0, 0) # Add a small top margin
+
+ preview_toolbar_layout = QtWidgets.QHBoxLayout()
+ self.btn_refresh_preview = QtWidgets.QPushButton(translate("Arch", "Refresh"))
+ self.btn_refresh_preview.setIcon(FreeCADGui.getIcon(":/icons/view-refresh.svg"))
+ self.btn_refresh_preview.setToolTip(
+ translate("Arch", "Re-run the query and update the preview table.")
+ )
+ preview_toolbar_layout.addWidget(
+ QtWidgets.QLabel(translate("Arch", "Query Results Preview"))
+ )
+ preview_toolbar_layout.addStretch()
+ preview_toolbar_layout.addWidget(self.btn_refresh_preview)
+
+ self.table_preview_results = QtWidgets.QTableWidget()
+ self.table_preview_results.setMinimumHeight(150)
+ self.table_preview_results.setEditTriggers(
+ QtWidgets.QAbstractItemView.NoEditTriggers
+ ) # Make read-only
+ self.table_preview_results.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
+ preview_pane_layout.addLayout(preview_toolbar_layout)
+ preview_pane_layout.addWidget(self.table_preview_results)
+ self.editor_layout.addWidget(self.preview_pane)
+
+ # Display Options GroupBox
+ self.display_options_group = QtWidgets.QGroupBox(translate("Arch", "Display Options"))
+ self.display_options_layout = QtWidgets.QVBoxLayout(self.display_options_group)
+
+ self.chk_is_pipelined = QtWidgets.QCheckBox(translate("Arch", "Use as Pipeline Step"))
+ self.chk_is_pipelined.setToolTip(
+ translate(
+ "Arch",
+ "When checked, this statement will use the results of the previous statement as its data source.",
+ )
+ )
+ self.chk_use_description_as_header = QtWidgets.QCheckBox(
+ translate("Arch", "Use Description as Section Header")
+ )
+ self.chk_use_description_as_header.setToolTip(
+ translate(
+ "Arch",
+ "When checked, the statement's description will be written as a merged header row before its results.",
+ )
+ )
+ self.chk_include_column_names = QtWidgets.QCheckBox(
+ translate("Arch", "Include Column Names as Headers")
+ )
+ self.chk_include_column_names.setToolTip(
+ translate(
+ "Arch",
+ "Include the column headers (Label, IfcType, ...) in the spreadsheet output.",
+ )
+ )
+ self.chk_add_empty_row_after = QtWidgets.QCheckBox(translate("Arch", "Add Empty Row After"))
+ self.chk_add_empty_row_after.setToolTip(
+ translate("Arch", "Insert one empty row after this statement's results.")
+ )
+ self.chk_print_results_in_bold = QtWidgets.QCheckBox(
+ translate("Arch", "Print Results in Bold")
+ )
+ self.chk_print_results_in_bold.setToolTip(
+ translate("Arch", "Render the result cells in bold font for emphasis.")
+ )
+ self.display_options_layout.addWidget(self.chk_is_pipelined)
+ self.display_options_layout.addWidget(self.chk_use_description_as_header)
+ self.display_options_layout.addWidget(self.chk_include_column_names)
+ self.display_options_layout.addWidget(self.chk_add_empty_row_after)
+ self.display_options_layout.addWidget(self.chk_print_results_in_bold)
+ self.editor_layout.addWidget(self.display_options_group)
+
+ # --- Commit Actions (Apply, Discard) ---
+ self.commit_actions_layout = QtWidgets.QHBoxLayout()
+ self.chk_save_and_next = QtWidgets.QCheckBox(translate("Arch", "Save and Next"))
+ self.chk_save_and_next.setToolTip(
+ translate(
+ "Arch",
+ "If checked, clicking 'Save' will automatically load the next statement for editing.",
+ )
+ )
+ self.btn_save = QtWidgets.QPushButton(translate("Arch", "Save"))
+ self.btn_save.setIcon(FreeCADGui.getIcon(":/icons/document-save.svg"))
+ self.btn_save.setToolTip(
+ translate("Arch", "Save changes to this statement and close the statement editor.")
+ )
+ self.btn_discard = QtWidgets.QPushButton(translate("Arch", "Discard"))
+ self.btn_discard.setIcon(FreeCADGui.getIcon(":/icons/delete.svg"))
+ self.btn_discard.setToolTip(
+ translate("Arch", "Discard all changes made in the statement editor.")
+ )
+ self.commit_actions_layout.addStretch()
+ self.commit_actions_layout.addWidget(self.chk_save_and_next)
+ self.commit_actions_layout.addWidget(self.btn_discard)
+ self.commit_actions_layout.addWidget(self.btn_save)
+ self.editor_layout.addLayout(self.commit_actions_layout)
+
+ # Expose form as a list of the two top-level widgets so FreeCAD creates
+ # two built-in TaskBox sections. The overview goes first, editor second.
+ self.form = [self.overview_widget, self.editor_widget]
+
+ # --- Connections ---
+ # Use explicit slots instead of lambda wrappers so Qt's meta-object
+ # system can see the call targets and avoid creating anonymous functions.
+ self.btn_add_statement.clicked.connect(self._on_add_statement_clicked)
+ self.btn_remove_statement.clicked.connect(self._on_remove_selected_statement_clicked)
+ self.btn_duplicate_statement.clicked.connect(self._on_duplicate_selected_statement_clicked) # type: ignore
+ self.btn_edit_selected.clicked.connect(self._on_edit_selected_clicked)
+ self.table_statements.itemSelectionChanged.connect(self._on_table_selection_changed)
+ self.table_statements.itemDoubleClicked.connect(self._on_item_double_clicked)
+ self.template_dropdown.activated.connect(self._on_load_report_template)
+
+ # Keep table edits in sync with the runtime statements
+ self.table_statements.itemChanged.connect(self._on_table_item_changed)
+ self.btn_save_template.clicked.connect(self._on_save_report_template)
+
+ # Enable and connect the preset management buttons
+ self.btn_manage_templates.setEnabled(True)
+ self.btn_manage_queries.setEnabled(True)
+ self.btn_manage_templates.clicked.connect(lambda: self._on_manage_presets("report"))
+ self.btn_manage_queries.clicked.connect(lambda: self._on_manage_presets("query"))
+
+ # Connect all editor fields to a generic handler to manage the dirty state.
+ self.description_edit.textChanged.connect(self._on_editor_field_changed)
+ self.sql_query_edit.textChanged.connect(self._on_editor_sql_changed)
+ for checkbox in self.display_options_group.findChildren(QtWidgets.QCheckBox):
+ checkbox.stateChanged.connect(self._on_editor_field_changed)
+ self.query_preset_dropdown.activated.connect(self._on_load_query_preset)
+ self.chk_is_pipelined.stateChanged.connect(self._on_editor_sql_changed)
+ self.btn_save_query_preset.clicked.connect(self._on_save_query_preset)
+
+ # Preview and Commit connections
+ self.btn_toggle_preview.toggled.connect(self._on_preview_toggled)
+ self.btn_refresh_preview.clicked.connect(self._run_and_display_preview)
+ self.btn_save.clicked.connect(self.on_save_clicked)
+ self.btn_discard.clicked.connect(self.on_discard_clicked)
+ self.btn_show_cheatsheet.clicked.connect(self._show_cheatsheet_dialog)
+
+ # Validation Timer for live SQL preview
+ # Timer doesn't need a specific QWidget parent here; use no parent.
+ self.validation_timer = QtCore.QTimer()
+ self.validation_timer.setSingleShot(True)
+ self.validation_timer.timeout.connect(self._run_live_validation_for_editor)
+
+ # Store icons for dynamic button changes
+ self.icon_show_preview = FreeCADGui.getIcon(":/icons/Std_ToggleVisibility.svg")
+ self.icon_hide_preview = FreeCADGui.getIcon(":/icons/Invisible.svg")
+
+ # Initial UI setup
+ self._load_and_populate_presets()
+ self._populate_table_from_statements()
+ # Pass the documentation data to the editor for its tooltips
+ api_docs = ArchSql.getSqlApiDocumentation()
+ self.sql_query_edit.set_api_documentation(api_docs)
+ self.editor_widget.setVisible(False) # Start with editor hidden
+ self._update_ui_for_mode("overview") # Set initial button states
+
+ def _load_and_populate_presets(self):
+ """Loads all presets and populates the UI dropdowns, including tooltips."""
+
+ def _populate_combobox(combobox, preset_type, placeholder_text):
+ """Internal helper to load presets and populate a QComboBox."""
+ # Load the raw preset data from the backend
+ presets = _get_presets(preset_type)
+
+ # Prepare the UI widget
+ combobox.clear()
+ # The placeholder_text is already translated by the caller
+ combobox.addItem(placeholder_text)
+
+ model = combobox.model()
+
+ sorted_presets = sorted(presets.items(), key=lambda item: item[1]["name"])
+
+ # Populate the combobox with the sorted presets
+ for filename, preset in sorted_presets:
+ # Add the item with its display name and stable filename (as userData)
+ combobox.addItem(preset["name"], userData=filename)
+
+ # Get the index of the item that was just added
+ index = combobox.count() - 1
+
+ # Access the description from the nested "data" dictionary.
+ description = preset["data"].get("description", "").strip()
+
+ if description:
+ item = model.item(index)
+ if item:
+ item.setToolTip(description)
+
+ return presets
+
+ # Use the helper function to populate both dropdowns,
+ # ensuring the placeholder strings are translatable.
+ self.query_presets = _populate_combobox(
+ self.query_preset_dropdown, "query", translate("Arch", "--- Select a Query Preset ---")
+ )
+ self.report_templates = _populate_combobox(
+ self.template_dropdown, "report", translate("Arch", "--- Load a Report Template ---")
+ )
+
+ def _on_manage_presets(self, mode):
+ """
+ Launches the ManagePresetsDialog and refreshes the dropdowns
+ when the dialog is closed.
+ """
+ dialog = ManagePresetsDialog(mode, parent=self.form[0])
+ dialog.exec_()
+
+ # Refresh the dropdowns to reflect any changes made
+ self._load_and_populate_presets()
+
+ @Slot("QTableWidgetItem")
+ def _on_item_double_clicked(self, item):
+ """Handles a double-click on an item in the statements table."""
+ if item:
+ # A double-click is a shortcut for editing the full statement.
+ self._start_edit_session(row_index=item.row())
+
+ # --- Statement Management (Buttons and Table Interaction) ---
+ def _populate_table_from_statements(self):
+ # Avoid emitting itemChanged while we repopulate programmatically
+ self.table_statements.blockSignals(True)
+ self.table_statements.setRowCount(0) # Clear existing rows
+ # The UI always interacts with the live list of objects from the proxy
+ for row_idx, statement in enumerate(self.obj.Proxy.live_statements):
+ self.table_statements.insertRow(row_idx)
+ # Description (editable text)
+ desc_item = QtWidgets.QTableWidgetItem(statement.description)
+ desc_item.setFlags(
+ desc_item.flags()
+ | QtCore.Qt.ItemIsEditable
+ | QtCore.Qt.ItemIsSelectable
+ | QtCore.Qt.ItemIsEnabled
+ )
+ desc_item.setToolTip(translate("Arch", "Double-click to edit description in place."))
+ self.table_statements.setItem(row_idx, 0, desc_item)
+
+ # Pipe checkbox
+ pipe_item = QtWidgets.QTableWidgetItem()
+ pipe_item.setFlags(
+ QtCore.Qt.ItemIsUserCheckable | QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
+ )
+ pipe_item.setCheckState(
+ QtCore.Qt.Checked if statement.is_pipelined else QtCore.Qt.Unchecked
+ )
+ if row_idx == 0:
+ pipe_item.setFlags(
+ pipe_item.flags() & ~QtCore.Qt.ItemIsEnabled
+ ) # Disable for first row
+ pipe_item.setToolTip(translate("Arch", "The first statement cannot be pipelined."))
+ else:
+ pipe_item.setToolTip(
+ translate(
+ "Arch", "Toggle whether to use the previous statement's results as input."
+ )
+ )
+ self.table_statements.setItem(row_idx, 1, pipe_item)
+
+ # Header checkbox
+ header_item = QtWidgets.QTableWidgetItem()
+ header_item.setFlags(
+ QtCore.Qt.ItemIsUserCheckable | QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
+ )
+ header_item.setCheckState(
+ QtCore.Qt.Checked if statement.use_description_as_header else QtCore.Qt.Unchecked
+ )
+ header_item.setToolTip(
+ translate(
+ "Arch",
+ "Toggle whether to use this statement's Description as a section header.",
+ )
+ )
+ self.table_statements.setItem(row_idx, 2, header_item)
+
+ # Cols checkbox (Include Column Names)
+ cols_item = QtWidgets.QTableWidgetItem()
+ cols_item.setFlags(
+ QtCore.Qt.ItemIsUserCheckable | QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
+ )
+ cols_item.setCheckState(
+ QtCore.Qt.Checked if statement.include_column_names else QtCore.Qt.Unchecked
+ )
+ cols_item.setToolTip(
+ translate(
+ "Arch", "Toggle whether to include this statement's column names in the report."
+ )
+ )
+ self.table_statements.setItem(row_idx, 3, cols_item)
+
+ # Status Item (Icon + Tooltip) - read-only
+ status_icon, status_tooltip = self._get_status_icon_and_tooltip(statement)
+ status_item = QtWidgets.QTableWidgetItem()
+ status_item.setIcon(status_icon)
+ status_item.setToolTip(status_tooltip)
+ # Display the object count next to the icon for valid queries.
+ if statement._validation_status in ("OK", "0_RESULTS"):
+ status_item.setText(str(statement._validation_count))
+ # Align the text to the right for better visual separation.
+ status_item.setTextAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
+ status_item.setFlags(status_item.flags() & ~QtCore.Qt.ItemIsEditable) # Make read-only
+ self.table_statements.setItem(row_idx, 4, status_item)
+
+ # After populating all rows, trigger a validation for all statements.
+ # This ensures the counts and statuses are up-to-date when the panel opens.
+ for statement in self.obj.Proxy.live_statements:
+ statement.validate_and_update_status()
+ self._update_table_row_status(
+ self.obj.Proxy.live_statements.index(statement), statement
+ )
+
+ # Re-enable signals after population so user edits are handled
+ self.table_statements.blockSignals(False)
+
+ # --- Explicit Qt Slot Wrappers ---
+ @Slot()
+ def _on_add_statement_clicked(self):
+ """Slot wrapper for the Add button (clicked)."""
+ # Default behavior: create a new statement but do not open editor.
+ self._add_statement(start_editing=False)
+
+ @Slot()
+ def _on_remove_selected_statement_clicked(self):
+ """Slot wrapper for the Remove button (clicked)."""
+ self._remove_selected_statement()
+
+ @Slot()
+ def _on_duplicate_selected_statement_clicked(self):
+ """Slot wrapper for the Duplicate button (clicked)."""
+ self._duplicate_selected_statement()
+
+ @Slot()
+ def _on_edit_selected_clicked(self):
+ """Slot wrapper for the Edit Selected button (clicked)."""
+ # Delegate to _start_edit_session() which will find the selection if no
+ # explicit row_index is given.
+ self._start_edit_session()
+
+ def _on_table_item_changed(self, item):
+ """Synchronize direct table edits (description and checkboxes) back into the runtime statement."""
+ row = item.row()
+ col = item.column()
+ if row < 0 or row >= len(self.obj.Proxy.live_statements):
+ return
+ stmt = self.obj.Proxy.live_statements[row]
+
+ if col == 0: # Description
+ new_text = item.text()
+ if stmt.description != new_text:
+ stmt.description = new_text
+ self._set_dirty(True)
+
+ elif col == 1: # Pipe checkbox
+ is_checked = item.checkState() == QtCore.Qt.Checked
+ if stmt.is_pipelined != is_checked:
+ stmt.is_pipelined = is_checked
+ self._set_dirty(True)
+ # Re-validate the editor if its context has changed
+ if self.current_edited_statement_index != -1:
+ self._run_live_validation_for_editor()
+
+ elif col == 2: # Header checkbox
+ is_checked = item.checkState() == QtCore.Qt.Checked
+ if stmt.use_description_as_header != is_checked:
+ stmt.use_description_as_header = is_checked
+ self._set_dirty(True)
+
+ elif col == 3: # Cols checkbox
+ is_checked = item.checkState() == QtCore.Qt.Checked
+ if stmt.include_column_names != is_checked:
+ stmt.include_column_names = is_checked
+ self._set_dirty(True)
+
+ def _on_row_moved(self, logical_index, old_visual_index, new_visual_index):
+ """Handles the reordering of statements via drag-and-drop."""
+ # The visual index is what the user sees. The logical index is tied to the original sort.
+ # When a row is moved, we need to map the visual change back to our data model.
+
+ # Pop the item from its original position in the data model.
+ moving_statement = self.obj.Proxy.live_statements.pop(old_visual_index)
+ # Insert it into its new position.
+ self.obj.Proxy.live_statements.insert(new_visual_index, moving_statement)
+
+ self._set_dirty(True)
+ # After reordering the data, we must repopulate the table to ensure
+ # everything is visually correct and consistent, especially the disabled
+ # "Pipe" checkbox on the new first row.
+ self._populate_table_from_statements()
+ # Restore the selection to the row that was just moved.
+ self.table_statements.selectRow(new_visual_index)
+
+ def _add_statement(self, start_editing=False):
+ """Creates a new statement, adds it to the report, and optionally starts editing it."""
+ # Create the new statement object and add it to the live list.
+ new_statement = ReportStatement(
+ description=translate(
+ "Arch", f"New Statement {len(self.obj.Proxy.live_statements) + 1}"
+ )
+ )
+ self.obj.Proxy.live_statements.append(new_statement)
+
+ # Refresh the entire overview table to show the new row.
+ self._populate_table_from_statements()
+
+ # Validate the new (empty) statement to populate its status.
+ new_statement.validate_and_update_status()
+
+ new_row_index = len(self.obj.Proxy.live_statements) - 1
+ if start_editing:
+ self._start_edit_session(row_index=new_row_index)
+ else:
+ self.table_statements.selectRow(new_row_index)
+
+ self._set_dirty(True)
+
+ def _remove_selected_statement(self):
+ selected_rows = self.table_statements.selectionModel().selectedRows()
+ if not selected_rows:
+ return
+
+ row_to_remove = selected_rows[0].row()
+ description_to_remove = self.table_statements.item(row_to_remove, 0).text()
+
+ if (
+ QtWidgets.QMessageBox.question(
+ None,
+ translate("Arch", "Remove Statement"),
+ translate(
+ "Arch", f"Are you sure you want to remove statement '{description_to_remove}'?"
+ ),
+ QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
+ )
+ == QtWidgets.QMessageBox.Yes
+ ):
+ self.obj.Proxy.live_statements.pop(row_to_remove)
+ self._set_dirty(True)
+ self._populate_table_from_statements()
+ self._end_edit_session() # Close editor and reset selection
+
+ def _duplicate_selected_statement(self):
+ """Duplicates the selected statement without opening the editor."""
+ selected_rows = self.table_statements.selectionModel().selectedRows()
+ if not selected_rows:
+ return
+
+ row_to_duplicate = selected_rows[0].row()
+ original = self.obj.Proxy.live_statements[row_to_duplicate]
+
+ duplicated = ReportStatement()
+ duplicated.loads(original.dumps())
+ duplicated.description = translate("Arch", f"Copy of {original.description}")
+
+ self.obj.Proxy.live_statements.insert(row_to_duplicate + 1, duplicated)
+ self._set_dirty(True)
+ self._populate_table_from_statements()
+ duplicated.validate_and_update_status()
+
+ # New behavior: Just select the newly created row. Do NOT open the editor.
+ self.table_statements.selectRow(row_to_duplicate + 1)
+
+ def _select_statement_in_table(self, row_idx):
+ # Select the row visually and trigger the new edit session
+ self.table_statements.selectRow(row_idx)
+ # This method should ONLY select, not start an edit session.
+
+ # --- Editor (Box 2) Management ---
+
+ def _load_statement_to_editor(self, statement: ReportStatement):
+ # Disable/enable the pipeline checkbox based on row index
+ is_first_statement = self.current_edited_statement_index == 0
+ self.chk_is_pipelined.setEnabled(not is_first_statement)
+ if is_first_statement:
+ # Ensure the first statement can never be pipelined
+ statement.is_pipelined = False
+
+ self.description_edit.setText(statement.description)
+ self.sql_query_edit.setPlainText(statement.query_string)
+ self.chk_is_pipelined.setChecked(statement.is_pipelined)
+ self.chk_use_description_as_header.setChecked(statement.use_description_as_header)
+ self.chk_include_column_names.setChecked(statement.include_column_names)
+ self.chk_add_empty_row_after.setChecked(statement.add_empty_row_after)
+ self.chk_print_results_in_bold.setChecked(statement.print_results_in_bold)
+
+ # We must re-run validation here because the context may have changed
+ self._run_live_validation_for_editor()
+
+ def _save_current_editor_state_to_statement(self):
+ if self.current_edited_statement_index != -1 and self.current_edited_statement_index < len(
+ self.obj.Proxy.live_statements
+ ):
+ statement = self.obj.Proxy.live_statements[self.current_edited_statement_index]
+ statement.description = self.description_edit.text()
+ statement.query_string = self.sql_query_edit.toPlainText()
+ statement.use_description_as_header = self.chk_use_description_as_header.isChecked()
+ statement.include_column_names = self.chk_include_column_names.isChecked()
+ statement.add_empty_row_after = self.chk_add_empty_row_after.isChecked()
+ statement.print_results_in_bold = self.chk_print_results_in_bold.isChecked()
+ statement.validate_and_update_status() # Update status in the statement object
+ self._update_table_row_status(
+ self.current_edited_statement_index, statement
+ ) # Refresh table status
+
+ def _on_editor_sql_changed(self):
+ """Handles text changes in the SQL editor, triggering validation."""
+ self._on_editor_field_changed() # Mark as dirty
+ # Immediately switch to a neutral "Typing..." state to provide
+ # instant feedback and hide any previous validation messages.
+ self.sql_query_status_label.setText(translate("Arch", "Typing..."))
+ self.sql_query_status_label.setStyleSheet("color: gray;")
+ # Start (or restart) the timer for the full validation.
+ self.validation_timer.start(500)
+
+ def _on_editor_field_changed(self, *args):
+ """A generic slot that handles any change in an editor field to mark it as dirty.
+
+ This method is connected to multiple signal signatures (textChanged -> str,
+ stateChanged -> int). Leaving it undecorated (or accepting *args) keeps it
+ flexible so Qt can call it with varying argument lists.
+ """
+ self._set_dirty(True)
+
+ @Slot(int)
+ def _on_load_query_preset(self, index):
+ """Handles the selection of a query preset from the dropdown."""
+ if index == 0: # Ignore the placeholder item
+ return
+
+ filename = self.query_preset_dropdown.itemData(index)
+ preset_data = self.query_presets.get(filename, {}).get("data")
+
+ if not preset_data:
+ FreeCAD.Console.PrintError(
+ f"BIM Report: Could not load data for query preset with filename '{filename}'.\n"
+ )
+ self.query_preset_dropdown.setCurrentIndex(0)
+ return
+
+ # Confirm before overwriting existing text
+ if self.sql_query_edit.toPlainText().strip():
+ reply = QtWidgets.QMessageBox.question(
+ None,
+ translate("Arch", "Overwrite Query?"),
+ translate(
+ "Arch",
+ "Loading a preset will overwrite the current text in the query editor. Continue?",
+ ),
+ QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
+ QtWidgets.QMessageBox.No,
+ )
+ if reply == QtWidgets.QMessageBox.No:
+ self.query_preset_dropdown.setCurrentIndex(0) # Reset dropdown
+ return
+
+ if "query" in preset_data:
+ self.sql_query_edit.setPlainText(preset_data["query"])
+
+ # Reset dropdown to act as a one-shot action button
+ self.query_preset_dropdown.setCurrentIndex(0)
+
+ @Slot()
+ def _on_save_query_preset(self):
+ """Saves the current query text as a new user preset."""
+ current_query = self.sql_query_edit.toPlainText().strip()
+ if not current_query:
+ QtWidgets.QMessageBox.warning(
+ None,
+ translate("Arch", "Empty Query"),
+ translate("Arch", "Cannot save an empty query as a preset."),
+ )
+ return
+
+ preset_name, ok = QtWidgets.QInputDialog.getText(
+ None, translate("Arch", "Save Query Preset"), translate("Arch", "Preset Name:")
+ )
+ if ok and preset_name:
+ # The data payload does not include the 'name' key; _save_preset adds it.
+ preset_data = {"description": "User-defined query preset.", "query": current_query}
+ _save_preset("query", preset_name, preset_data)
+ self._load_and_populate_presets() # Refresh the dropdown with the new preset
+
+ @Slot(int)
+ def _on_load_report_template(self, index):
+ """Handles the selection of a full report template from the dropdown."""
+ if index == 0:
+ return
+
+ filename = self.template_dropdown.itemData(index)
+ template_data = self.report_templates.get(filename, {}).get("data")
+
+ if not template_data:
+ FreeCAD.Console.PrintError(
+ f"BIM Report: Could not load data for template with filename '{filename}'.\n"
+ )
+ self.template_dropdown.setCurrentIndex(0)
+ return
+
+ if self.obj.Proxy.live_statements:
+ reply = QtWidgets.QMessageBox.question(
+ None,
+ translate("Arch", "Overwrite Report?"),
+ translate(
+ "Arch",
+ "Loading a template will replace all current statements in this report. Continue?",
+ ),
+ QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
+ QtWidgets.QMessageBox.No,
+ )
+ if reply == QtWidgets.QMessageBox.No:
+ self.template_dropdown.setCurrentIndex(0)
+ return
+
+ if "statements" in template_data:
+ # Rebuild the live list from the template data
+ self.obj.Proxy.live_statements = []
+ for s_data in template_data["statements"]:
+ statement = ReportStatement()
+ statement.loads(s_data)
+ self.obj.Proxy.live_statements.append(statement)
+
+ self._populate_table_from_statements()
+
+ # Terminate any active editing session, as loading a template invalidates it. This
+ # correctly resets the entire UI state.
+ self._end_edit_session()
+
+ self._set_dirty(True)
+
+ self.template_dropdown.setCurrentIndex(0)
+
+ @Slot()
+ def _on_save_report_template(self):
+ """Saves the current set of statements as a new report template."""
+ if not self.obj.Proxy.live_statements:
+ QtWidgets.QMessageBox.warning(
+ None,
+ translate("Arch", "Empty Report"),
+ translate("Arch", "Cannot save an empty report as a template."),
+ )
+ return
+
+ template_name, ok = QtWidgets.QInputDialog.getText(
+ None, translate("Arch", "Save Report Template"), translate("Arch", "Template Name:")
+ )
+ if ok and template_name:
+ # The data payload does not include the 'name' key.
+ template_data = {
+ "description": "User-defined report template.",
+ "statements": [s.dumps() for s in self.obj.Proxy.live_statements],
+ }
+ _save_preset("report", template_name, template_data)
+ self._load_and_populate_presets() # Refresh the template dropdown
+
+ def _run_live_validation_for_editor(self):
+ """
+ Runs live validation for the query in the editor, providing
+ contextual feedback if the statement is part of a pipeline.
+ This method does NOT modify the underlying statement object.
+ """
+ if self.current_edited_statement_index == -1:
+ return
+
+ current_query = self.sql_query_edit.toPlainText()
+ is_pipelined = self.chk_is_pipelined.isChecked()
+
+ # Create a temporary, in-memory statement object for validation.
+ # This prevents mutation of the real data model.
+ temp_statement = ReportStatement()
+
+ source_objects = None
+ input_count_str = ""
+
+ if is_pipelined and self.current_edited_statement_index > 0:
+ preceding_statements = self.obj.Proxy.live_statements[
+ : self.current_edited_statement_index
+ ]
+ source_objects = ArchSql._execute_pipeline_for_objects(preceding_statements)
+ input_count = len(source_objects)
+ input_count_str = translate("Arch", f" (from {input_count} in pipeline)")
+
+ count, error = ArchSql.count(current_query, source_objects=source_objects)
+
+ # --- Update the UI display using the validation results ---
+ if not error and count > 0:
+ temp_statement._validation_status = "OK"
+ temp_statement._validation_message = f"{translate('Arch', 'Found')} {count} {translate('Arch', 'objects')}{input_count_str}."
+ elif not error and count == 0:
+ temp_statement._validation_status = "0_RESULTS"
+ # The message for 0 results is more of a warning than a success.
+ temp_statement._validation_message = (
+ f"{translate('Arch', 'Query is valid but found 0 objects')}{input_count_str}."
+ )
+ elif error == "INCOMPLETE":
+ temp_statement._validation_status = "INCOMPLETE"
+ temp_statement._validation_message = translate("Arch", "Query is incomplete")
+ else: # An actual error occurred
+ temp_statement._validation_status = "ERROR"
+ temp_statement._validation_message = f"{error}{input_count_str}"
+
+ self._update_editor_status_display(temp_statement)
+
+ def _update_editor_status_display(self, statement: ReportStatement):
+ # Update the status label (below SQL editor) in Box 2
+ # The "Typing..." state is now handled instantly by _on_editor_sql_changed.
+ # This method only handles the final states (Incomplete, Error, 0, OK).
+ if statement._validation_status == "INCOMPLETE":
+ self.sql_query_status_label.setText(f"⚠️ {statement._validation_message}")
+ self.sql_query_status_label.setStyleSheet("color: orange;")
+ elif statement._validation_status == "ERROR":
+ self.sql_query_status_label.setText(f"❌ {statement._validation_message}")
+ self.sql_query_status_label.setStyleSheet("color: red;")
+ elif statement._validation_status == "0_RESULTS":
+ self.sql_query_status_label.setText(f"⚠️ {statement._validation_message}")
+ self.sql_query_status_label.setStyleSheet("color: orange;")
+ else: # OK or Ready
+ self.sql_query_status_label.setText(f"✅ {statement._validation_message}")
+ self.sql_query_status_label.setStyleSheet("color: green;")
+
+ # The preview button should only be enabled if the query is valid and
+ # can be executed (even if it returns 0 results).
+ is_executable = statement._validation_status in ("OK", "0_RESULTS")
+ self.btn_toggle_preview.setEnabled(is_executable)
+
+ def _update_table_row_status(self, row_idx, statement: ReportStatement):
+ """Updates the status icon/tooltip and other data in the QTableWidget for a given row."""
+ if row_idx < 0 or row_idx >= self.table_statements.rowCount():
+ return
+
+ # Correct Column Mapping:
+ # 0: Description
+ # 1: Pipe
+ # 2: Header
+ # 3: Cols
+ # 4: Status
+
+ # Update all cells in the row to be in sync with the statement object.
+ # This is safer than assuming which property might have changed.
+ self.table_statements.item(row_idx, 0).setText(statement.description)
+ self.table_statements.item(row_idx, 1).setCheckState(
+ QtCore.Qt.Checked if statement.is_pipelined else QtCore.Qt.Unchecked
+ )
+ self.table_statements.item(row_idx, 2).setCheckState(
+ QtCore.Qt.Checked if statement.use_description_as_header else QtCore.Qt.Unchecked
+ )
+ self.table_statements.item(row_idx, 3).setCheckState(
+ QtCore.Qt.Checked if statement.include_column_names else QtCore.Qt.Unchecked
+ )
+
+ status_item = self.table_statements.item(row_idx, 4)
+ if status_item:
+ status_icon, status_tooltip = self._get_status_icon_and_tooltip(statement)
+ status_item.setIcon(status_icon)
+ status_item.setToolTip(status_tooltip)
+ # Update the text as well
+ if statement._validation_status in ("OK", "0_RESULTS"):
+ status_item.setText(str(statement._validation_count))
+ else:
+ status_item.setText("") # Clear the text for error/incomplete states
+
+ def _get_status_icon_and_tooltip(self, statement: ReportStatement):
+ # Helper to get appropriate icon and tooltip for table status column
+ status = statement._validation_status
+ message = statement._validation_message
+
+ if status == "OK":
+ return ICON_STATUS_OK, message
+ elif status == "0_RESULTS":
+ return ICON_STATUS_WARN, message
+ elif status == "ERROR":
+ return ICON_STATUS_ERROR, message
+ elif status == "INCOMPLETE":
+ return ICON_STATUS_INCOMPLETE, translate("Arch", "Query incomplete or typing...")
+ return QtGui.QIcon(), translate("Arch", "Ready") # Default/initial state
+
+ def _set_dirty(self, dirty_state):
+ """Updates the UI to show if there are uncommitted changes."""
+ if self.is_dirty == dirty_state:
+ return
+ self.is_dirty = dirty_state
+ title = translate("Arch", "Report Statements")
+ if self.is_dirty:
+ title += " *"
+ self.overview_widget.setWindowTitle(title)
+
+ def _show_cheatsheet_dialog(self):
+ """Gets the API documentation and displays it in a dialog."""
+ api_data = ArchSql.getSqlApiDocumentation()
+ dialog = CheatsheetDialog(api_data, parent=self.editor_widget)
+ dialog.exec_()
+
+ def _build_completion_model(self):
+ """
+ Builds the master list of words for the autocompleter.
+
+ This method gets raw data from the SQL engine and then applies all
+ UI-specific formatting, such as combining keywords into phrases and
+ adding trailing spaces for a better user experience.
+ """
+ # 1. Get the set of keywords that should NOT get a trailing space.
+ no_space_keywords = ArchSql.getSqlKeywords(kind="no_space")
+
+ # 2. Get the raw list of all individual keywords.
+ raw_keywords = set(ArchSql.getSqlKeywords())
+
+ # 3. Define UI-specific phrases and their components.
+ smart_clauses = {"GROUP BY ": ("GROUP", "BY"), "ORDER BY ": ("ORDER", "BY")}
+
+ # 4. Build the final set of completion words.
+ all_words = set()
+
+ # Add the smart phrases directly.
+ all_words.update(smart_clauses.keys())
+
+ # Get the individual components of the smart phrases to avoid adding them twice.
+ words_to_skip = {word for components in smart_clauses.values() for word in components}
+
+ for word in raw_keywords:
+ if word in words_to_skip:
+ continue
+
+ if word in no_space_keywords:
+ all_words.add(word) # Add without a space
+ else:
+ all_words.add(word + " ") # Add with a space by default
+
+ # 5. Add all unique property names from the document (without spaces).
+ if FreeCAD.ActiveDocument:
+ property_names = set()
+ for obj in FreeCAD.ActiveDocument.Objects:
+ for prop_name in obj.PropertiesList:
+ if prop_name not in self.PROPERTY_BLOCKLIST:
+ property_names.add(prop_name)
+ all_words.update(property_names)
+
+ # 6. Return a sorted model for the completer.
+ return QtCore.QStringListModel(sorted(list(all_words)))
+
+ def _update_ui_for_mode(self, mode):
+ """Centralizes enabling/disabling of UI controls based on the current mode."""
+ if mode == "editing":
+ # In edit mode, disable overview actions to prevent conflicts
+ self.btn_add_statement.setEnabled(False)
+ self.btn_remove_statement.setEnabled(False)
+ self.btn_duplicate_statement.setEnabled(False)
+ self.btn_edit_selected.setEnabled(False)
+ self.template_dropdown.setEnabled(False)
+ self.btn_save_template.setEnabled(False)
+ self.table_statements.setEnabled(False)
+ else: # "overview" mode
+ # In overview mode, re-enable controls
+ self.btn_add_statement.setEnabled(True)
+ self.btn_remove_statement.setEnabled(True)
+ self.btn_duplicate_statement.setEnabled(True)
+ self.template_dropdown.setEnabled(True)
+ self.btn_save_template.setEnabled(True)
+ self.table_statements.setEnabled(True)
+ # The "Edit" button state depends on whether a row is selected
+ self._on_table_selection_changed()
+
+ def _on_table_selection_changed(self):
+ """Slot for selection changes in the overview table."""
+ # This method's only job is to enable the "Edit" button if a row is selected.
+ has_selection = bool(self.table_statements.selectionModel().selectedRows())
+ self.btn_edit_selected.setEnabled(has_selection)
+
+ def _start_edit_session(self, row_index=None):
+ """Loads a statement into the editor and displays it."""
+ if row_index is None:
+ selected_rows = self.table_statements.selectionModel().selectedRows()
+ if not selected_rows:
+ return
+ row_index = selected_rows[0].row()
+
+ # Explicitly hide the preview pane and reset the toggle when starting a new session.
+ self.preview_pane.setVisible(False)
+ self.btn_toggle_preview.setChecked(False)
+
+ self.current_edited_statement_index = row_index
+ statement = self.obj.Proxy.live_statements[row_index]
+
+ # Load data into the editor
+ self._load_statement_to_editor(statement)
+
+ # Show editor and set focus
+ self.editor_widget.setVisible(True)
+ self.sql_query_edit.setFocus()
+ self._update_ui_for_mode("editing")
+
+ # Initially disable the preview button until the first validation confirms
+ # that the query is executable.
+ self.btn_toggle_preview.setEnabled(False)
+
+ def _end_edit_session(self):
+ """Hides the editor and restores the overview state."""
+ self.editor_widget.setVisible(False)
+ self.preview_pane.setVisible(False) # Also hide preview if it was open
+ self.btn_toggle_preview.setChecked(False) # Ensure toggle is reset
+ self.current_edited_statement_index = -1
+ self._update_ui_for_mode("overview")
+ self.table_statements.setFocus()
+
+ def _commit_changes(self):
+ """Saves the data from the editor back to the live statement object."""
+ if self.current_edited_statement_index == -1:
+ return
+
+ statement = self.obj.Proxy.live_statements[self.current_edited_statement_index]
+ statement.description = self.description_edit.text()
+ statement.query_string = self.sql_query_edit.toPlainText()
+ statement.is_pipelined = self.chk_is_pipelined.isChecked()
+ statement.use_description_as_header = self.chk_use_description_as_header.isChecked()
+ statement.include_column_names = self.chk_include_column_names.isChecked()
+ statement.add_empty_row_after = self.chk_add_empty_row_after.isChecked()
+ statement.print_results_in_bold = self.chk_print_results_in_bold.isChecked()
+
+ statement.validate_and_update_status()
+ self._update_table_row_status(self.current_edited_statement_index, statement)
+ self._set_dirty(True)
+
+ def on_save_clicked(self):
+ """Saves changes and either closes the editor or adds a new statement."""
+ # First, always commit the changes from the current edit session.
+ self._commit_changes()
+
+ if self.chk_save_and_next.isChecked():
+ # If the checkbox is checked, the "Next" action is to add a new
+ # blank statement. The _add_statement helper already handles
+ # creating the statement and opening it in the editor.
+ self._add_statement(start_editing=True)
+ else:
+ # The default action is to simply close the editor.
+ self._end_edit_session()
+
+ def on_discard_clicked(self):
+ """Discards changes and closes the editor."""
+ self._end_edit_session()
+
+ @Slot(bool)
+ def _on_preview_toggled(self, checked):
+ """Shows or hides the preview pane and updates the toggle button's appearance."""
+ if checked:
+ self.btn_toggle_preview.setText(translate("Arch", "Hide Preview"))
+ self.btn_toggle_preview.setIcon(self.icon_hide_preview)
+ self.preview_pane.setVisible(True)
+ self.btn_refresh_preview.setVisible(True)
+ self._run_and_display_preview()
+ else:
+ self.btn_toggle_preview.setText(translate("Arch", "Show Preview"))
+ self.btn_toggle_preview.setIcon(self.icon_show_preview)
+ self.preview_pane.setVisible(False)
+ self.btn_refresh_preview.setVisible(False)
+
+ def _run_and_display_preview(self):
+ """Executes the query in the editor and populates the preview table, respecting the pipeline context."""
+ query = self.sql_query_edit.toPlainText().strip()
+ is_pipelined = self.chk_is_pipelined.isChecked()
+
+ if not self.preview_pane.isVisible():
+ return
+ if not query:
+ self.table_preview_results.clear()
+ self.table_preview_results.setRowCount(0)
+ self.table_preview_results.setColumnCount(0)
+ return
+
+ source_objects = None
+ if is_pipelined and self.current_edited_statement_index > 0:
+ preceding_statements = self.obj.Proxy.live_statements[
+ : self.current_edited_statement_index
+ ]
+ source_objects = ArchSql._execute_pipeline_for_objects(preceding_statements)
+
+ try:
+ # Run the preview with the correct context.
+ headers, data_rows, _ = ArchSql._run_query(
+ query, mode="full_data", source_objects=source_objects
+ )
+
+ self.table_preview_results.clear()
+ self.table_preview_results.setColumnCount(len(headers))
+ self.table_preview_results.setHorizontalHeaderLabels(headers)
+ self.table_preview_results.setRowCount(len(data_rows))
+
+ for row_idx, row_data in enumerate(data_rows):
+ for col_idx, cell_value in enumerate(row_data):
+ item = QtWidgets.QTableWidgetItem(str(cell_value))
+ self.table_preview_results.setItem(row_idx, col_idx, item)
+ self.table_preview_results.horizontalHeader().setSectionResizeMode(
+ QtWidgets.QHeaderView.Interactive
+ )
+
+ except (ArchSql.SqlEngineError, ArchSql.BimSqlSyntaxError) as e:
+ # Error handling remains the same
+ self.table_preview_results.clear()
+ self.table_preview_results.setRowCount(1)
+ self.table_preview_results.setColumnCount(1)
+ self.table_preview_results.setHorizontalHeaderLabels(["Query Error"])
+ error_item = QtWidgets.QTableWidgetItem(f"❌ {str(e)}")
+ error_item.setForeground(QtGui.QColor("red"))
+ self.table_preview_results.setItem(0, 0, error_item)
+ self.table_preview_results.horizontalHeader().setSectionResizeMode(
+ 0, QtWidgets.QHeaderView.Stretch
+ )
+
+ # --- Dialog Acceptance / Rejection ---
+
+ def accept(self):
+ """Saves changes from UI to Report object and triggers recompute."""
+ # First, check if there is an active, unsaved edit session.
+ if self.current_edited_statement_index != -1:
+ reply = QtWidgets.QMessageBox.question(
+ None,
+ translate("Arch", "Unsaved Changes"),
+ translate(
+ "Arch",
+ "You have unsaved changes in the statement editor. Do you want to save them before closing?",
+ ),
+ QtWidgets.QMessageBox.Save
+ | QtWidgets.QMessageBox.Discard
+ | QtWidgets.QMessageBox.Cancel,
+ QtWidgets.QMessageBox.Save,
+ )
+
+ if reply == QtWidgets.QMessageBox.Save:
+ self._commit_changes()
+ elif reply == QtWidgets.QMessageBox.Cancel:
+ return # Abort the close operation entirely.
+ # If Discard, do nothing and proceed with closing.
+
+ # This is the "commit" step: persist the live statements to the document object.
+ self.obj.Proxy.commit_statements()
+
+ # Trigger a recompute to run the report and mark the document as modified.
+ # This will now run the final, correct pipeline.
+ FreeCAD.ActiveDocument.recompute()
+
+ # Quality of life: open the target spreadsheet to show the results.
+ spreadsheet = self.obj.Target
+ if spreadsheet:
+ FreeCADGui.ActiveDocument.setEdit(spreadsheet.Name, 0)
+
+ # Close the task panel.
+ try:
+ FreeCADGui.Control.closeDialog()
+ except Exception as e:
+ FreeCAD.Console.PrintLog(f"Could not close Report Task Panel: {e}\n")
+ self._set_dirty(False)
+
+ def reject(self):
+ """Closes dialog without saving changes to the Report object."""
+ # Revert changes by not writing to self.obj.Statements
+ # Discard live changes by re-hydrating from the persisted property
+ self.obj.Proxy.hydrate_live_statements(self.obj)
+ self._set_dirty(False)
+ # Close the task panel when GUI is available
+ try:
+ FreeCADGui.Control.closeDialog()
+ except Exception as e:
+ # This is a defensive catch. If closing the dialog fails for any reason
+ # (e.g., it was already closed), we log the error but do not crash.
+ FreeCAD.Console.PrintLog(f"Could not close Report Task Panel: {e}\n")
+
+
+if FreeCAD.GuiUp:
+ from PySide.QtGui import QDesktopServices
+ from PySide.QtCore import QUrl
+
+ class ManagePresetsDialog(QtWidgets.QDialog):
+ """A dialog for managing user-created presets (rename, delete, edit source)."""
+
+ def __init__(self, mode, parent=None):
+ super().__init__(parent)
+ self.mode = mode # 'query' or 'report'
+ self.setWindowTitle(translate("Arch", f"Manage {mode.capitalize()} Presets"))
+ self.setMinimumSize(500, 400)
+
+ # --- UI Layout ---
+ self.layout = QtWidgets.QVBoxLayout(self)
+
+ self.preset_list = QtWidgets.QListWidget()
+ self.layout.addWidget(self.preset_list)
+
+ self.buttons_layout = QtWidgets.QHBoxLayout()
+ self.btn_rename = QtWidgets.QPushButton(translate("Arch", "Rename..."))
+ self.btn_delete = QtWidgets.QPushButton(translate("Arch", "Delete"))
+ self.btn_edit_source = QtWidgets.QPushButton(translate("Arch", "Edit Source..."))
+ self.btn_close = QtWidgets.QPushButton(translate("Arch", "Close"))
+
+ self.buttons_layout.addWidget(self.btn_rename)
+ self.buttons_layout.addWidget(self.btn_delete)
+ self.buttons_layout.addStretch()
+ self.buttons_layout.addWidget(self.btn_edit_source)
+ self.layout.addLayout(self.buttons_layout)
+ self.layout.addWidget(self.btn_close)
+
+ # --- Connections ---
+ self.btn_close.clicked.connect(self.accept)
+ self.preset_list.itemSelectionChanged.connect(self._on_selection_changed)
+ self.btn_rename.clicked.connect(self._on_rename)
+ self.btn_delete.clicked.connect(self._on_delete)
+ self.btn_edit_source.clicked.connect(self._on_edit_source)
+
+ # --- Initial State ---
+ self._populate_list()
+ self._on_selection_changed() # Set initial button states
+
+ def _populate_list(self):
+ """Fills the list widget with system and user presets."""
+ self.preset_list.clear()
+ self.presets = _get_presets(self.mode)
+
+ # Sort by display name for consistent UI order
+ sorted_presets = sorted(self.presets.items(), key=lambda item: item[1]["name"])
+
+ for filename, preset_data in sorted_presets:
+ item = QtWidgets.QListWidgetItem()
+ display_text = preset_data["name"]
+
+ if preset_data["is_user"]:
+ item.setText(f"{display_text} (User)")
+ else:
+ item.setText(display_text)
+ # Make system presets visually distinct and non-selectable for modification
+ item.setForeground(QtGui.QColor("gray"))
+ flags = item.flags()
+ flags &= ~QtCore.Qt.ItemIsSelectable
+ item.setFlags(flags)
+
+ # Store the stable filename as data in the item
+ item.setData(QtCore.Qt.UserRole, filename)
+ self.preset_list.addItem(item)
+
+ def _on_selection_changed(self):
+ """Enables/disables buttons based on the current selection."""
+ selected_items = self.preset_list.selectedItems()
+ is_user_preset_selected = False
+
+ if selected_items:
+ filename = selected_items[0].data(QtCore.Qt.UserRole)
+ if self.presets[filename]["is_user"]:
+ is_user_preset_selected = True
+
+ self.btn_rename.setEnabled(is_user_preset_selected)
+ self.btn_delete.setEnabled(is_user_preset_selected)
+ self.btn_edit_source.setEnabled(is_user_preset_selected)
+
+ # --- Add Tooltips for Disabled State (Refinement #2) ---
+ tooltip = translate("Arch", "This action is only available for user-created presets.")
+ self.btn_rename.setToolTip("" if is_user_preset_selected else tooltip)
+ self.btn_delete.setToolTip("" if is_user_preset_selected else tooltip)
+ self.btn_edit_source.setToolTip("" if is_user_preset_selected else tooltip)
+
+ def _on_rename(self):
+ """Handles the rename action."""
+ item = self.preset_list.selectedItems()[0]
+ filename = item.data(QtCore.Qt.UserRole)
+ current_name = self.presets[filename]["name"]
+
+ # --- Live Name Collision Check (Refinement #2) ---
+ existing_names = {p["name"] for f, p in self.presets.items() if f != filename}
+
+ new_name, ok = QtWidgets.QInputDialog.getText(
+ self,
+ translate("Arch", "Rename Preset"),
+ translate("Arch", "New name:"),
+ text=current_name,
+ )
+ if ok and new_name and new_name != current_name:
+ if new_name in existing_names:
+ QtWidgets.QMessageBox.warning(
+ self,
+ translate("Arch", "Name Conflict"),
+ translate(
+ "Arch",
+ "A preset with this name already exists. Please choose a different name.",
+ ),
+ )
+ return
+
+ _rename_preset(self.mode, filename, new_name)
+ self._populate_list() # Refresh the list
+
+ def _on_delete(self):
+ """Handles the delete action."""
+ item = self.preset_list.selectedItems()[0]
+ filename = item.data(QtCore.Qt.UserRole)
+ name = self.presets[filename]["name"]
+
+ reply = QtWidgets.QMessageBox.question(
+ self,
+ translate("Arch", "Delete Preset"),
+ translate(
+ "Arch", f"Are you sure you want to permanently delete the preset '{name}'?"
+ ),
+ QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
+ QtWidgets.QMessageBox.No,
+ )
+
+ if reply == QtWidgets.QMessageBox.Yes:
+ _delete_preset(self.mode, filename)
+ self._populate_list()
+
+ def _on_edit_source(self):
+ """Opens the preset's JSON file in an external editor."""
+ item = self.preset_list.selectedItems()[0]
+ filename = item.data(QtCore.Qt.UserRole)
+ _, user_path = _get_preset_paths(self.mode)
+ file_path = os.path.join(user_path, filename)
+
+ if not os.path.exists(file_path):
+ QtWidgets.QMessageBox.critical(
+ self,
+ translate("Arch", "File Not Found"),
+ translate("Arch", f"Could not find the preset file at:\n{file_path}"),
+ )
+ return
+
+ # --- Use QDesktopServices for robust, cross-platform opening (Refinement #3) ---
+ url = QUrl.fromLocalFile(file_path)
+ if not QDesktopServices.openUrl(url):
+ QtWidgets.QMessageBox.warning(
+ self,
+ translate("Arch", "Could Not Open File"),
+ translate(
+ "Arch",
+ "FreeCAD could not open the file. Please check if you have a default text editor configured in your operating system.",
+ ),
+ )
+
+ class NoScrollHijackComboBox(QtWidgets.QComboBox):
+ """
+ A custom QComboBox that only processes wheel events when its popup view is visible.
+ This prevents it from "hijacking" the scroll wheel from a parent QScrollArea.
+ """
+
+ def wheelEvent(self, event):
+ if self.view().isVisible():
+ # If the widget has focus, perform the default scrolling action.
+ super().wheelEvent(event)
+ else:
+ # If the popup is not visible, ignore the event. This allows
+ # the event to propagate to the parent widget (the scroll area).
+ event.ignore()
+
+ class SqlSyntaxHighlighter(QtGui.QSyntaxHighlighter):
+ """
+ Custom QSyntaxHighlighter for SQL syntax.
+ """
+
+ def __init__(self, parent_text_document):
+ super().__init__(parent_text_document)
+
+ # --- Define Formatting Rules ---
+ keyword_format = QtGui.QTextCharFormat()
+ keyword_format.setForeground(QtGui.QColor("#0070C0")) # Dark Blue
+ keyword_format.setFontWeight(QtGui.QFont.Bold)
+
+ function_format = QtGui.QTextCharFormat()
+ function_format.setForeground(QtGui.QColor("#800080")) # Purple
+ function_format.setFontItalic(True)
+
+ string_format = QtGui.QTextCharFormat()
+ string_format.setForeground(QtGui.QColor("#A31515")) # Dark Red
+
+ comment_format = QtGui.QTextCharFormat()
+ comment_format.setForeground(QtGui.QColor("#008000")) # Green
+ comment_format.setFontItalic(True)
+
+ # --- Build Rules List ---
+ self.highlighting_rules = []
+
+ if hasattr(QtCore.QRegularExpression, "PatternOption"):
+ # This is the PySide6/Qt6 structure
+ CaseInsensitiveOption = (
+ QtCore.QRegularExpression.PatternOption.CaseInsensitiveOption
+ )
+ else:
+ # This is the PySide2/Qt5 structure
+ CaseInsensitiveOption = QtCore.QRegularExpression.CaseInsensitiveOption
+
+ # Keywords (case-insensitive regex)
+ # Get the list of keywords from the SQL engine.
+ for word in ArchSql.getSqlKeywords():
+ pattern = QtCore.QRegularExpression(r"\b" + word + r"\b", CaseInsensitiveOption)
+ rule = {"pattern": pattern, "format": keyword_format}
+ self.highlighting_rules.append(rule)
+
+ # Aggregate Functions (case-insensitive regex)
+ functions = ["COUNT", "SUM", "MIN", "MAX"]
+ for word in functions:
+ pattern = QtCore.QRegularExpression(r"\b" + word + r"\b", CaseInsensitiveOption)
+ rule = {"pattern": pattern, "format": function_format}
+ self.highlighting_rules.append(rule)
+
+ # String Literals (single quotes)
+ # This regex captures everything between single quotes, allowing for escaped quotes
+ string_pattern = QtCore.QRegularExpression(r"'[^'\\]*(\\.[^'\\]*)*'")
+ self.highlighting_rules.append({"pattern": string_pattern, "format": string_format})
+ # Also support double-quoted string literals (some SQL dialects use double quotes)
+ double_string_pattern = QtCore.QRegularExpression(r'"[^"\\]*(\\.[^"\\]*)*"')
+ self.highlighting_rules.append(
+ {"pattern": double_string_pattern, "format": string_format}
+ )
+
+ # Single-line comments (starting with -- or #)
+ comment_single_line_pattern = QtCore.QRegularExpression(r"--[^\n]*|\#[^\n]*")
+ self.highlighting_rules.append(
+ {"pattern": comment_single_line_pattern, "format": comment_format}
+ )
+
+ # Multi-line comments (/* ... */) - requires special handling in highlightBlock
+ self.multi_line_comment_start_pattern = QtCore.QRegularExpression(r"/\*")
+ self.multi_line_comment_end_pattern = QtCore.QRegularExpression(r"\*/")
+ self.multi_line_comment_format = comment_format
+
+ def highlightBlock(self, text):
+ """
+ Applies highlighting rules to the given text block.
+ This method is called automatically by Qt for each visible text block.
+ """
+ # --- Part 1: Handle single-line rules ---
+ # Iterate over all the rules defined in the constructor
+ for rule in self.highlighting_rules:
+ pattern = rule["pattern"]
+ format = rule["format"]
+
+ # Get an iterator for all matches
+ iterator = pattern.globalMatch(text)
+ while iterator.hasNext():
+ match = iterator.next()
+ # Apply the format to the matched text
+ self.setFormat(match.capturedStart(), match.capturedLength(), format)
+
+ # --- Part 2: Handle multi-line comments (which span blocks) ---
+ self.setCurrentBlockState(0)
+
+ startIndex = 0
+ # Check if the previous block was an unclosed multi-line comment
+ if self.previousBlockState() != 1:
+ # It wasn't, so find the start of a new comment in the current line
+ match = self.multi_line_comment_start_pattern.match(text)
+ startIndex = match.capturedStart() if match.hasMatch() else -1
+ else:
+ # The previous block was an unclosed comment, so this block starts inside a comment
+ startIndex = 0
+
+ while startIndex >= 0:
+ # Find the end of the comment
+ end_match = self.multi_line_comment_end_pattern.match(text, startIndex)
+ commentLength = 0
+
+ if not end_match.hasMatch():
+ # The comment doesn't end in this line, so it spans the rest of the block
+ self.setCurrentBlockState(1)
+ commentLength = len(text) - startIndex
+ else:
+ # The comment ends in this line
+ commentLength = end_match.capturedEnd() - startIndex
+
+ self.setFormat(startIndex, commentLength, self.multi_line_comment_format)
+
+ # Look for the next multi-line comment in the same line
+ next_start_index = startIndex + commentLength
+ next_match = self.multi_line_comment_start_pattern.match(text, next_start_index)
+ startIndex = next_match.capturedStart() if next_match.hasMatch() else -1
+
+ class CheatsheetDialog(QtWidgets.QDialog):
+ """A simple dialog to display the HTML cheatsheet."""
+
+ def __init__(self, api_data, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle(translate("Arch", "BIM SQL Cheatsheet"))
+ self.setMinimumSize(800, 600)
+ layout = QtWidgets.QVBoxLayout(self)
+ html = self._format_as_html(api_data)
+ text_edit = QtWidgets.QTextEdit()
+ text_edit.setReadOnly(True)
+ text_edit.setHtml(html)
+ layout.addWidget(text_edit)
+ button_box = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Ok)
+ button_box.accepted.connect(self.accept)
+ layout.addWidget(button_box)
+ self.setLayout(layout)
+
+ def _format_as_html(self, api_data: dict) -> str:
+ """
+ Takes the structured data from the API and builds the final HTML string.
+ All presentation logic and translatable strings are contained here.
+ """
+ html = f"
{translate('Arch', 'BIM SQL Cheatsheet')}
"
+ html += f"
{translate('Arch', 'Clauses')}
"
+ html += f"{', '.join(sorted(api_data.get('clauses', [])))}"
+ html += f"
{translate('Arch', 'Key Functions')}
"
+ # Sort categories for a consistent display order
+ for category_name in sorted(api_data.get("functions", {}).keys()):
+ functions = api_data["functions"][category_name]
+ html += f"{category_name}:
"
+ # Sort functions within a category alphabetically
+ for func_data in sorted(functions, key=lambda x: x["name"]):
+ # Add a bottom margin to the list item for clear visual separation.
+ html += f"
{func_data['signature']} {func_data['description']}" # Add the example snippet if it exists
+ if func_data.get("snippet"):
+ snippet_html = func_data["snippet"].replace("\n", " ")
+ # No before the snippet. Added styling to make the snippet stand out.
+ html += f"
{snippet_html}
"
+ else:
+ html += ""
+ html += "
"
+ return html
+
+else:
+ # In headless mode, we don't need the GUI classes.
+ pass
diff --git a/src/Mod/BIM/ArchSql.py b/src/Mod/BIM/ArchSql.py
new file mode 100644
index 0000000000..fd9978a243
--- /dev/null
+++ b/src/Mod/BIM/ArchSql.py
@@ -0,0 +1,2630 @@
+# SPDX-License-Identifier: MIT
+#
+# Copyright (c) 2019 Daniel Furtlehner (furti)
+# Copyright (c) 2025 The FreeCAD Project
+#
+# This file is a derivative work of the sql_parser.py file from the
+# FreeCAD-Reporting workbench (https://github.com/furti/FreeCAD-Reporting).
+# As per the terms of the original MIT license, this derivative work is also
+# licensed under the MIT license.
+
+"""Contains the SQL parsing and execution engine for BIM/Arch objects."""
+
+import FreeCAD
+import re
+from collections import deque
+
+if FreeCAD.GuiUp:
+ # In GUI mode, import PySide and create real translation functions.
+ from PySide import QtCore
+
+ def translate(context, text, comment=None):
+ """Wraps the Qt translation function."""
+ return QtCore.QCoreApplication.translate(context, text, comment)
+
+ # QT_TRANSLATE_NOOP is used to mark strings for the translation tool but
+ # does not perform the translation at definition time.
+ QT_TRANSLATE_NOOP = QtCore.QT_TRANSLATE_NOOP
+else:
+ # In headless mode, create dummy (no-op) functions that simply
+ # return the original text. This ensures the code runs without a GUI.
+ def translate(context, text, comment=None):
+ return text
+
+ def QT_TRANSLATE_NOOP(context, text):
+ return text
+
+
+# Import exception types from the generated parser for type-safe handling.
+from generated_sql_parser import UnexpectedEOF, UnexpectedToken, VisitError
+import generated_sql_parser
+
+from typing import List, Tuple, Any, Optional
+
+__all__ = [
+ "select",
+ "count",
+ "selectObjects",
+ "selectObjectsFromPipeline",
+ "getSqlKeywords",
+ "getSqlApiDocumentation",
+ "BimSqlSyntaxError",
+ "SqlEngineError",
+ "ReportStatement",
+]
+
+# --- Custom Exceptions for the SQL Engine ---
+
+
+class SqlEngineError(Exception):
+ """Base class for all custom exceptions in this module."""
+
+ pass
+
+
+class BimSqlSyntaxError(SqlEngineError):
+ """Raised for any parsing or syntax error."""
+
+ def __init__(self, message, is_incomplete=False):
+ super().__init__(message)
+ self.is_incomplete = is_incomplete
+
+
+# --- Debug Helpers for the SQL Engine ---
+
+
+def debug_transformer_method(func):
+ """A decorator to add verbose logging to transformer methods for debugging."""
+
+ def wrapper(self, items):
+ print(f"\n>>> ENTERING: {func.__name__}")
+ print(f" RECEIVED: {repr(items)}")
+ result = func(self, items)
+ print(f" RETURNING: {repr(result)}")
+ print(f"<<< EXITING: {func.__name__}")
+ return result
+
+ return wrapper
+
+
+# --- Module-level Constants ---
+
+SELECT_STAR_HEADER = "Object Label"
+_CUSTOM_FRIENDLY_TOKEN_NAMES = {
+ # This dictionary provides overrides for tokens where the name is not user-friendly.
+ # Punctuation
+ "RPAR": "')'",
+ "LPAR": "'('",
+ "COMMA": "','",
+ "ASTERISK": "'*'",
+ "DOT": "'.'",
+ "SEMICOLON": "';'",
+ # Arithmetic Operators
+ "ADD": "'+'",
+ "SUB": "'-'",
+ "MUL": "'*'",
+ "DIV": "'/'",
+ # Comparison Operators (from the grammar)
+ "EQUAL": "'='",
+ "MORETHAN": "'>'",
+ "LESSTHAN": "'<'",
+ # Other non-keyword tokens
+ "CNAME": "a property or function name",
+ "STRING": "a quoted string like 'text'",
+}
+
+
+# --- Internal Helper Functions ---
+
+
+def _get_property(obj, prop_name):
+ """Gets a property from a FreeCAD object, including sub-properties."""
+ # The property name implies sub-property access (e.g., 'Placement.Base.x')
+ is_nested_property = lambda prop_name: "." in prop_name
+
+ if not is_nested_property(prop_name):
+ # Handle simple, direct properties first, which is the most common case.
+ if hasattr(obj, prop_name):
+ return getattr(obj, prop_name)
+ return None
+ else:
+ # Handle nested properties (e.g., Placement.Base.x)
+ current_obj = obj
+ parts = prop_name.split(".")
+ for part in parts:
+ if hasattr(current_obj, part):
+ current_obj = getattr(current_obj, part)
+ else:
+ return None
+ return current_obj
+
+
+def _generate_friendly_token_names(parser):
+ """Dynamically builds the friendly token name map from the Lark parser instance."""
+ friendly_names = _CUSTOM_FRIENDLY_TOKEN_NAMES.copy()
+ for term in parser.terminals:
+ # Add any keyword/terminal from the grammar that isn't already in our custom map.
+ if term.name not in friendly_names:
+ # By default, the friendly name is the keyword itself in single quotes.
+ friendly_names[term.name] = f"'{term.name}'"
+ return friendly_names
+
+
+def _map_results_to_objects(headers, data_rows):
+ """
+ Maps the raw data rows from a query result back to FreeCAD DocumentObjects.
+
+ It uses a 'Name' or 'Label' column in the results to perform the lookup.
+
+ Parameters
+ ----------
+ headers : list of str
+ The list of column headers from the query result.
+ data_rows : list of list
+ The list of data rows from the query result.
+
+ Returns
+ -------
+ list of FreeCAD.DocumentObject
+ A list of unique FreeCAD DocumentObject instances that correspond to the
+ query results. Returns an empty list if no identifiable column is
+ found or if no objects match.
+ """
+ if not data_rows:
+ return []
+
+ # Build a lookup map for fast access to objects by their unique Name.
+ objects_by_name = {o.Name: o for o in FreeCAD.ActiveDocument.Objects}
+ objects_by_label = {} # Lazy-loaded if needed
+
+ # Find the index of the column that contains the object identifier.
+ # Prefer 'Name' as it is guaranteed to be unique. Fallback to 'Label'.
+ if "Name" in headers:
+ id_idx = headers.index("Name")
+ lookup_dict = objects_by_name
+ elif "Label" in headers:
+ id_idx = headers.index("Label")
+ objects_by_label = {o.Label: o for o in FreeCAD.ActiveDocument.Objects}
+ lookup_dict = objects_by_label
+ elif SELECT_STAR_HEADER in headers: # Handle 'SELECT *' case
+ id_idx = headers.index(SELECT_STAR_HEADER)
+ objects_by_label = {o.Label: o for o in FreeCAD.ActiveDocument.Objects}
+ lookup_dict = objects_by_label
+ else:
+ # If no identifiable column, we cannot map back to objects.
+ # This can happen for queries like "SELECT 1 + 1".
+ return []
+
+ # Map the identifiers from the query results back to the actual objects.
+ found_objects = []
+ for row in data_rows:
+ identifier = row[id_idx]
+ obj = lookup_dict.get(identifier)
+ if obj and obj not in found_objects: # Avoid duplicates
+ found_objects.append(obj)
+
+ return found_objects
+
+
+def _is_generic_group(obj):
+ """
+ Checks if an object is a generic group that should be excluded from
+ architectural query results.
+ """
+ # A generic group is a group that is not an architecturally significant one
+ # like a BuildingPart (which covers Floors, Buildings, etc.).
+ return obj.isDerivedFrom("App::DocumentObjectGroup")
+
+
+def _get_bim_type(obj):
+ """
+ Gets the most architecturally significant type for a FreeCAD object.
+
+ This is a specialized utility for BIM reporting. It prioritizes explicit
+ BIM properties (.IfcType) to correctly distinguish between different Arch
+ objects that may share the same proxy (e.g., Doors and Windows).
+
+ Parameters
+ ----------
+ obj : App::DocumentObject
+ The object to inspect.
+
+ Returns
+ -------
+ str
+ The determined type string (e.g., 'Door', 'Building Storey', 'Wall').
+ """
+ if not obj:
+ return None
+
+ # 1. Prioritize the explicit IfcType for architectural objects.
+ # This correctly handles Door vs. Window and returns the raw value.
+ if hasattr(obj, "IfcType"):
+ if obj.IfcType and obj.IfcType != "Undefined":
+ return obj.IfcType
+
+ # 2. Check for legacy .Class property from old IFC imports.
+ if hasattr(obj, "Class") and "Ifc" in str(obj.Class):
+ return obj.Class
+
+ # 3. Fallback to Proxy.Type for other scripted objects.
+ if hasattr(obj, "Proxy") and hasattr(obj.Proxy, "Type"):
+ return obj.Proxy.Type
+
+ # 4. Final fallback to the object's internal TypeId.
+ if hasattr(obj, "TypeId"):
+ return obj.TypeId
+
+ return "Unknown"
+
+
+def _is_bim_group(obj):
+ """
+ Checks if an object is a group-like container in a BIM context.
+
+ Parameters
+ ----------
+ obj : App::DocumentObject
+ The object to check.
+
+ Returns
+ -------
+ bool
+ True if the object is considered a BIM group.
+ """
+ bim_type = _get_bim_type(obj)
+ # Note: 'Floor' and 'Building' are obsolete but kept for compatibility.
+ return (
+ obj.isDerivedFrom("App::DocumentObjectGroup") and bim_type != "LayerContainer"
+ ) or bim_type in (
+ "Project",
+ "Site",
+ "Building",
+ "Building Storey",
+ "Floor",
+ "Building Element Part",
+ "Space",
+ )
+
+
+def _get_direct_children(obj, discover_hosted_elements, include_components_from_additions):
+ """
+ Finds the immediate descendants of a single object.
+
+ Encapsulates the different ways an object can be a "child" in FreeCAD's BIM context, checking
+ for hierarchical containment (.Group), architectural hosting (.Hosts/.Host), and geometric
+ composition (.Additions).
+
+ Parameters
+ ----------
+ obj : App::DocumentObject
+ The parent object to find the children of.
+
+ discover_hosted_elements : bool
+ If True, the function will perform checks to find objects that are architecturally hosted by
+ `obj` (e.g., a Window in a Wall).
+
+ include_components_from_additions : bool
+ If True, the function will include objects found in the `obj.Additions` list, which are
+ typically used for geometric composition.
+
+ Returns
+ -------
+ list of App::DocumentObject
+ A list of the direct child objects of `obj`.
+ """
+ children = []
+
+ # 1. Hierarchical children from .Group (containment)
+ if _is_bim_group(obj) and hasattr(obj, "Group") and obj.Group:
+ children.extend(obj.Group)
+
+ # 2. Architecturally-hosted elements
+ if discover_hosted_elements:
+ host_types = ["Wall", "Structure", "CurtainWall", "Precast", "Panel", "Roof"]
+ if _get_bim_type(obj) in host_types:
+ for item_in_inlist in obj.InList:
+ element_to_check = item_in_inlist
+ if hasattr(item_in_inlist, "getLinkedObject"):
+ linked = item_in_inlist.getLinkedObject()
+ if linked:
+ element_to_check = linked
+
+ element_type = _get_bim_type(element_to_check)
+ is_confirmed_hosted = False
+ if element_type == "Window":
+ if hasattr(element_to_check, "Hosts") and obj in element_to_check.Hosts:
+ is_confirmed_hosted = True
+ elif element_type == "Rebar":
+ if hasattr(element_to_check, "Host") and obj == element_to_check.Host:
+ is_confirmed_hosted = True
+
+ if is_confirmed_hosted:
+ children.append(element_to_check)
+
+ # 3. Geometric components from .Additions list
+ if include_components_from_additions and hasattr(obj, "Additions") and obj.Additions:
+ for addition_comp in obj.Additions:
+ actual_addition = addition_comp
+ if hasattr(addition_comp, "getLinkedObject"):
+ linked_add = addition_comp.getLinkedObject()
+ if linked_add:
+ actual_addition = linked_add
+ children.append(actual_addition)
+
+ return children
+
+
+# TODO: Refactor architectural traversal logic.
+# This function is a temporary, enhanced copy of the traversal algorithm
+# found in ArchCommands.get_architectural_contents. It was duplicated here
+# to avoid creating a circular dependency and to keep the BIM Report PR
+# self-contained.
+#
+# A future refactoring task should:
+# 1. Move this enhanced implementation into a new, low-level core utility
+# module (e.g., ArchCoreUtils.py).
+# 2. Add a comprehensive unit test suite for this new core function.
+# 3. Refactor this implementation and the original get_architectural_contents
+# to be simple wrappers around the new, centralized core function.
+# This will remove the code duplication and improve the overall architecture.
+def _traverse_architectural_hierarchy(
+ initial_objects,
+ max_depth=0,
+ discover_hosted_elements=True,
+ include_components_from_additions=False,
+ include_groups_in_result=True,
+ include_initial_objects_in_result=True,
+):
+ """
+ Traverses the BIM hierarchy to find all descendants of a given set of objects.
+
+ This function implements a Breadth-First Search (BFS) algorithm using a
+ queue to safely and efficiently traverse the model. It is the core engine
+ used by the CHILDREN and CHILDREN_RECURSIVE SQL functions.
+
+ Parameters
+ ----------
+ initial_objects : list of App::DocumentObject
+ The starting object(s) for the traversal.
+
+ max_depth : int, optional
+ The maximum number of architecturally significant levels to traverse.
+ A value of 0 (default) means the traversal is unlimited. A value of 1
+ will find direct children only. Generic organizational groups do not
+ count towards the depth limit.
+
+ discover_hosted_elements : bool, optional
+ If True (default), the traversal will find objects that are
+ architecturally hosted (e.g., Windows in Walls).
+
+ include_components_from_additions : bool, optional
+ If True, the traversal will include objects from `.Additions` lists.
+ Defaults to False, as these are typically geometric components, not
+ separate architectural elements.
+
+ include_groups_in_result : bool, optional
+ If True (default), generic organizational groups (App::DocumentObjectGroup)
+ will be included in the final output. If False, they are traversed
+ transparently but excluded from the results.
+
+ include_initial_objects_in_result : bool, optional
+ If True (default), the objects in `initial_objects` will themselves
+ be included in the returned list.
+
+ Returns
+ -------
+ list of App::DocumentObject
+ A flat, unique list of all discovered descendant objects.
+ """
+ final_contents_list = []
+ queue = deque()
+ processed_or_queued_names = set()
+
+ if not isinstance(initial_objects, list):
+ initial_objects_list = [initial_objects]
+ else:
+ initial_objects_list = list(initial_objects)
+
+ for obj in initial_objects_list:
+ queue.append((obj, 0))
+ processed_or_queued_names.add(obj.Name)
+
+ while queue:
+ obj, current_depth = queue.popleft()
+
+ is_initial = obj in initial_objects_list
+ if (is_initial and include_initial_objects_in_result) or not is_initial:
+ if obj not in final_contents_list:
+ final_contents_list.append(obj)
+
+ if max_depth != 0 and current_depth >= max_depth:
+ continue
+
+ direct_children = _get_direct_children(
+ obj, discover_hosted_elements, include_components_from_additions
+ )
+
+ for child in direct_children:
+ if child.Name not in processed_or_queued_names:
+ if _is_generic_group(child):
+ next_depth = current_depth
+ else:
+ next_depth = current_depth + 1
+
+ queue.append((child, next_depth))
+ processed_or_queued_names.add(child.Name)
+
+ if not include_groups_in_result:
+ filtered_list = [obj for obj in final_contents_list if not _is_generic_group(obj)]
+ return filtered_list
+
+ return final_contents_list
+
+
+def _execute_pipeline_for_objects(statements: List["ReportStatement"]) -> List:
+ """
+ Internal helper to run a pipeline and get the final list of objects.
+
+ Unlike the main generator, this function consumes the entire pipeline and
+ returns only the final list of resulting objects, for use in validation.
+ """
+ pipeline_input_objects = None
+
+ for i, statement in enumerate(statements):
+ if not statement.query_string or not statement.query_string.strip():
+ pipeline_input_objects = [] if statement.is_pipelined else None
+ continue
+
+ source = pipeline_input_objects if statement.is_pipelined else None
+
+ try:
+ _, _, resulting_objects = _run_query(
+ statement.query_string, mode="full_data", source_objects=source
+ )
+ pipeline_input_objects = resulting_objects
+ except (SqlEngineError, BimSqlSyntaxError):
+ # If any step fails, the final output is an empty list of objects.
+ return []
+
+ return pipeline_input_objects or []
+
+
+# --- Logical Classes for the SQL Statement Object Model ---
+
+
+class FunctionRegistry:
+ """A simple class to manage the registration of SQL functions."""
+
+ def __init__(self):
+ self._functions = {}
+
+ def register(self, name, function_class, category, signature, description, snippet=""):
+ """Registers a class to handle a function with the given name."""
+ self._functions[name.upper()] = {
+ "class": function_class,
+ "category": category,
+ "signature": signature,
+ "description": description,
+ "snippet": snippet,
+ }
+
+ def get_class(self, name):
+ """Retrieves the class registered for a given function name."""
+ data = self._functions.get(name.upper())
+ return data["class"] if data else None
+
+
+# Create global, module-level registries that will be populated by decorators.
+select_function_registry = FunctionRegistry()
+from_function_registry = FunctionRegistry()
+
+
+def register_select_function(name, category, signature, description, snippet=""):
+ """
+ A decorator that registers a class as a selectable SQL function.
+ The decorated class must be a subclass of FunctionBase or similar.
+ """
+
+ def wrapper(cls):
+ select_function_registry.register(name, cls, category, signature, description, snippet)
+ return cls
+
+ return wrapper
+
+
+def register_from_function(name, category, signature, description, snippet=""):
+ """
+ A decorator that registers a class as a FROM clause SQL function.
+ The decorated class must be a subclass of FromFunctionBase.
+ """
+
+ def wrapper(cls):
+ from_function_registry.register(name, cls, category, signature, description, snippet)
+ return cls
+
+ return wrapper
+
+
+class AggregateFunction:
+ """Represents an aggregate function call like COUNT(*) or SUM(Height)."""
+
+ def __init__(self, name, arg_extractors):
+ self.function_name = name.lower()
+ self.arg_extractors = arg_extractors
+
+ if len(self.arg_extractors) != 1:
+ raise ValueError(
+ f"Aggregate function {self.function_name.upper()} requires exactly one argument."
+ )
+
+ self.argument = self.arg_extractors[0]
+
+ def get_value(self, obj):
+ # This method should never be called directly in a row-by-row context like a WHERE clause.
+ # Aggregates are handled in a separate path (_execute_grouped_query or
+ # the single-row path in _execute_non_grouped_query). Calling it here is a semantic error.
+ raise SqlEngineError(
+ f"Aggregate function '{self.function_name.upper()}' cannot be used in this context."
+ )
+
+
+@register_select_function(
+ name="COUNT",
+ category=QT_TRANSLATE_NOOP("ArchSql", "Aggregate"),
+ signature="COUNT(* | property)",
+ description=QT_TRANSLATE_NOOP("ArchSql", "Counts rows that match criteria."),
+ snippet="SELECT COUNT(*) FROM document WHERE IfcType = 'Space'",
+)
+class CountFunction(AggregateFunction):
+ """Implements the COUNT() aggregate function."""
+
+ pass
+
+
+@register_select_function(
+ name="SUM",
+ category=QT_TRANSLATE_NOOP("ArchSql", "Aggregate"),
+ signature="SUM(property)",
+ description=QT_TRANSLATE_NOOP("ArchSql", "Calculates the sum of a numerical property."),
+ snippet="SELECT SUM(Area) FROM document WHERE IfcType = 'Space'",
+)
+class SumFunction(AggregateFunction):
+ """Implements the SUM() aggregate function."""
+
+ pass
+
+
+@register_select_function(
+ name="MIN",
+ category=QT_TRANSLATE_NOOP("ArchSql", "Aggregate"),
+ signature="MIN(property)",
+ description=QT_TRANSLATE_NOOP("ArchSql", "Finds the minimum value of a property."),
+ snippet="SELECT MIN(Length) FROM document WHERE IfcType = 'Wall'",
+)
+class MinFunction(AggregateFunction):
+ """Implements the MIN() aggregate function."""
+
+ pass
+
+
+@register_select_function(
+ name="MAX",
+ category=QT_TRANSLATE_NOOP("ArchSql", "Aggregate"),
+ signature="MAX(property)",
+ description=QT_TRANSLATE_NOOP("ArchSql", "Finds the maximum value of a property."),
+ snippet="SELECT MAX(Height) FROM document WHERE IfcType = 'Wall'",
+)
+class MaxFunction(AggregateFunction):
+ """Implements the MAX() aggregate function."""
+
+ pass
+
+
+class FunctionBase:
+ """A base class for non-aggregate functions like TYPE, CONCAT, etc."""
+
+ def __init__(self, function_name, arg_extractors):
+ self.function_name = function_name
+ self.arg_extractors = arg_extractors
+ # The 'base' is set by the transformer during parsing of a chain.
+ self.base = None
+
+ def get_value(self, obj):
+ """
+ Calculates the function's value. This is the entry point.
+ It determines the object to operate on (from the chain, or the row object)
+ and then calls the specific implementation.
+ """
+ if self.base:
+ on_object = self.base.get_value(obj)
+ if on_object is None:
+ return None
+ else:
+ on_object = obj
+
+ return self._execute_function(on_object, obj)
+
+ def _execute_function(self, on_object, original_obj):
+ """
+ Child classes must implement this.
+ - on_object: The object the function should run on (from the chain).
+ - original_obj: The original row object, used to evaluate arguments.
+ """
+ raise NotImplementedError()
+
+
+@register_select_function(
+ name="TYPE",
+ category=QT_TRANSLATE_NOOP("ArchSql", "Utility"),
+ signature="TYPE(*)",
+ description=QT_TRANSLATE_NOOP("ArchSql", "Returns the object's BIM type (e.g., 'Wall')."),
+ snippet="SELECT Label FROM document WHERE TYPE(*) = 'Wall'",
+)
+class TypeFunction(FunctionBase):
+ """Implements the TYPE() function."""
+
+ def __init__(self, function_name, arg_extractors):
+ super().__init__(function_name, arg_extractors)
+ if len(self.arg_extractors) != 1 or self.arg_extractors[0] != "*":
+ raise ValueError(f"Function {self.function_name} requires exactly one argument: '*'")
+
+ def get_value(self, obj):
+ # The argument for TYPE is the object itself, represented by '*'.
+ return _get_bim_type(obj)
+
+
+@register_select_function(
+ name="LOWER",
+ category=QT_TRANSLATE_NOOP("ArchSql", "String"),
+ signature="LOWER(property)",
+ description=QT_TRANSLATE_NOOP("ArchSql", "Converts text to lowercase."),
+ snippet="SELECT Label FROM document WHERE LOWER(Label) = 'exterior wall'",
+)
+class LowerFunction(FunctionBase):
+ """Implements the LOWER() string function."""
+
+ def __init__(self, function_name, arg_extractors):
+ super().__init__(function_name, arg_extractors)
+ if len(self.arg_extractors) != 1:
+ raise ValueError(f"Function {self.function_name} requires exactly one argument.")
+
+ def get_value(self, obj):
+ value = self.arg_extractors[0].get_value(obj)
+ return str(value).lower() if value is not None else None
+
+
+@register_select_function(
+ name="UPPER",
+ category=QT_TRANSLATE_NOOP("ArchSql", "String"),
+ signature="UPPER(property)",
+ description=QT_TRANSLATE_NOOP("ArchSql", "Converts text to uppercase."),
+ snippet="SELECT Label FROM document WHERE UPPER(IfcType) = 'WALL'",
+)
+class UpperFunction(FunctionBase):
+ """Implements the UPPER() string function."""
+
+ def __init__(self, function_name, arg_extractors):
+ super().__init__(function_name, arg_extractors)
+ if len(self.arg_extractors) != 1:
+ raise ValueError(f"Function {self.function_name} requires exactly one argument.")
+
+ def get_value(self, obj):
+ value = self.arg_extractors[0].get_value(obj)
+ return str(value).upper() if value is not None else None
+
+
+@register_select_function(
+ name="CONCAT",
+ category=QT_TRANSLATE_NOOP("ArchSql", "String"),
+ signature="CONCAT(value1, value2, ...)",
+ description=QT_TRANSLATE_NOOP("ArchSql", "Joins multiple strings and properties together."),
+ snippet="SELECT CONCAT(Label, ': ', IfcType) FROM document",
+)
+class ConcatFunction(FunctionBase):
+ """Implements the CONCAT() string function."""
+
+ def __init__(self, function_name, arg_extractors):
+ super().__init__(function_name, arg_extractors)
+ if not self.arg_extractors:
+ raise ValueError(f"Function {self.function_name} requires at least one argument.")
+
+ def get_value(self, obj):
+ parts = [
+ str(ex.get_value(obj)) if ex.get_value(obj) is not None else ""
+ for ex in self.arg_extractors
+ ]
+ return "".join(parts)
+
+
+@register_select_function(
+ name="CONVERT",
+ category=QT_TRANSLATE_NOOP("ArchSql", "Utility"),
+ signature="CONVERT(quantity, 'unit')",
+ description=QT_TRANSLATE_NOOP(
+ "ArchSql", "Converts a Quantity to a different unit (e.g., CONVERT(Length, 'm'))."
+ ),
+ snippet="SELECT CONVERT(Length, 'm') AS LengthInMeters FROM document",
+)
+class ConvertFunction(FunctionBase):
+ """Implements the CONVERT(Quantity, 'unit') function."""
+
+ def __init__(self, function_name, arg_extractors):
+ super().__init__(function_name, arg_extractors)
+ if len(self.arg_extractors) != 2:
+ raise ValueError(
+ f"Function {self.function_name} requires exactly two arguments: a property and a unit string."
+ )
+
+ def get_value(self, obj):
+ # Evaluate the arguments to get the input value and target unit string.
+ input_value = self.arg_extractors[0].get_value(obj)
+ unit_string = self.arg_extractors[1].get_value(obj)
+
+ # The first argument must be a Quantity object to be convertible.
+ if not isinstance(input_value, FreeCAD.Units.Quantity):
+ raise SqlEngineError(
+ f"CONVERT function requires a Quantity object as the first argument, but got {type(input_value).__name__}."
+ )
+
+ try:
+ # Use the underlying API to perform the conversion.
+ result_quantity = input_value.getValueAs(str(unit_string))
+ return result_quantity.Value
+ except Exception as e:
+ # The API will raise an error for incompatible units (e.g., mm to kg).
+ raise SqlEngineError(f"Unit conversion failed: {e}")
+
+
+class FromFunctionBase:
+ """Base class for all functions used in a FROM clause."""
+
+ def __init__(self, args):
+ # args will be the SelectStatement to be executed
+ self.args = args
+
+ def get_objects(self, source_objects=None):
+ """Executes the subquery and returns the final list of objects."""
+ raise NotImplementedError()
+
+ def _get_parent_objects(self, source_objects=None):
+ """
+ Helper to execute the subquery and resolve the resulting rows back
+ into a list of FreeCAD document objects.
+ """
+ if source_objects is not None:
+ # If source_objects are provided by the pipeline, use them directly as the parents.
+ return source_objects
+
+ # Only execute the substatement if no source_objects are provided.
+ headers, rows = self.args.execute(FreeCAD.ActiveDocument.Objects)
+
+ if not rows:
+ return []
+
+ # Determine which column to use for mapping back to objects
+ label_idx = headers.index("Label") if "Label" in headers else -1
+ name_idx = headers.index("Name") if "Name" in headers else -1
+ # Handle the special header name from a 'SELECT *' query
+ if headers == [SELECT_STAR_HEADER]:
+ label_idx = 0
+
+ if label_idx == -1 and name_idx == -1:
+ raise ValueError(
+ "Subquery for FROM function must return an object identifier column: "
+ "'Name' or 'Label' (or use SELECT *)."
+ )
+
+ # Build lookup maps once for efficient searching
+ objects_by_name = {o.Name: o for o in FreeCAD.ActiveDocument.Objects}
+ objects_by_label = {o.Label: o for o in FreeCAD.ActiveDocument.Objects}
+
+ parent_objects = []
+ for row in rows:
+ parent = None
+ # Prioritize matching by unique Name first
+ if name_idx != -1:
+ parent = objects_by_name.get(row[name_idx])
+ # Fallback to user-facing Label if no match by Name
+ if not parent and label_idx != -1:
+ parent = objects_by_label.get(row[label_idx])
+
+ if parent:
+ parent_objects.append(parent)
+ return parent_objects
+
+
+@register_from_function(
+ name="CHILDREN",
+ category=QT_TRANSLATE_NOOP("ArchSql", "Hierarchical"),
+ signature="CHILDREN(subquery)",
+ description=QT_TRANSLATE_NOOP("ArchSql", "Selects direct child objects of a given parent set."),
+ snippet="SELECT * FROM CHILDREN(SELECT * FROM document WHERE Label = 'My Floor')",
+)
+class ChildrenFromFunction(FromFunctionBase):
+ """Implements the CHILDREN() function."""
+
+ def get_objects(self, source_objects=None):
+ recursive_handler = ChildrenRecursiveFromFunction(self.args)
+
+ # Get the root objects to start from.
+ subquery_statement = self.args[0]
+ parent_objects = recursive_handler._get_parent_objects_from_subquery(
+ subquery_statement, source_objects
+ )
+ if not parent_objects:
+ return []
+
+ # Call the core traversal function with a hard-coded max_depth of 1.
+ return _traverse_architectural_hierarchy(
+ initial_objects=parent_objects,
+ max_depth=1,
+ include_groups_in_result=False,
+ include_initial_objects_in_result=False, # Only return children
+ )
+
+
+@register_from_function(
+ name="CHILDREN_RECURSIVE",
+ category=QT_TRANSLATE_NOOP("ArchSql", "Hierarchical"),
+ signature="CHILDREN_RECURSIVE(subquery, max_depth=15)",
+ description=QT_TRANSLATE_NOOP(
+ "ArchSql", "Selects all descendant objects of a given set, traversing the full hierarchy."
+ ),
+ snippet="SELECT * FROM CHILDREN_RECURSIVE(SELECT * FROM document WHERE Label = 'My Building')",
+)
+class ChildrenRecursiveFromFunction(FromFunctionBase):
+ """Implements the CHILDREN_RECURSIVE() function."""
+
+ def get_objects(self, source_objects=None):
+ # The subquery is always the first argument.
+ subquery_statement = self.args[0]
+ max_depth = 15 # Default safe depth limit
+
+ # The optional max_depth is the second argument. It will be a StaticExtractor.
+ if len(self.args) > 1 and isinstance(self.args[1], StaticExtractor):
+ # We get its raw value, which should be a float from the NUMBER terminal.
+ max_depth = int(self.args[1].get_value(None))
+
+ # Get the root objects to start from.
+ # The subquery runs on the pipeline source if provided.
+ parent_objects = self._get_parent_objects_from_subquery(subquery_statement, source_objects)
+ if not parent_objects:
+ return []
+
+ # Call our fully-tested core traversal function with the correct parameters.
+ return _traverse_architectural_hierarchy(
+ initial_objects=parent_objects,
+ max_depth=max_depth,
+ include_groups_in_result=False,
+ include_initial_objects_in_result=False, # Critical: Only return children
+ )
+
+ def _get_parent_objects_from_subquery(self, substatement, source_objects=None):
+ """Helper to execute a subquery statement and return its objects."""
+ # This is a simplified version of the old _get_parent_objects method.
+ # It executes the substatement and maps the results back to objects.
+ if source_objects:
+ # If a pipeline provides the source, the subquery runs on that source.
+ headers, rows = substatement.execute(source_objects)
+ else:
+ headers, rows = substatement.execute(FreeCAD.ActiveDocument.Objects)
+
+ return _map_results_to_objects(headers, rows)
+
+
+@register_select_function(
+ name="PARENT",
+ category=QT_TRANSLATE_NOOP("ArchSql", "Hierarchical"),
+ signature="PARENT(*)",
+ description=QT_TRANSLATE_NOOP(
+ "ArchSql", "Returns the immediate, architecturally significant parent of an object."
+ ),
+ snippet="SELECT Label, PARENT(*).Label AS Floor FROM document WHERE IfcType = 'Space'",
+)
+class ParentFunction(FunctionBase):
+ """Implements the PARENT(*) function to find an object's container."""
+
+ def __init__(self, function_name, arg_extractors):
+ super().__init__(function_name, arg_extractors)
+ if len(self.arg_extractors) != 1 or self.arg_extractors[0] != "*":
+ raise ValueError(f"Function {self.function_name} requires exactly one argument: '*'")
+
+ def _execute_function(self, on_object, original_obj):
+ """
+ Walks up the document tree from the given on_object to find the first
+ architecturally significant parent, transparently skipping generic groups.
+ """
+ current_obj = on_object
+
+ # Limit search depth to 20 levels to prevent infinite loops.
+ for _ in range(20):
+ # --- Step 1: Find the immediate parent of current_obj ---
+ immediate_parent = None
+
+ # Priority 1: Check for a host (for Windows, Doors, etc.)
+ if hasattr(current_obj, "Hosts") and current_obj.Hosts:
+ immediate_parent = current_obj.Hosts[0]
+
+ # Priority 2: If no host, search InList for a true container.
+ # A true container is an object that has the current object in its .Group list.
+ elif hasattr(current_obj, "InList") and current_obj.InList:
+ for obj_in_list in current_obj.InList:
+ if hasattr(obj_in_list, "Group") and current_obj in obj_in_list.Group:
+ immediate_parent = obj_in_list
+ break
+
+ if not immediate_parent:
+ return None # No parent found, top of branch.
+
+ # Check if the found parent is a generic group to be skipped.
+ if _is_generic_group(immediate_parent):
+ # The parent is a generic group. Skip it and continue the search
+ # from this parent's level in the next loop.
+ current_obj = immediate_parent
+ else:
+ # The parent is architecturally significant. This is our answer.
+ return immediate_parent
+
+ return None # Search limit reached.
+
+
+class GroupByClause:
+ """Represents the GROUP BY clause of a SQL statement."""
+
+ def __init__(self, columns):
+ # columns is a list of ReferenceExtractor objects
+ self.columns = columns
+
+
+class OrderByClause:
+ """Represents the ORDER BY clause of a SQL statement."""
+
+ def __init__(self, column_references, direction="ASC"):
+ # Store the string names of the columns to sort by, which can be properties or aliases.
+ self.column_names = [ref.value for ref in column_references]
+ self.direction = direction.upper()
+
+ def __repr__(self):
+ # Add a __repr__ for clearer debug logs.
+ return f""
+
+
+class SelectStatement:
+ def __init__(self, columns_info, from_clause, where_clause, group_by_clause, order_by_clause):
+ self.columns_info = columns_info # Stores (extractor_object, display_name) tuples
+ self.from_clause = from_clause
+ self.where_clause = where_clause
+ self.group_by_clause = group_by_clause
+ self.order_by_clause = order_by_clause
+
+ def execute(self, all_objects):
+ # 1. Phase 1: Get filtered and grouped object data.
+ grouped_data = self._get_grouped_data(all_objects)
+ # 2. Determine the column headers from the parsed statement
+ headers = [display_name for _, display_name in self.columns_info]
+
+ # 3. Phase 2: Process the SELECT columns to get the final data rows.
+ results_data = self._process_select_columns(grouped_data, all_objects)
+
+ # 4. Perform final sorting if an ORDER BY clause was provided.
+ if self.order_by_clause:
+ # Sorting logic: it finds the index of the ORDER BY column name/alias in the final
+ # headers list and sorts the existing results_data based on that index.
+ sort_column_indices = []
+ for sort_column_name in self.order_by_clause.column_names:
+ try:
+ # Find the 0-based index of the column to sort by from the
+ # final headers. This works for properties and aliases.
+ idx = headers.index(sort_column_name)
+ sort_column_indices.append(idx)
+ except ValueError:
+ raise ValueError(
+ f"ORDER BY column '{sort_column_name}' is not in the SELECT list."
+ )
+
+ is_descending = self.order_by_clause.direction == "DESC"
+
+ # Define a sort key that can handle different data types.
+ def sort_key(row):
+ """
+ Returns a tuple of sortable keys for a given row, one for each
+ column specified in the ORDER BY clause.
+ """
+ keys = []
+ for index in sort_column_indices:
+ value = row[index]
+ # Create a consistent, comparable key for each value.
+ if value is None:
+ keys.append((0, None)) # Nones sort first.
+ elif isinstance(value, (int, float, FreeCAD.Units.Quantity)):
+ num_val = (
+ value.Value if isinstance(value, FreeCAD.Units.Quantity) else value
+ )
+ keys.append((1, num_val)) # Numbers sort second.
+ else:
+ keys.append((2, str(value))) # Everything else sorts as a string.
+ return tuple(keys)
+
+ results_data.sort(key=sort_key, reverse=is_descending)
+
+ return headers, results_data
+
+ def _get_extractor_signature(self, extractor):
+ """Generates a unique, hashable signature for any extractor object."""
+ if isinstance(extractor, ReferenceExtractor):
+ return extractor.value
+ elif isinstance(extractor, StaticExtractor):
+ return f"'{extractor.get_value(None)}'"
+ elif isinstance(extractor, FunctionBase):
+ # Recursively build a signature for functions, e.g., "LOWER(Label)"
+ arg_sigs = []
+ for arg_ex in extractor.arg_extractors:
+ if arg_ex == "*":
+ arg_sigs.append("*")
+ else:
+ # This recursive call handles nested functions correctly
+ arg_sigs.append(self._get_extractor_signature(arg_ex))
+ return f"{extractor.function_name.upper()}({', '.join(arg_sigs)})"
+ # Return a non-string type for unsupported extractors to prevent accidental matches
+ return None
+
+ def validate(self):
+ """
+ Validates the select statement against SQL rules, such as those for GROUP BY.
+ Raises ValueError on failure with a user-friendly message.
+ """
+ if self.group_by_clause:
+ # Rule: Every column in the SELECT list must either be an aggregate function,
+ # a static value, or be part of the GROUP BY clause.
+ group_by_signatures = {
+ self._get_extractor_signature(ex) for ex in self.group_by_clause.columns
+ }
+
+ for extractor, _ in self.columns_info:
+ # This check is for columns that are inherently valid (aggregates, static values).
+ # A regular function (FunctionBase) is NOT inherently valid, so it must be checked below.
+ if isinstance(extractor, (AggregateFunction, StaticExtractor)):
+ continue
+
+ if extractor == "*":
+ raise ValueError("Cannot use '*' in a SELECT statement with a GROUP BY clause.")
+
+ # This is the main check. It generates a signature for the current SELECT column
+ # (which could be a property OR a function) and ensures it exists in the GROUP BY clause.
+ select_col_signature = self._get_extractor_signature(extractor)
+ if select_col_signature not in group_by_signatures:
+ raise ValueError(
+ f"Column '{select_col_signature}' must appear in the GROUP BY clause "
+ "or be used in an aggregate function."
+ )
+ return
+
+ # Rule: If there is no GROUP BY, you cannot mix aggregate and non-aggregate columns.
+ has_aggregate = any(isinstance(ex, AggregateFunction) for ex, _ in self.columns_info)
+ # A non-aggregate is a ReferenceExtractor or a scalar function (FunctionBase).
+ # StaticExtractors are always allowed.
+ has_non_aggregate = any(
+ isinstance(ex, (ReferenceExtractor, FunctionBase)) for ex, _ in self.columns_info
+ )
+
+ if has_aggregate and has_non_aggregate:
+ raise ValueError(
+ "Cannot mix aggregate functions (like COUNT) and other columns or functions (like Label or LOWER) "
+ "without a GROUP BY clause."
+ )
+
+ def _get_grouping_key(self, obj, group_by_extractors):
+ """Generates a tuple key for an object based on the GROUP BY columns."""
+ key_parts = []
+ for extractor in group_by_extractors:
+ value = extractor.get_value(obj)
+ # We must ensure the key part is hashable. Converting to string is a
+ # safe fallback for unhashable types like lists, while preserving
+ # the original value for common hashable types (str, int, None, etc.).
+ if value is not None and not isinstance(value, (str, int, float, bool, tuple)):
+ key_parts.append(str(value))
+ else:
+ key_parts.append(value)
+ return tuple(key_parts)
+
+ def _execute_grouped_query(self, objects):
+ """Executes a query that contains a GROUP BY clause."""
+ results_data = []
+ groups = {} # A dictionary to partition objects by their group key
+
+ group_by_extractors = self.group_by_clause.columns
+
+ # 1. Partition all filtered objects into groups
+ for obj in objects:
+ key = self._get_grouping_key(obj, group_by_extractors)
+ if key not in groups:
+ groups[key] = []
+ groups[key].append(obj)
+
+ # 2. Process each group to generate one summary row
+ for key, object_list in groups.items():
+ row = []
+ for extractor, _ in self.columns_info:
+ value = None
+ if isinstance(extractor, AggregateFunction):
+ if extractor.function_name == "count":
+ # Distinguish between COUNT(*) and COUNT(property)
+ if extractor.argument == "*":
+ value = len(object_list)
+ else:
+ # Count only objects where the specified property is not None
+ prop_name = extractor.argument.value
+ count = sum(
+ 1
+ for obj in object_list
+ if _get_property(obj, prop_name) is not None
+ )
+ value = count
+ else:
+ # For other aggregates, extract the relevant property from all objects in the group
+ arg_extractor = extractor.argument
+ values = []
+ for obj in object_list:
+ prop_val = arg_extractor.get_value(obj)
+ # Ensure we only aggregate numeric, non-null values
+ if prop_val is not None:
+ # Handle FreeCAD.Quantity by using its value
+ if isinstance(prop_val, FreeCAD.Units.Quantity):
+ prop_val = prop_val.Value
+ if isinstance(prop_val, (int, float)):
+ values.append(prop_val)
+
+ if not values:
+ value = None # Return None if no valid numeric values were found
+ elif extractor.function_name == "sum":
+ value = sum(values)
+ elif extractor.function_name == "min":
+ value = min(values)
+ elif extractor.function_name == "max":
+ value = max(values)
+ else:
+ value = f"'{extractor.function_name}' NOT_IMPL"
+
+ elif isinstance(extractor, FunctionBase):
+ # For non-aggregate functions, just calculate the value based on the first object.
+ # This is consistent with how non-grouped, non-aggregate columns are handled.
+ if object_list:
+ value = extractor.get_value(object_list[0])
+
+ else:
+ # This must be a column from the GROUP BY clause. We find which part
+ # of the key corresponds to this column.
+ key_index = -1
+ if isinstance(extractor, ReferenceExtractor):
+ for i, gb_extractor in enumerate(group_by_extractors):
+ if gb_extractor.value == extractor.value:
+ key_index = i
+ break
+ if key_index != -1:
+ value = key[key_index]
+
+ row.append(value)
+ results_data.append(row)
+
+ return results_data
+
+ def _execute_non_grouped_query(self, objects):
+ """Executes a simple query without a GROUP BY clause."""
+ results_data = []
+
+ # Check if this is a query with only aggregate or non-aggregate functions
+ is_single_row_query = any(isinstance(ex, AggregateFunction) for ex, _ in self.columns_info)
+ if is_single_row_query:
+ # A query with functions but no GROUP BY always returns a single row.
+ row = []
+ for extractor, _ in self.columns_info:
+ value = None
+
+ if isinstance(extractor, StaticExtractor):
+ value = extractor.get_value(None)
+ elif isinstance(extractor, AggregateFunction):
+ if extractor.function_name == "count":
+ if extractor.argument == "*":
+ value = len(objects)
+ else:
+ # Count only objects where the specified property is not None
+ prop_name = extractor.argument.value
+ count = sum(
+ 1 for obj in objects if _get_property(obj, prop_name) is not None
+ )
+ value = count
+ else:
+ # For other aggregates, they must have a property to act on.
+ if isinstance(extractor.argument, ReferenceExtractor):
+ arg_extractor = extractor.argument
+ values = []
+ for obj in objects:
+ prop_val = arg_extractor.get_value(obj)
+ # Ensure we only aggregate numeric, non-null values
+ if prop_val is not None:
+ if isinstance(prop_val, FreeCAD.Units.Quantity):
+ prop_val = prop_val.Value
+ if isinstance(prop_val, (int, float)):
+ values.append(prop_val)
+
+ if not values:
+ value = None
+ elif extractor.function_name == "sum":
+ value = sum(values)
+ elif extractor.function_name == "min":
+ value = min(values)
+ elif extractor.function_name == "max":
+ value = max(values)
+ else:
+ value = f"'{extractor.function_name}' NOT_IMPL"
+ elif isinstance(extractor, FunctionBase):
+ # For non-aggregate functions, calculate based on the first object if available.
+ if objects:
+ value = extractor.get_value(objects[0])
+ else:
+ # This case (a ReferenceExtractor) is correctly blocked by the
+ # validate() method and should not be reached.
+ value = "INVALID_MIX"
+
+ row.append(value)
+ results_data.append(row)
+ else:
+ # This is a standard row-by-row query.
+ for obj in objects:
+ row = []
+ for extractor, _ in self.columns_info:
+ if extractor == "*":
+ value = obj.Label if hasattr(obj, "Label") else getattr(obj, "Name", "")
+ else:
+ value = extractor.get_value(obj)
+
+ # Append the raw value; formatting is the writer's responsibility
+ row.append(value)
+ results_data.append(row)
+
+ return results_data
+
+ def get_row_count(self, all_objects):
+ """
+ Calculates only the number of rows the query will produce, performing
+ the minimal amount of work necessary. This is used by Arch.count()
+ for a fast UI preview.
+ """
+ grouped_data = self._get_grouped_data(all_objects)
+ return len(grouped_data)
+
+ def _get_grouped_data(self, all_objects):
+ """
+ Performs Phase 1 of execution: FROM, WHERE, and GROUP BY.
+ This is the fast part of the query that only deals with object lists.
+ Returns a list of "groups", where each group is a list of objects.
+ """
+ filtered_objects = [
+ o for o in all_objects if self.where_clause is None or self.where_clause.matches(o)
+ ]
+
+ if not self.group_by_clause:
+ # If no GROUP BY, every object is its own group.
+ # Return as a list of single-item lists to maintain a consistent data structure.
+ return [[obj] for obj in filtered_objects]
+ else:
+ # If GROUP BY is present, partition the objects.
+ groups = {}
+ group_by_extractors = self.group_by_clause.columns
+ for obj in filtered_objects:
+ key = self._get_grouping_key(obj, group_by_extractors)
+ if key not in groups:
+ groups[key] = []
+ groups[key].append(obj)
+ return list(groups.values())
+
+ def _process_select_columns(self, grouped_data, all_objects_for_context):
+ """
+ Performs Phase 2 of execution: processes the SELECT columns.
+ This is the slow part of the query that does data extraction,
+ function calls, and aggregation.
+ """
+ results_data = []
+
+ # Handle SELECT * as a special case for non-grouped queries
+ if not self.group_by_clause and self.columns_info and self.columns_info[0][0] == "*":
+ for group in grouped_data:
+ obj = group[0]
+ value = obj.Label if hasattr(obj, "Label") else getattr(obj, "Name", "")
+ results_data.append([value])
+ return results_data
+
+ is_single_row_aggregate = (
+ any(isinstance(ex, AggregateFunction) for ex, _ in self.columns_info)
+ and not self.group_by_clause
+ )
+ if is_single_row_aggregate:
+ # A query with aggregates but no GROUP BY always returns one summary row
+ # based on all objects that passed the filter.
+
+ all_filtered_objects = [obj for group in grouped_data for obj in group]
+ row = self._calculate_row_values(all_filtered_objects)
+
+ return [row]
+
+ # Standard processing: one output row for each group.
+ for group in grouped_data:
+ row = self._calculate_row_values(group)
+ results_data.append(row)
+
+ return results_data
+
+ def _calculate_row_values(self, object_list):
+ """
+ Helper that calculates all SELECT column values for a given list of objects
+ (which can be a "group" or all filtered objects).
+ """
+ row = []
+ for extractor, _ in self.columns_info:
+ # Add a specific handler for the SELECT * case.
+ if extractor == "*":
+ if object_list:
+ obj = object_list[0]
+ value = obj.Label if hasattr(obj, "Label") else getattr(obj, "Name", "")
+ row.append(value)
+ # '*' is the only column in this case, so we must stop here.
+ continue
+
+ value = None
+ if isinstance(extractor, AggregateFunction):
+ value = self._calculate_aggregate(extractor, object_list)
+ elif isinstance(
+ extractor, (StaticExtractor, FunctionBase, ReferenceExtractor, ArithmeticOperation)
+ ):
+ # For non-aggregate extractors, the value is based on the first object in the list.
+ if object_list:
+ value = extractor.get_value(object_list[0])
+ else: # Should not be reached with proper validation
+ value = "INVALID_EXTRACTOR"
+ row.append(value)
+ return row
+
+ def _calculate_aggregate(self, extractor, object_list):
+ """Helper to compute the value for a single aggregate function."""
+ if extractor.function_name == "count":
+ if extractor.argument == "*":
+ return len(object_list)
+ else:
+ prop_name = extractor.argument.value
+ return sum(1 for obj in object_list if _get_property(obj, prop_name) is not None)
+
+ # For other aggregates, extract numeric values
+ arg_extractor = extractor.argument
+ values = []
+ for obj in object_list:
+ prop_val = arg_extractor.get_value(obj)
+ if prop_val is not None:
+ if isinstance(prop_val, FreeCAD.Units.Quantity):
+ prop_val = prop_val.Value
+ if isinstance(prop_val, (int, float)):
+ values.append(prop_val)
+
+ if not values:
+ return None
+ elif extractor.function_name == "sum":
+ return sum(values)
+ elif extractor.function_name == "min":
+ return min(values)
+ elif extractor.function_name == "max":
+ return max(values)
+
+ return f"'{extractor.function_name}' NOT_IMPL"
+
+
+class FromClause:
+ def __init__(self, reference):
+ self.reference = reference
+
+ def get_objects(self, source_objects=None):
+ """
+ Delegates the object retrieval to the contained logical object.
+ This works for both ReferenceExtractor and FromFunctionBase children.
+ """
+ return self.reference.get_objects(source_objects=source_objects)
+
+
+class WhereClause:
+ def __init__(self, expression):
+ self.expression = expression
+
+ def matches(self, obj):
+ return self.expression.evaluate(obj)
+
+
+class BooleanExpression:
+ def __init__(self, left, op, right):
+ self.left = left
+ self.op = op
+ self.right = right
+
+ def evaluate(self, obj):
+ if self.op is None:
+ return self.left.evaluate(obj)
+ elif self.op == "and":
+ return self.left.evaluate(obj) and self.right.evaluate(obj)
+ elif self.op == "or":
+ return self.left.evaluate(obj) or self.right.evaluate(obj)
+ else:
+ # An unknown operator is an invalid state and should raise an error.
+ raise SqlEngineError(f"Unknown boolean operator: '{self.op}'")
+
+
+class BooleanComparison:
+ def __init__(self, left, op, right):
+ self.left = left
+ self.op = op
+ self.right = right
+ # Validation: Aggregate functions are not allowed in WHERE clauses.
+ if isinstance(self.left, AggregateFunction) or isinstance(self.right, AggregateFunction):
+ raise SqlEngineError(
+ "Aggregate functions (like COUNT, SUM) cannot be used in a WHERE clause."
+ )
+
+ def evaluate(self, obj):
+ # The 'get_value' method is polymorphic and works for ReferenceExtractor,
+ # StaticExtractor, and all FunctionBase derivatives.
+ left_val = self.left.get_value(obj)
+ right_val = self.right.get_value(obj)
+ if self.op == "is":
+ return left_val is right_val
+ if self.op == "is_not":
+ return left_val is not right_val
+ # Strict SQL-like NULL semantics: any comparison (except IS / IS NOT)
+ # with a None (NULL) operand evaluates to False. Use IS / IS NOT for
+ # explicit NULL checks.
+ if left_val is None or right_val is None:
+ return False
+
+ # Normalize Quantities to their raw numerical values first.
+ # After this step, we are dealing with basic Python types.
+ if isinstance(left_val, FreeCAD.Units.Quantity):
+ left_val = left_val.Value
+ if isinstance(right_val, FreeCAD.Units.Quantity):
+ right_val = right_val.Value
+
+ # Prioritize numeric comparison if both operands are numbers.
+ if isinstance(left_val, (int, float)) and isinstance(right_val, (int, float)):
+ ops = {
+ "=": lambda a, b: a == b,
+ "!=": lambda a, b: a != b,
+ ">": lambda a, b: a > b,
+ "<": lambda a, b: a < b,
+ ">=": lambda a, b: a >= b,
+ "<=": lambda a, b: a <= b,
+ }
+ if self.op in ops:
+ return ops[self.op](left_val, right_val)
+
+ # Fallback to string-based comparison for all other cases (including 'like').
+ try:
+ str_left = str(left_val)
+ str_right = str(right_val)
+ except Exception:
+ # This is a defensive catch. If an object's __str__ method is buggy and raises
+ # an error, we treat the comparison as False rather than crashing the whole query.
+ return False
+
+ def like_to_regex(pattern):
+ s = str(pattern).replace("%", ".*").replace("_", ".")
+ return s
+
+ ops = {
+ "=": lambda a, b: a == b,
+ "!=": lambda a, b: a != b,
+ "like": lambda a, b: re.search(like_to_regex(b), a, re.IGNORECASE) is not None,
+ }
+
+ # Note: Operators like '>' are intentionally not in this dictionary.
+ # If the code reaches here with a '>' operator and non-numeric types,
+ # it will correctly return False, as a string-based '>' is not supported.
+ return ops[self.op](str_left, str_right) if self.op in ops else False
+
+
+class InComparison:
+ """Represents a SQL 'IN (values...)' comparison."""
+
+ def __init__(self, reference_extractor, literal_extractors):
+ self.reference_extractor = reference_extractor
+ # Eagerly extract the static string values for efficient lookup
+ self.values_set = {ex.get_value(None) for ex in literal_extractors}
+
+ def evaluate(self, obj):
+ property_value = self.reference_extractor.get_value(obj)
+ # The check is a simple Python 'in' against the pre-calculated set
+ return property_value in self.values_set
+
+
+class ArithmeticOperation:
+ """Represents a recursive arithmetic operation (e.g., a + (b * c))."""
+
+ def __init__(self, left, op, right):
+ self.left = left
+ self.op = op
+ self.right = right
+
+ def _normalize_value(self, value):
+ """Converts Quantities to floats for calculation, propagating None."""
+ # This is the first point of defense.
+ if value is None:
+ return None
+
+ if isinstance(value, FreeCAD.Units.Quantity):
+ return value.Value
+ elif isinstance(value, (int, float)):
+ return value
+ else:
+ # A non-numeric, non-None value is still an error.
+ type_name = type(value).__name__
+ raise SqlEngineError(
+ f"Cannot perform arithmetic on a non-numeric value of type '{type_name}'."
+ )
+
+ def get_value(self, obj):
+ """Recursively evaluates the calculation tree, propagating None."""
+ left_val = self._normalize_value(self.left.get_value(obj))
+ right_val = self._normalize_value(self.right.get_value(obj))
+
+ # This is the second point of defense. If either operand resolved to None,
+ # the entire arithmetic expression resolves to None (SQL NULL).
+ if left_val is None or right_val is None:
+ return None
+
+ if self.op == "+":
+ return left_val + right_val
+ if self.op == "-":
+ return left_val - right_val
+ if self.op == "*":
+ return left_val * right_val
+ if self.op == "/":
+ return left_val / right_val if right_val != 0 else float("inf")
+
+ raise SqlEngineError(f"Unknown arithmetic operator: '{self.op}'")
+
+
+class ReferenceExtractor:
+ """
+ Represents a request to extract a value from an object, handling nesting.
+
+ This class is the core of property access in the SQL engine. It can represent a simple property
+ access (e.g., `Label`), a nested property access using a dot-notation string (e.g.,
+ `Shape.Volume`), or a chained access on the result of another function (e.g.,
+ `PARENT(*).Label`).
+
+ The chained access is achieved by making the class recursive. The `base` attribute can hold
+ another extractor object (like `ParentFunction` or another `ReferenceExtractor`), which is
+ evaluated first to get an intermediate object from which the final `value` is extracted.
+ """
+
+ def __init__(self, value, base=None):
+ """
+ Initializes the ReferenceExtractor.
+
+ Parameters
+ ----------
+ value : str
+ The name of the property to extract (e.g., 'Label', 'Shape.Volume').
+ base : object, optional
+ Another logical extractor object that, when evaluated, provides the base object for this
+ extraction. If None, the property is extracted from the main object of the current row.
+ Defaults to None.
+ """
+ self.value = value
+ self.base = base
+
+ def get_value(self, obj):
+ """
+ Extracts and returns the final property value from a given object.
+
+ If `self.base` is set, this method first recursively calls `get_value` on the base to
+ resolve the intermediate object (e.g., executing `PARENT(*)` to get the parent). It then
+ extracts the property specified by `self.value` from that intermediate object.
+
+ If `self.base` is None, it directly extracts the property from the provided row object
+ `obj`.
+
+ Parameters
+ ----------
+ obj : FreeCAD.DocumentObject
+ The document object for the current row being processed.
+
+ Returns
+ -------
+ any
+ The value of the requested property, or None if any part of the access chain is invalid
+ or returns None.
+ """
+ if self.base:
+ base_object = self.base.get_value(obj)
+ # If the base evaluates to None (e.g., PARENT(*) on a top-level object),
+ # we cannot get a property from it. Return None to prevent errors.
+ if base_object is None:
+ return None
+ return _get_property(base_object, self.value)
+ else:
+ # Original behavior for a base reference from the current row's object.
+ return _get_property(obj, self.value)
+
+ def get_objects(self, source_objects=None):
+ """
+ Provides the interface for the FromClause to get the initial set of objects to query.
+
+ This method is only intended to be used for the special case of `FROM document`, where it
+ returns all objects in the active document. In all other contexts, it returns an empty list.
+
+ Returns
+ -------
+ list of FreeCAD.DocumentObject
+ A list of all objects in the active document if `self.value` is 'document', otherwise an
+ empty list.
+ """
+ if source_objects is not None:
+ # If source_objects are provided, they override 'FROM document'.
+ return source_objects
+ if self.value == "document" and not self.base:
+ found_objects = FreeCAD.ActiveDocument.Objects
+ return found_objects
+ return []
+
+
+class StaticExtractor:
+ def __init__(self, value):
+ self.value = value
+
+ def get_value(self, obj):
+ return self.value
+
+
+# --- Lark Transformer ---
+
+
+class SqlTransformerMixin:
+ """
+ A mixin class containing all our custom transformation logic for SQL rules.
+ It has no __init__ to avoid conflicts in a multiple inheritance scenario.
+ """
+
+ def start(self, i):
+ return i[0]
+
+ def statement(self, children):
+ # The 'columns' rule produces a list of (extractor, display_name) tuples
+ columns_info = next((c for c in children if c.__class__ == list), None)
+ from_c = next((c for c in children if isinstance(c, FromClause)), None)
+ where_c = next((c for c in children if isinstance(c, WhereClause)), None)
+ group_by_c = next((c for c in children if isinstance(c, GroupByClause)), None)
+ order_by_c = next((c for c in children if isinstance(c, OrderByClause)), None)
+
+ return SelectStatement(columns_info, from_c, where_c, group_by_c, order_by_c)
+
+ def from_source(self, items):
+ # This method handles the 'from_source' rule.
+ # items[0] will either be a CNAME token (for 'document') or a
+ # transformed FromFunctionBase object.
+ item = items[0]
+ if isinstance(item, generated_sql_parser.Token) and item.type == "CNAME":
+ # If it's the CNAME 'document', create the base ReferenceExtractor for it.
+ return ReferenceExtractor(str(item))
+ else:
+ # Otherwise, it's already a transformed function object, so just return it.
+ return item
+
+ def from_clause(self, i):
+ return FromClause(i[1])
+
+ def where_clause(self, i):
+ return WhereClause(i[1])
+
+ def from_function(self, items):
+ function_name_token = items[0]
+ # The arguments are a list that can contain the subquery statement
+ # and an optional StaticExtractor for max_depth.
+ args = items[1:]
+ function_name = str(function_name_token).upper()
+ function_class = self.from_function_registry.get_class(function_name)
+ if not function_class:
+ raise ValueError(f"Unknown FROM function: {function_name}")
+ return function_class(args)
+
+ def group_by_clause(self, items):
+ # Allow both property references and function calls as grouping keys.
+ references = [
+ item for item in items if isinstance(item, (ReferenceExtractor, FunctionBase))
+ ]
+ return GroupByClause(references)
+
+ def order_by_clause(self, items):
+ # items contains: ORDER, BY, reference, (",", reference)*, optional direction
+
+ # The ORDER BY clause only operates on the names of the final columns, which are always
+ # parsed as simple identifiers.
+ column_references = []
+ for item in items:
+ if isinstance(item, ReferenceExtractor):
+ column_references.append(item)
+ # This is the new, stricter validation.
+ elif isinstance(item, (FunctionBase, ArithmeticOperation)):
+ raise ValueError(
+ "ORDER BY expressions are not supported directly. Please include the expression "
+ "as a column in the SELECT list with an alias, and ORDER BY the alias."
+ )
+
+ direction = "ASC"
+ # The optional direction token will be the last item if it exists.
+ last_item = items[-1]
+ if isinstance(last_item, generated_sql_parser.Token) and last_item.type in ("ASC", "DESC"):
+ direction = last_item.value.upper()
+
+ return OrderByClause(column_references, direction)
+
+ def columns(self, items):
+ # `items` is a list of results from `column` rules, which are (extractor, display_name) tuples
+ return items
+
+ def as_clause(self, items):
+ # The alias will be the second item. It can either be a transformed
+ # StaticExtractor (from a quoted string) or a raw CNAME token.
+ alias_part = items[1]
+
+ if isinstance(alias_part, StaticExtractor):
+ # Case 1: The alias was a quoted string like "Floor Name".
+ # The 'literal' rule transformed it into a StaticExtractor.
+ return alias_part.get_value(None)
+ else:
+ # Case 2: The alias was an unquoted name like FloorName.
+ # The grammar passed the raw CNAME Token directly.
+ return str(alias_part)
+
+ def column(self, items):
+ # Each item in `items` is either '*' (for SELECT *) or an extractor object.
+ # We need to return a (extractor, display_name) tuple.
+ extractor = items[0]
+ alias = items[1] if len(items) > 1 else None
+
+ # Determine the default display name first
+ default_name = "Unknown Column"
+ if extractor == "*":
+ default_name = SELECT_STAR_HEADER
+ elif isinstance(extractor, ReferenceExtractor):
+ default_name = extractor.value
+ elif isinstance(extractor, StaticExtractor):
+ default_name = str(extractor.get_value(None))
+ elif isinstance(extractor, AggregateFunction):
+ # Correctly handle the argument for default name generation.
+ arg = extractor.argument
+ arg_display = "?" # fallback
+ if arg == "*":
+ arg_display = "*"
+ elif hasattr(arg, "value"): # It's a ReferenceExtractor
+ arg_display = arg.value
+ default_name = f"{extractor.function_name.upper()}({arg_display})"
+ elif isinstance(extractor, FunctionBase):
+ # Create a nice representation for multi-arg functions
+ arg_strings = []
+ for arg_ex in extractor.arg_extractors:
+ if arg_ex == "*":
+ arg_strings.append("*")
+ elif isinstance(arg_ex, ReferenceExtractor):
+ arg_strings.append(arg_ex.value)
+ elif isinstance(arg_ex, StaticExtractor):
+ arg_strings.append(f"'{arg_ex.get_value(None)}'")
+ else:
+ arg_strings.append("?") # Fallback
+ default_name = f"{extractor.function_name.upper()}({', '.join(arg_strings)})"
+
+ # Use the alias if provided, otherwise fall back to the default name.
+ final_name = alias if alias is not None else default_name
+ return (extractor, final_name)
+
+ def boolean_expression_recursive(self, items):
+ return BooleanExpression(items[0], items[1].value.lower(), items[2])
+
+ def boolean_expression(self, i):
+ return BooleanExpression(i[0], None, None)
+
+ def boolean_or(self, i):
+ return i[0]
+
+ def boolean_and(self, i):
+ return i[0]
+
+ def boolean_term(self, i):
+ return i[0]
+
+ def boolean_comparison(self, items):
+ return BooleanComparison(items[0], items[1], items[2])
+
+ def primary(self, items):
+ # This transformer handles the 'primary' grammar rule.
+ # It transforms a CNAME token into a base ReferenceExtractor.
+ # All other items (functions, literals, numbers) are already transformed
+ # by their own methods, so we just pass them up.
+ item = items[0]
+ if isinstance(item, generated_sql_parser.Token) and item.type == "CNAME":
+ return ReferenceExtractor(str(item))
+ return item
+
+ def factor(self, items):
+ # This transformer handles the 'factor' rule for chained property access.
+ # It receives a list of the transformed children.
+ # The first item is the base (the result of the 'primary' rule).
+ # The subsequent items are the CNAME tokens for each property access.
+
+ # Start with the base of the chain.
+ base_extractor = items[0]
+
+ # Iteratively wrap the base with a new ReferenceExtractor for each
+ # property in the chain.
+ for prop_token in items[1:]:
+ prop_name = str(prop_token)
+ base_extractor = ReferenceExtractor(prop_name, base=base_extractor)
+
+ return base_extractor
+
+ def in_expression(self, items):
+ # Unpack the items: the factor to check, and then all literal extractors.
+ factor_to_check = items[0]
+ literal_extractors = [item for item in items[1:] if isinstance(item, StaticExtractor)]
+ return InComparison(factor_to_check, literal_extractors)
+
+ def comparison_operator(self, i):
+ return i[0]
+
+ def eq_op(self, _):
+ return "="
+
+ def neq_op(self, _):
+ return "!="
+
+ def like_op(self, _):
+ return "like"
+
+ def is_op(self, _):
+ return "is"
+
+ def is_not_op(self, _):
+ return "is_not"
+
+ def gt_op(self, _):
+ return ">"
+
+ def lt_op(self, _):
+ return "<"
+
+ def gte_op(self, _):
+ return ">="
+
+ def lte_op(self, _):
+ return "<="
+
+ def operand(self, items):
+ # This method is now "dumb" and simply passes up the already-transformed object.
+ # The transformation of terminals happens in their own dedicated methods below.
+ return items[0]
+
+ def literal(self, items):
+ return StaticExtractor(items[0].value[1:-1])
+
+ def NUMBER(self, token):
+ # This method is automatically called by Lark for any NUMBER terminal.
+ return StaticExtractor(float(token.value))
+
+ def NULL(self, token):
+ # This method is automatically called by Lark for any NULL terminal.
+ return StaticExtractor(None)
+
+ def ASTERISK(self, token):
+ # This method is automatically called by Lark for any ASTERISK terminal.
+ # Return the string '*' to be used as a special identifier.
+ return "*"
+
+ def function_args(self, items):
+ # This method just collects all arguments into a single list.
+ return items
+
+ def term(self, items):
+ """
+ Builds a left-associative tree for multiplication/division.
+ This is a critical change to fix the data flow for the factor rule.
+ """
+ tree = items[0]
+ for i in range(1, len(items), 2):
+ op_token = items[i]
+ right = items[i + 1]
+ tree = ArithmeticOperation(tree, op_token.value, right)
+ return tree
+
+ def expr(self, items):
+ """Builds a left-associative tree for addition/subtraction."""
+ tree = items[0]
+ for i in range(1, len(items), 2):
+ op_token = items[i]
+ right = items[i + 1]
+ tree = ArithmeticOperation(tree, op_token.value, right)
+ return tree
+
+ def member_access(self, items):
+ """
+ This transformer handles the 'member_access' rule for chained property access.
+ It can handle both simple properties (CNAME) and function calls after a dot.
+ """
+ # Start with the base of the chain (the result of the 'primary' rule).
+ base_extractor = items[0]
+
+ # The rest of the items are a mix of CNAME tokens and transformed Function objects.
+ for member in items[1:]:
+ if isinstance(member, generated_sql_parser.Token) and member.type == "CNAME":
+ # Case 1: A simple property access like '.Label'
+ # Wrap the current chain in a new ReferenceExtractor.
+ base_extractor = ReferenceExtractor(str(member), base=base_extractor)
+ else:
+ # Case 2: A function call like '.PARENT(*)'
+ # The 'member' is already a transformed object (e.g., ParentFunction).
+ # We set its base to the current chain, making it the new end of the chain.
+ member.base = base_extractor
+ base_extractor = member
+
+ return base_extractor
+
+ def function(self, items):
+ function_name_token = items[0]
+ function_name = str(function_name_token).upper()
+ # Arguments are optional (e.g. for a future function).
+ args = items[1] if len(items) > 1 else []
+
+ # Special handling for aggregates, which all use the AggregateFunction logic
+ # but are instantiated via their specific subclasses.
+ aggregate_map = {
+ "COUNT": CountFunction,
+ "SUM": SumFunction,
+ "MIN": MinFunction,
+ "MAX": MaxFunction,
+ }
+ if function_name in aggregate_map:
+ # Instantiate the correct subclass (CountFunction, etc.) but pass the
+ # function name (e.g., 'count') to the AggregateFunction constructor.
+ return aggregate_map[function_name](function_name.lower(), args)
+
+ # Look up the function in the injected SELECT function registry
+ function_class = self.select_function_registry.get_class(function_name)
+
+ if function_class:
+ return function_class(function_name, args)
+
+ # If the function is not in our registry, it's a validation error.
+ raise ValueError(f"Unknown SELECT function: {function_name}")
+
+
+# --- Engine Initialization ---
+
+
+def _initialize_engine():
+ """
+ Creates and configures all components of the SQL engine.
+ Function registration is now handled automatically via decorators on each
+ function's class definition.
+ """
+
+ # 1. Define and instantiate the transformer.
+ class FinalTransformer(generated_sql_parser.Transformer, SqlTransformerMixin):
+ def __init__(self):
+ # The transformer still needs access to the registries to look up
+ # standard function classes.
+ self.select_function_registry = select_function_registry
+ self.from_function_registry = from_function_registry
+
+ transformer = FinalTransformer()
+
+ # 2. Instantiate the parser
+ parser = generated_sql_parser.Lark_StandAlone()
+
+ # 3. Generate friendly token names from the initialized parser
+ friendly_token_names = _generate_friendly_token_names(parser)
+
+ return parser, transformer, friendly_token_names
+
+
+# --- Module-level Globals (initialized by the engine) ---
+try:
+ _parser, _transformer, _FRIENDLY_TOKEN_NAMES = _initialize_engine()
+except Exception as e:
+ _parser, _transformer, _FRIENDLY_TOKEN_NAMES = None, None, {}
+ FreeCAD.Console.PrintError(f"BIM SQL engine failed to initialize: {e}\n")
+
+
+# --- Internal API Functions ---
+
+
+def _run_query(query_string: str, mode: str, source_objects: Optional[List] = None):
+ """
+ The single, internal entry point for the SQL engine.
+
+ This function encapsulates the entire query process: parsing, transformation,
+ validation, and execution. It uses a 'mode' parameter to decide whether
+ to perform a full data execution or a lightweight, performant count. It is
+ a "silent" function that raises a specific exception on any failure, but
+ performs no logging itself.
+
+ Parameters
+ ----------
+ query_string : str
+ The raw SQL query string to be processed.
+ mode : str
+ The execution mode, either 'full_data' or 'count_only'.
+ source_objects : list of FreeCAD.DocumentObject, optional
+ If provided, the query will run on this list of objects instead of the
+ entire document. Defaults to None.
+
+ Returns
+ -------
+ int or tuple
+ If mode is 'count_only', returns an integer representing the row count.
+ If mode is 'full_data', returns a tuple `(headers, data_rows)`.
+
+ Raises
+ ------
+ SqlEngineError
+ For general engine errors, such as initialization failures or
+ validation errors (e.g., mixing aggregates without GROUP BY).
+ BimSqlSyntaxError
+ For any syntax, parsing, or transformation error, with a flag to
+ indicate if the query was simply incomplete.
+ """
+
+ def _parse_and_transform(query_string_internal: str) -> "SelectStatement":
+ """Parses and transforms the string into a logical statement object."""
+ if not _parser or not _transformer:
+ raise SqlEngineError(
+ "BIM SQL engine is not initialized. Check console for errors on startup."
+ )
+ try:
+ tree = _parser.parse(query_string_internal)
+ statement_obj = _transformer.transform(tree)
+ statement_obj.validate()
+ return statement_obj
+ except ValueError as e:
+ raise SqlEngineError(str(e))
+ except VisitError as e:
+ message = f"Transformer Error: Failed to process rule '{e.rule}'. Original error: {e.orig_exc}"
+ raise BimSqlSyntaxError(message) from e
+ except UnexpectedToken as e:
+ # Heuristic for a better typing experience: If the unexpected token's
+ # text is a prefix of any of the keywords the parser was expecting,
+ # we can assume the user is still typing that keyword. In this case,
+ # we treat the error as "Incomplete" instead of a harsh "Syntax Error".
+ token_text = e.token.value.upper()
+ # The `e.expected` list from Lark contains the names of the expected terminals.
+ is_prefix_of_expected = any(
+ expected_keyword.startswith(token_text)
+ for expected_keyword in e.expected
+ if expected_keyword.isupper()
+ )
+
+ if is_prefix_of_expected:
+ raise BimSqlSyntaxError("Query is incomplete.", is_incomplete=True) from e
+
+ # If it's not an incomplete keyword, proceed with a full syntax error.
+ is_incomplete = e.token.type == "$END"
+ # Filter out internal Lark tokens before creating the message
+ friendly_expected = [
+ _FRIENDLY_TOKEN_NAMES.get(t, f"'{t}'") for t in e.expected if not t.startswith("__")
+ ]
+ expected_str = ", ".join(friendly_expected)
+ message = (
+ f"Syntax Error: Unexpected '{e.token.value}' at line {e.line}, column {e.column}. "
+ f"Expected {expected_str}."
+ )
+ raise BimSqlSyntaxError(message, is_incomplete=is_incomplete) from e
+ except UnexpectedEOF as e:
+ raise BimSqlSyntaxError("Query is incomplete.", is_incomplete=True) from e
+
+ statement = _parse_and_transform(query_string)
+
+ all_objects = statement.from_clause.get_objects(source_objects=source_objects)
+
+ if mode == "count_only":
+ # Phase 1: Perform the fast filtering and grouping to get the
+ # correct final row count.
+ grouped_data = statement._get_grouped_data(all_objects)
+ row_count = len(grouped_data)
+
+ # If there are no results, the query is valid and simply returns 0 rows.
+ if row_count == 0:
+ return 0, [], []
+
+ # Phase 2 Validation: Perform a "sample execution" on the first group
+ # to validate the SELECT clause and catch any execution-time errors.
+ # We only care if it runs without error; the result is discarded.
+ # For aggregates without GROUP BY, the "group" is all filtered objects.
+ is_single_row_aggregate = (
+ any(isinstance(ex, AggregateFunction) for ex, _ in statement.columns_info)
+ and not statement.group_by_clause
+ )
+ if is_single_row_aggregate:
+ sample_group = [obj for group in grouped_data for obj in group]
+ else:
+ sample_group = grouped_data[0]
+
+ # Get headers from the parsed statement object.
+ headers = [display_name for _, display_name in statement.columns_info]
+ # Get data from the process method, which returns a single list of rows.
+ data = statement._process_select_columns([sample_group], all_objects)
+
+ # If the sample execution succeeds, the query is fully valid.
+ # The resulting_objects are not needed for the count validation itself,
+ # but are returned for API consistency.
+ resulting_objects = _map_results_to_objects(headers, data)
+ return row_count, headers, resulting_objects
+ else: # 'full_data'
+ headers, results_data = statement.execute(all_objects)
+ resulting_objects = _map_results_to_objects(headers, results_data)
+ return headers, results_data, resulting_objects
+
+
+# --- Public API Objects ---
+
+
+class ReportStatement:
+ """A data model for a single statement within a BIM Report.
+
+ This class encapsulates all the information required to execute and present
+ a single query. It holds the SQL string itself, options for how its
+ results should be formatted in the final spreadsheet, and the crucial flag
+ that controls whether it participates in a pipeline.
+
+ Instances of this class are created and managed by the ReportTaskPanel UI
+ and are passed as a list to the `execute_pipeline` engine function. The
+ class includes methods for serialization to and from a dictionary format,
+ allowing it to be persisted within a FreeCAD document object.
+
+ Parameters
+ ----------
+ description : str, optional
+ A user-defined description for the statement. This is shown in the UI
+ and can optionally be used as a section header in the spreadsheet.
+ query_string : str, optional
+ The SQL query to be executed for this statement.
+ use_description_as_header : bool, optional
+ If True, the `description` will be written as a merged header row
+ before the statement's results in the spreadsheet. Defaults to False.
+ include_column_names : bool, optional
+ If True, the column names from the SQL query (e.g., 'Label', 'Area')
+ will be included as a header row for this statement's results.
+ Defaults to True.
+ add_empty_row_after : bool, optional
+ If True, an empty row will be inserted after this statement's results
+ to provide visual spacing in the report. Defaults to False.
+ print_results_in_bold : bool, optional
+ If True, the data cells for this statement's results will be formatted
+ with a bold font for emphasis. Defaults to False.
+ is_pipelined : bool, optional
+ If True, this statement will use the resulting objects from the
+ previous statement as its data source instead of the entire document.
+ This is the flag that enables pipeline functionality. Defaults to False.
+
+ Attributes
+ ----------
+ _validation_status : str
+ A transient (not saved) string indicating the validation state for the
+ UI (e.g., "OK", "ERROR").
+ _validation_message : str
+ A transient (not saved) user-facing message corresponding to the
+ validation status.
+ _validation_count : int
+ A transient (not saved) count of objects found during validation.
+ """
+
+ def __init__(
+ self,
+ description="",
+ query_string="",
+ use_description_as_header=False,
+ include_column_names=True,
+ add_empty_row_after=False,
+ print_results_in_bold=False,
+ is_pipelined=False,
+ ):
+ self.description = description
+ self.query_string = query_string
+ self.use_description_as_header = use_description_as_header
+ self.include_column_names = include_column_names
+ self.add_empty_row_after = add_empty_row_after
+ self.print_results_in_bold = print_results_in_bold
+ self.is_pipelined = is_pipelined
+
+ # Internal validation state (transient, not serialized)
+ self._validation_status = "Ready" # e.g., "OK", "0_RESULTS", "ERROR", "INCOMPLETE"
+ self._validation_message = translate(
+ "Arch", "Ready"
+ ) # e.g., "Found 5 objects.", "Syntax Error: ..."
+ self._validation_count = 0
+
+ def dumps(self):
+ """Returns the internal state for serialization."""
+ return {
+ "description": self.description,
+ "query_string": self.query_string,
+ "use_description_as_header": self.use_description_as_header,
+ "include_column_names": self.include_column_names,
+ "add_empty_row_after": self.add_empty_row_after,
+ "print_results_in_bold": self.print_results_in_bold,
+ "is_pipelined": self.is_pipelined,
+ }
+
+ def loads(self, state):
+ """Restores the internal state from serialized data."""
+ self.description = state.get("description", "")
+ self.query_string = state.get("query_string", "")
+ self.use_description_as_header = state.get("use_description_as_header", False)
+ self.include_column_names = state.get("include_column_names", True)
+ self.add_empty_row_after = state.get("add_empty_row_after", False)
+ self.print_results_in_bold = state.get("print_results_in_bold", False)
+ self.is_pipelined = state.get("is_pipelined", False)
+
+ # Validation state is transient and re-calculated on UI load/edit
+ self._validation_status = "Ready"
+ self._validation_message = translate("Arch", "Ready")
+ self._validation_count = 0
+
+ def validate_and_update_status(self):
+ """Runs validation for this statement's query and updates its internal status."""
+ if not self.query_string.strip():
+ self._validation_status = "OK" # Empty query is valid, no error
+ self._validation_message = translate("Arch", "Ready")
+ self._validation_count = 0
+ return
+
+ # Avoid shadowing the module-level `count` function by using a
+ # different local name for the numeric result.
+ count_result, error = count(self.query_string)
+
+ if error == "INCOMPLETE":
+ self._validation_status = "INCOMPLETE"
+ self._validation_message = translate("Arch", "Typing...")
+ self._validation_count = -1
+ elif error:
+ self._validation_status = "ERROR"
+ self._validation_message = error
+ self._validation_count = -1
+ elif count_result == 0:
+ self._validation_status = "0_RESULTS"
+ self._validation_message = translate("Arch", "Query is valid, but found 0 objects.")
+ self._validation_count = 0
+ else:
+ self._validation_status = "OK"
+ self._validation_message = (
+ f"{translate('Arch', 'Found')} {count_result} {translate('Arch', 'objects')}."
+ )
+ self._validation_count = count_result
+
+
+# --- Public API Functions ---
+
+
+def selectObjectsFromPipeline(statements: list) -> list:
+ """
+ Executes a multi-statement pipeline and returns a final list of FreeCAD objects.
+
+ This is a high-level convenience function for scripting complex, multi-step
+ selections that are too difficult or cumbersome for a single SQL query.
+
+ Parameters
+ ----------
+ statements : list of ArchReport.ReportStatement
+ A configured list of statements defining the pipeline.
+
+ Returns
+ -------
+ list of FreeCAD.DocumentObject
+ A list of the final FreeCAD.DocumentObject instances that result from
+ the pipeline.
+ """
+ # 1. The pipeline orchestrator is a generator. We consume it to get the
+ # list of all output blocks.
+ output_blocks = list(execute_pipeline(statements))
+
+ if not output_blocks:
+ return []
+
+ # 2. For scripting, we are only interested in the final result.
+ # The final result is the last item yielded by the generator.
+ _, final_headers, final_data = output_blocks[-1]
+
+ # 3. Use the existing helper to map the final raw data back to objects.
+ return _map_results_to_objects(final_headers, final_data)
+
+
+def execute_pipeline(statements: List["ReportStatement"]):
+ """
+ Executes a list of statements, handling chained data flow as a generator.
+
+ This function orchestrates a multi-step query. It yields the results of
+ each standalone statement or the final statement of a contiguous pipeline,
+ allowing the caller to handle presentation.
+
+ Parameters
+ ----------
+ statements : list of ArchReport.ReportStatement
+ A configured list of statements to execute.
+
+ Yields
+ ------
+ tuple
+ A tuple `(statement, headers, data_rows)` for each result block that
+ should be outputted.
+ """
+ pipeline_input_objects = None
+
+ for i, statement in enumerate(statements):
+ # Skip empty queries (user may have a placeholder statement)
+ if not statement.query_string or not statement.query_string.strip():
+ # If this empty statement breaks a chain, reset the pipeline
+ if not statement.is_pipelined:
+ pipeline_input_objects = None
+ continue
+
+ # Determine the data source for the current query
+ source = pipeline_input_objects if statement.is_pipelined else None
+
+ try:
+ headers, data, resulting_objects = _run_query(
+ statement.query_string, mode="full_data", source_objects=source
+ )
+ except (SqlEngineError, BimSqlSyntaxError) as e:
+ # If a step fails, yield an error block and reset the pipeline
+ yield (statement, ["Error"], [[str(e)]])
+ pipeline_input_objects = None
+ continue
+
+ # The output of this query becomes the input for the next one.
+ pipeline_input_objects = resulting_objects
+
+ # If this statement is NOT pipelined, it breaks any previous chain.
+ # Its own output becomes the new potential start for a subsequent chain.
+ if not statement.is_pipelined:
+ pass # The pipeline_input_objects is already correctly set for the next step.
+
+ # Determine if this statement's results should be part of the final output.
+ is_last_statement = i == len(statements) - 1
+
+ # A statement's results are yielded UNLESS it is an intermediate step.
+ # An intermediate step is any statement that is immediately followed by a pipelined statement.
+ is_intermediate_step = not is_last_statement and statements[i + 1].is_pipelined
+
+ if not is_intermediate_step:
+ yield (statement, headers, data)
+
+
+def count(query_string: str, source_objects: Optional[List] = None) -> Tuple[int, Optional[str]]:
+ """
+ Executes a query and returns only the count of resulting rows.
+
+ This is a "safe" public API function intended for UI components like
+ live validation feedback. It catches all exceptions and returns a
+ consistent tuple, making it safe to call with incomplete user input.
+
+ Parameters
+ ----------
+ query_string : str
+ The raw SQL query string.
+ source_objects : list of FreeCAD.DocumentObject, optional
+ If provided, the query count will run on this list of objects instead
+ of the entire document. Defaults to None.
+
+ Returns
+ -------
+ Tuple[int, Optional[str]]
+ A tuple `(count, error_string)`.
+ On success, `count` is the number of rows and `error_string` is `None`.
+ On failure, `count` is `-1` and `error_string` contains a user-friendly
+ description of the error (e.g., "INCOMPLETE", "Syntax Error").
+ """
+ if (query_string.count("'") % 2 != 0) or (query_string.count('"') % 2 != 0):
+ return -1, "INCOMPLETE"
+
+ try:
+ count_result, _, _ = _run_query(
+ query_string, mode="count_only", source_objects=source_objects
+ )
+ return count_result, None
+ except BimSqlSyntaxError as e:
+ if e.is_incomplete:
+ return -1, "INCOMPLETE"
+ else:
+ # Pass the specific, detailed error message up to the caller.
+ return -1, str(e)
+ except SqlEngineError as e:
+ return -1, str(e)
+
+
+def select(query_string: str) -> Tuple[List[str], List[List[Any]]]:
+ """
+ Executes a query and returns the full results.
+
+ This function implements a "Catch, Log, and Re-Raise" pattern. It is
+ safe in that it logs a detailed error to the console, but it is also
+ "unsafe" in that it re-raises the exception to signal the failure to
+ the calling function, which is responsible for handling it.
+
+ Returns
+ -------
+ Tuple[List[str], List[List[Any]]]
+ A tuple `(headers, data_rows)`.
+
+ Raises
+ ------
+ SqlEngineError
+ Re-raises any SqlEngineError or BimSqlSyntaxError without logging it.
+ The caller is responsible for logging and handling.
+ """
+ # This is the "unsafe" API. It performs no error handling and lets all
+ # exceptions propagate up to the caller, who is responsible for logging
+ # or handling them as needed.
+ headers, results_data, _ = _run_query(query_string, mode="full_data")
+ return headers, results_data
+
+
+def selectObjects(query_string: str) -> List["FreeCAD.DocumentObject"]:
+ """
+ Selects objects from the active document using a SQL-like query.
+
+ This provides a declarative way to select BIM objects
+ based on their properties. This is a convenience function for scripting.
+
+ Parameters
+ ----------
+ query_string : str
+ The SQL query to execute. For example:
+ 'SELECT * FROM document WHERE IfcType = "Wall" AND Label LIKE "%exterior%"'
+
+ Returns
+ -------
+ list of App::DocumentObject
+ A list of the FreeCAD document objects that match the query.
+ Returns an empty list if the query is invalid or finds no objects.
+ """
+ if not FreeCAD.ActiveDocument:
+ FreeCAD.Console.PrintError("Arch.selectObjects() requires an active document.\n")
+ return []
+
+ try:
+ # Execute the query using the internal 'select' function.
+ headers, data_rows = select(query_string)
+
+ return _map_results_to_objects(headers, data_rows)
+
+ except (SqlEngineError, BimSqlSyntaxError) as e:
+ # If the query fails, log the error and return an empty list.
+ FreeCAD.Console.PrintError(f"Arch.selectObjects query failed: {e}\n")
+ return []
+
+
+def getSqlKeywords(kind="all") -> List[str]:
+ """
+ Returns a list of all keywords and function names for syntax highlighters.
+
+ This function provides the single source of truth for SQL syntax in the
+ BIM workbench. It dynamically introspects the initialized Lark parser and
+ the function registries. It can return different subsets of keywords
+ based on the `kind` parameter.
+
+ Parameters
+ ----------
+ kind : str, optional
+ Specifies the type of keyword list to return.
+ - 'all': (Default) Returns all single keywords from the grammar.
+ - 'no_space': Returns a list of keywords that should not have a
+ trailing space in autocompletion (e.g., functions, modifiers).
+
+ Returns
+ -------
+ List[str]
+ A sorted, unique list of keywords and function names (e.g.,
+ ['SELECT', 'FROM', 'COUNT', 'CHILDREN', ...]). Returns an empty list
+ if the engine failed to initialize.
+ """
+ # The parser and transformer are initialized at the module level.
+ # We just check if the initialization was successful.
+ if not _parser or not _transformer:
+ return []
+
+ if kind == "no_space":
+ no_space_keywords = _get_sql_function_names()
+ no_space_keywords.update({"ASC", "DESC"})
+ return sorted(list(no_space_keywords))
+
+ # Default behavior for kind='all'
+ keywords = []
+ # This blocklist contains all uppercase terminals from the grammar that are NOT
+ # actual keywords a user would type.
+ NON_KEYWORD_TERMINALS = {"WS", "CNAME", "STRING", "NUMBER", "LPAR", "RPAR", "COMMA", "ASTERISK"}
+
+ # 1. Get all keywords from the parser's terminals.
+ # A terminal is a keyword if its name is uppercase and not in our blocklist.
+ for term in _parser.terminals:
+ # Filter out internal, anonymous tokens generated by Lark.
+ is_internal = term.name.startswith("__") # Filter out internal __ANON tokens
+ if term.name.isupper() and term.name not in NON_KEYWORD_TERMINALS and not is_internal:
+ keywords.append(term.name)
+
+ # 2. Get all registered function names.
+ keywords.extend(list(_get_sql_function_names()))
+
+ return sorted(list(set(keywords))) # Return a sorted, unique list.
+
+
+def _get_sql_function_names() -> set:
+ """(Internal) Returns a set of all registered SQL function names."""
+ if not _transformer:
+ return set()
+ select_funcs = set(_transformer.select_function_registry._functions.keys())
+ from_funcs = set(_transformer.from_function_registry._functions.keys())
+ return select_funcs.union(from_funcs)
+
+
+def getSqlApiDocumentation() -> dict:
+ """
+ Generates a structured dictionary describing the supported SQL dialect.
+
+ This function introspects the live engine configuration and performs
+ just-in-time translation of descriptive strings to ensure they appear
+ in the user's current language.
+
+ Returns
+ -------
+ dict
+ A dictionary with keys 'clauses' and 'functions'. 'functions' is a
+ dict of lists, categorized by function type.
+ """
+ api_data = {"clauses": [], "functions": {}}
+
+ # Combine all function registries into one for easier iteration.
+ all_registries = {
+ **_transformer.select_function_registry._functions,
+ **_transformer.from_function_registry._functions,
+ }
+
+ # Group functions by their registered category, translating as we go.
+ for name, data in all_registries.items():
+ # The category and description strings were marked for translation
+ # with a context of "ArchSql" when they were registered.
+ translated_category = translate("ArchSql", data["category"])
+
+ if translated_category not in api_data["functions"]:
+ api_data["functions"][translated_category] = []
+
+ api_data["functions"][translated_category].append(
+ {
+ "name": name,
+ "signature": data["signature"],
+ "description": translate("ArchSql", data["description"]),
+ "snippet": data["snippet"],
+ }
+ )
+
+ # To get a clean list of "Clauses" for the cheatsheet, we use an explicit
+ # whitelist of keywords that represent major SQL clauses.
+ CLAUSE_KEYWORDS = {
+ "SELECT",
+ "FROM",
+ "WHERE",
+ "GROUP",
+ "BY",
+ "ORDER",
+ "AS",
+ "AND",
+ "OR",
+ "IS",
+ "NOT",
+ "IN",
+ "LIKE",
+ }
+ all_terminals = {term.name for term in _parser.terminals}
+ api_data["clauses"] = sorted([k for k in CLAUSE_KEYWORDS if k in all_terminals])
+
+ return api_data
diff --git a/src/Mod/BIM/CMakeLists.txt b/src/Mod/BIM/CMakeLists.txt
index 325e2546c5..0c5834a706 100644
--- a/src/Mod/BIM/CMakeLists.txt
+++ b/src/Mod/BIM/CMakeLists.txt
@@ -1,3 +1,5 @@
+find_package(PythonInterp REQUIRED)
+
# ==============================================================================
# File Management
# Define the source files and resources of the BIM workbench
@@ -56,6 +58,8 @@ SET(Arch_SRCS
BimStatus.py
TestArch.py
TestArchGui.py
+ ArchReport.py
+ ArchSql.py
)
SET(importers_SRCS
@@ -96,6 +100,11 @@ SET(Arch_presets
Presets/ifc_contexts_IFC2X3.json
Presets/ifc_contexts_IFC4.json
Presets/properties_conversion.csv
+ Presets/ArchReport/QueryPresets/all_spaces.json
+ Presets/ArchReport/QueryPresets/all_walls.json
+ Presets/ArchReport/QueryPresets/count_by_ifc_class.json
+ Presets/ArchReport/ReportPresets/room_schedule.json
+ Presets/ArchReport/ReportPresets/wall_quantities.json
)
SET(bimcommands_SRCS
@@ -179,6 +188,7 @@ SET(bimcommands_SRCS
bimcommands/BimWindow.py
bimcommands/BimWindows.py
bimcommands/BimWPCommands.py
+ bimcommands/BimReport.py
bimcommands/__init__.py
)
@@ -242,10 +252,14 @@ SET(bimtests_SRCS
bimtests/TestWebGLExportGui.py
bimtests/TestArchImportersGui.py
bimtests/TestArchBuildingPartGui.py
+ bimtests/TestArchReport.py
+ bimtests/TestArchReportGui.py
)
set(bimtests_FIXTURES
bimtests/fixtures/FC_site_simple-102.FCStd
+ bimtests/fixtures/__init__.py
+ bimtests/fixtures/BimFixtures.py
)
SET(BIMGuiIcon_SVG
@@ -260,6 +274,23 @@ SET(ImportersSample_Files
# - ImportersSample_Files should probably be merged into bimtests_FIXTURES
# - BIM_templates should probably be merged into Arch_presets
+SET(ArchSql_resources
+ Resources/ArchSql.lark
+ Resources/ArchSqlParserGenerator.py
+)
+
+set(BIM_LARK_GRAMMAR ${CMAKE_CURRENT_SOURCE_DIR}/Resources/ArchSql.lark)
+set(BIM_PARSER_GENERATOR ${CMAKE_CURRENT_SOURCE_DIR}/Resources/ArchSqlParserGenerator.py)
+
+set(BIM_GENERATED_PARSER ${CMAKE_BINARY_DIR}/Mod/BIM/generated_sql_parser.py)
+
+add_custom_command(
+ OUTPUT ${BIM_GENERATED_PARSER}
+ COMMAND ${PYTHON_EXECUTABLE} ${BIM_PARSER_GENERATOR} ${BIM_LARK_GRAMMAR} ${BIM_GENERATED_PARSER}
+ DEPENDS ${BIM_LARK_GRAMMAR} ${BIM_PARSER_GENERATOR}
+ COMMENT "Generating Arch SQL parser..."
+)
+
# ==============================================================================
# Developer workflow
# This populates the build tree using the file lists from the Install Manifest
@@ -304,6 +335,8 @@ ADD_CUSTOM_TARGET(BIM ALL
${BIM_templates}
)
+add_custom_target(ArchSqlParser ALL DEPENDS ${BIM_GENERATED_PARSER})
+
# Populate the build tree with the BIM workbench sources and test data
fc_copy_sources(BIM "${CMAKE_BINARY_DIR}/Mod/BIM" ${Arch_SRCS})
fc_copy_sources(BIM "${CMAKE_BINARY_DIR}/Mod/BIM" ${Dice3DS_SRCS})
@@ -318,6 +351,7 @@ fc_copy_sources(BIM "${CMAKE_BINARY_DIR}/Mod/BIM" ${nativeifc_SRCS})
fc_copy_sources(BIM "${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_DATADIR}/Mod/BIM" ${BIMGuiIcon_SVG})
fc_copy_sources(BIM "${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_DATADIR}/Mod/BIM" ${Arch_presets})
fc_copy_sources(BIM "${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_DATADIR}/Mod/BIM" ${BIM_templates})
+fc_copy_sources(BIM "${CMAKE_SOURCE_DIR}/${CMAKE_INSTALL_DATADIR}/Mod/BIM" ${ArchSql_resources})
# For generated resources, we cannot rely on `fc_copy_sources` in case
# INSTALL_PREFER_SYMLINKS=ON has been specified, since we're generating a new
@@ -398,3 +432,10 @@ INSTALL(
DESTINATION
"${CMAKE_INSTALL_DATADIR}/Mod/BIM/Resources/templates"
)
+
+INSTALL(
+ FILES
+ ${BIM_GENERATED_PARSER}
+ DESTINATION
+ Mod/BIM
+)
diff --git a/src/Mod/BIM/InitGui.py b/src/Mod/BIM/InitGui.py
index 4af5ae5c3d..7fae6fb8bf 100644
--- a/src/Mod/BIM/InitGui.py
+++ b/src/Mod/BIM/InitGui.py
@@ -200,6 +200,7 @@ class BIMWorkbench(Workbench):
"BIM_Layers",
"BIM_Material",
"Arch_Schedule",
+ "BIM_Report",
"BIM_Preflight",
"Draft_AnnotationStyleEditor",
]
diff --git a/src/Mod/BIM/Presets/ArchReport/QueryPresets/all_spaces.json b/src/Mod/BIM/Presets/ArchReport/QueryPresets/all_spaces.json
new file mode 100644
index 0000000000..1ecfe1147b
--- /dev/null
+++ b/src/Mod/BIM/Presets/ArchReport/QueryPresets/all_spaces.json
@@ -0,0 +1,5 @@
+{
+ "name": "All Spaces",
+ "description": "Selects all Space objects in the document.",
+ "query": "SELECT * FROM document WHERE IfcType = 'Space'"
+}
diff --git a/src/Mod/BIM/Presets/ArchReport/QueryPresets/all_walls.json b/src/Mod/BIM/Presets/ArchReport/QueryPresets/all_walls.json
new file mode 100644
index 0000000000..fbd939e3be
--- /dev/null
+++ b/src/Mod/BIM/Presets/ArchReport/QueryPresets/all_walls.json
@@ -0,0 +1,5 @@
+{
+ "name": "All Walls",
+ "description": "Selects all Wall objects in the document.",
+ "query": "SELECT * FROM document WHERE IfcType = 'Wall'"
+}
diff --git a/src/Mod/BIM/Presets/ArchReport/QueryPresets/count_by_ifc_class.json b/src/Mod/BIM/Presets/ArchReport/QueryPresets/count_by_ifc_class.json
new file mode 100644
index 0000000000..fb76cff03d
--- /dev/null
+++ b/src/Mod/BIM/Presets/ArchReport/QueryPresets/count_by_ifc_class.json
@@ -0,0 +1,5 @@
+{
+ "name": "Count by IfcType",
+ "description": "Counts all objects, grouped by their IfcType.",
+ "query": "SELECT IfcType, COUNT(*) FROM document GROUP BY IfcType"
+}
diff --git a/src/Mod/BIM/Presets/ArchReport/ReportPresets/room_schedule.json b/src/Mod/BIM/Presets/ArchReport/ReportPresets/room_schedule.json
new file mode 100644
index 0000000000..a16c130f80
--- /dev/null
+++ b/src/Mod/BIM/Presets/ArchReport/ReportPresets/room_schedule.json
@@ -0,0 +1,24 @@
+{
+ "name": "Room and Area Schedule",
+ "description": "A standard schedule listing all rooms and their areas, with a final total.",
+ "statements": [
+ {
+ "description": "Room List",
+ "query_string": "SELECT Label, Area FROM document WHERE IfcType = 'Space'",
+ "use_description_as_header": true,
+ "include_column_names": true,
+ "add_empty_row_after": false,
+ "print_results_in_bold": false,
+ "is_pipelined": false
+ },
+ {
+ "description": "Total Living Area",
+ "query_string": "SELECT 'Total', SUM(Area) FROM document WHERE IfcType = 'Space'",
+ "use_description_as_header": false,
+ "include_column_names": false,
+ "add_empty_row_after": false,
+ "print_results_in_bold": true,
+ "is_pipelined": false
+ }
+ ]
+}
diff --git a/src/Mod/BIM/Presets/ArchReport/ReportPresets/wall_quantities.json b/src/Mod/BIM/Presets/ArchReport/ReportPresets/wall_quantities.json
new file mode 100644
index 0000000000..1ab0cd4b87
--- /dev/null
+++ b/src/Mod/BIM/Presets/ArchReport/ReportPresets/wall_quantities.json
@@ -0,0 +1,24 @@
+{
+ "name": "Wall Quantities",
+ "description": "A schedule that lists all walls and calculates their total length.",
+ "statements": [
+ {
+ "description": "Wall List",
+ "query_string": "SELECT Label, Length, Height, Width FROM document WHERE IfcType = 'Wall'",
+ "use_description_as_header": true,
+ "include_column_names": true,
+ "add_empty_row_after": true,
+ "print_results_in_bold": false,
+ "is_pipelined": false
+ },
+ {
+ "description": "Total Wall Length",
+ "query_string": "SELECT 'Total Length', SUM(Length) FROM document WHERE IfcType = 'Wall'",
+ "use_description_as_header": false,
+ "include_column_names": false,
+ "add_empty_row_after": false,
+ "print_results_in_bold": true,
+ "is_pipelined": false
+ }
+ ]
+}
diff --git a/src/Mod/BIM/Resources/Arch.qrc b/src/Mod/BIM/Resources/Arch.qrc
index b9290b673c..e7fcefb26e 100644
--- a/src/Mod/BIM/Resources/Arch.qrc
+++ b/src/Mod/BIM/Resources/Arch.qrc
@@ -125,6 +125,7 @@
icons/BIM_ProjectManager.svgicons/BIM_Reextrude.svgicons/BIM_Reorder.svg
+ icons/BIM_Report.svgicons/BIM_ResetCloneColors.svgicons/BIM_Rewire.svgicons/BIM_Slab.svg
diff --git a/src/Mod/BIM/Resources/ArchSql.lark b/src/Mod/BIM/Resources/ArchSql.lark
new file mode 100644
index 0000000000..bc1f2abb74
--- /dev/null
+++ b/src/Mod/BIM/Resources/ArchSql.lark
@@ -0,0 +1,116 @@
+// SPDX-License-Identifier: MIT
+//
+// Copyright (c) 2019 Daniel Furtlehner (furti)
+// Copyright (c) 2025 The FreeCAD Project
+//
+// This file is a derivative work of the sql_grammar.peg file from the
+// FreeCAD-Reporting workbench (https://github.com/furti/FreeCAD-Reporting).
+// As per the terms of the original MIT license, this derivative work is also
+// licensed under the MIT license.
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, to be subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+// Main Rule
+start: statement
+statement: SELECT columns from_clause where_clause? group_by_clause? order_by_clause? ";"*
+
+// Clauses
+// Allow FROM to accept either a simple reference (e.g., 'document')
+// or a function invocation that returns a set (e.g., CHILDREN(SELECT ...)).
+from_clause: FROM from_source
+from_source: CNAME | from_function
+from_function: CNAME "(" statement ("," operand)? ")"
+where_clause: WHERE boolean_expression
+group_by_clause: GROUP BY member_access ("," member_access)*
+order_by_clause: ORDER BY member_access ("," member_access)* (ASC | DESC)?
+columns: (column ("," column)*)
+column: (ASTERISK | operand) as_clause?
+as_clause: AS (literal | CNAME)
+// Arithmetic Expression Parsing with Operator Precedence
+expr: term (ADD term | SUB term)* // Lowest precedence: Addition/Subtraction
+term: member_access (MUL member_access | DIV member_access)* // Next precedence: Multiplication/Division
+member_access: primary ("." (CNAME | function))* // Explicitly allow a function after a dot
+primary: function | CNAME | literal | NUMBER | NULL | "(" expr ")" // The fundamental building blocks
+
+// 'operand' is an alias for a full arithmetic expression.
+operand: expr
+
+// Boolean Logic
+boolean_expression: boolean_or
+boolean_or: boolean_or OR boolean_and -> boolean_expression_recursive
+ | boolean_and
+boolean_and: boolean_and AND boolean_term -> boolean_expression_recursive
+ | boolean_term
+boolean_term: boolean_comparison | in_expression | "(" boolean_expression ")"
+boolean_comparison: operand comparison_operator operand
+in_expression: member_access IN "(" literal ("," literal)* ")"
+
+// Comparison Operator
+comparison_operator: eq_op | neq_op | gt_op | lt_op | gte_op | lte_op | is_not_op | is_op | like_op
+eq_op: "="
+neq_op: "!="
+gt_op: ">"
+lt_op: "<"
+gte_op: ">="
+lte_op: "<="
+is_not_op: IS NOT
+is_op: IS
+like_op: LIKE
+
+// Basic Tokens
+literal: STRING
+
+// Functions
+function: CNAME "(" function_args? ")"
+function_args: ASTERISK | (operand ("," operand)*)
+
+// Terminal Definitions
+SELECT: "SELECT"i
+FROM: "FROM"i
+WHERE: "WHERE"i
+AS: "AS"i
+OR: "OR"i
+AND: "AND"i
+IS: "IS"i
+NOT: "NOT"i
+IN: "IN"i
+LIKE: "LIKE"i
+GROUP: "GROUP"i
+BY: "BY"i
+ORDER: "ORDER"i
+ASC: "ASC"i
+DESC: "DESC"i
+ADD: "+"
+SUB: "-"
+MUL: "*"
+DIV: "/"
+ASTERISK.2: "*" // Higher priority
+NULL.2: "NULL"i
+STRING : /"[^"]*"|'[^']*'/
+CNAME: /[a-zA-Z_][\w\.]*/ // An identifier cannot start with a digit, allows unicode via \w
+NUMBER.2: /[0-9]+(\.[0-9]+)?/ // Higher priority
+%import common.WS
+
+// Define comment terminals. The regex for multi-line is non-greedy.
+SINGLE_LINE_COMMENT: /--[^\n]*/
+MULTI_LINE_COMMENT: /\/\*[\s\S]*?\*\//
+
+%ignore WS
+%ignore SINGLE_LINE_COMMENT
+%ignore MULTI_LINE_COMMENT
diff --git a/src/Mod/BIM/Resources/ArchSqlParserGenerator.py b/src/Mod/BIM/Resources/ArchSqlParserGenerator.py
new file mode 100644
index 0000000000..d89924c4d7
--- /dev/null
+++ b/src/Mod/BIM/Resources/ArchSqlParserGenerator.py
@@ -0,0 +1,52 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+#
+# Copyright (c) 2025 The FreeCAD Project
+
+"""This script generates a standalone Python parser from the ArchSql.lark grammar."""
+
+import sys
+import os
+
+try:
+ from lark import Lark
+ from lark.tools.standalone import gen_standalone
+except ImportError:
+ print("Error: The 'lark' Python package is required to generate the parser.")
+ print("Please install it using: pip install lark")
+ sys.exit(1)
+
+
+def main():
+ if len(sys.argv) != 3:
+ print("Usage: python ArchSqlParserGenerator.py ")
+ return 1
+
+ input_file = sys.argv[1]
+ output_file = sys.argv[2]
+
+ if not os.path.exists(input_file):
+ print(f"Error: Input grammar file not found at '{input_file}'")
+ return 1
+
+ print(
+ f"Generating standalone parser from '{os.path.basename(input_file)}' to '{os.path.basename(output_file)}'..."
+ )
+
+ # 1. Read the grammar file content.
+ with open(input_file, "r", encoding="utf8") as f:
+ grammar_text = f.read()
+
+ # 2. Create an instance of the Lark parser.
+ # The 'lalr' parser is recommended for performance.
+ lark_instance = Lark(grammar_text, parser="lalr")
+
+ # 3. Open the output file and call the gen_standalone() API function.
+ with open(output_file, "w", encoding="utf8") as f:
+ gen_standalone(lark_instance, out=f)
+
+ print("Parser generation complete.")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/src/Mod/BIM/Resources/icons/BIM_Report.svg b/src/Mod/BIM/Resources/icons/BIM_Report.svg
new file mode 100644
index 0000000000..d190f2766f
--- /dev/null
+++ b/src/Mod/BIM/Resources/icons/BIM_Report.svg
@@ -0,0 +1,244 @@
+
+
diff --git a/src/Mod/BIM/TestArch.py b/src/Mod/BIM/TestArch.py
index 9e2f4cc038..a054f01cd2 100644
--- a/src/Mod/BIM/TestArch.py
+++ b/src/Mod/BIM/TestArch.py
@@ -48,3 +48,4 @@ from bimtests.TestArchSchedule import TestArchSchedule
from bimtests.TestArchTruss import TestArchTruss
from bimtests.TestArchComponent import TestArchComponent
from bimtests.TestWebGLExport import TestWebGLExport
+from bimtests.TestArchReport import TestArchReport
diff --git a/src/Mod/BIM/TestArchGui.py b/src/Mod/BIM/TestArchGui.py
index e4de71f012..f878c12c2d 100644
--- a/src/Mod/BIM/TestArchGui.py
+++ b/src/Mod/BIM/TestArchGui.py
@@ -27,4 +27,5 @@
from bimtests.TestArchImportersGui import TestArchImportersGui
from bimtests.TestArchBuildingPartGui import TestArchBuildingPartGui
from bimtests.TestArchSiteGui import TestArchSiteGui
+from bimtests.TestArchReportGui import TestArchReportGui
from bimtests.TestWebGLExportGui import TestWebGLExportGui
diff --git a/src/Mod/BIM/bimcommands/BimReport.py b/src/Mod/BIM/bimcommands/BimReport.py
new file mode 100644
index 0000000000..a3d85c5c3b
--- /dev/null
+++ b/src/Mod/BIM/bimcommands/BimReport.py
@@ -0,0 +1,27 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+#
+# Copyright (c) 2025 The FreeCAD Project
+
+import FreeCAD
+import FreeCADGui
+
+
+class BIM_Report:
+ """The command to create a new BIM Report object."""
+
+ def GetResources(self):
+ return {
+ "Pixmap": "BIM_Report",
+ "MenuText": "BIM Report",
+ "ToolTip": "Create a new BIM Report to query model data with SQL",
+ }
+
+ def Activated(self):
+ FreeCADGui.addModule("Arch")
+ FreeCADGui.doCommand("Arch.makeReport()")
+
+ def IsActive(self):
+ return FreeCAD.ActiveDocument is not None
+
+
+FreeCADGui.addCommand("BIM_Report", BIM_Report())
diff --git a/src/Mod/BIM/bimtests/TestArchBase.py b/src/Mod/BIM/bimtests/TestArchBase.py
index d73be3bce2..400f4f322f 100644
--- a/src/Mod/BIM/bimtests/TestArchBase.py
+++ b/src/Mod/BIM/bimtests/TestArchBase.py
@@ -70,3 +70,37 @@ class TestArchBase(unittest.TestCase):
passed as the prepend_text argument
"""
FreeCAD.Console.PrintMessage(prepend_text + text + end)
+
+ def assertDictContainsSubset(self, subset, main_dict):
+ """Asserts that one dictionary's key-value pairs are contained within another.
+
+ This method iterates through each key-value pair in the `subset`
+ dictionary and verifies two conditions against the `main_dict`:
+ 1. The key exists in `main_dict`.
+ 2. The value associated with that key in `main_dict` is equal to the
+ value in `subset`.
+
+ Use Case:
+ This assertion is more flexible than `assertDictEqual`. It is ideal for
+ tests where a function or query returns a large dictionary of results,
+ but the test's scope is only to validate a specific, known subset of
+ those results. It allows the test to succeed even if `main_dict`
+ contains extra, irrelevant keys, thus preventing test brittleness.
+
+ Parameters
+ ----------
+ subset : dict
+ The dictionary containing the expected key-value pairs that must
+ be found.
+ main_dict : dict
+ The larger, actual dictionary returned by the code under test,
+ which is checked for the presence of the subset.
+
+ Example:
+ >>> actual_results = {'Wall': 4, 'Structure': 2, 'Window': 1}
+ >>> expected_subset = {'Wall': 4, 'Window': 1}
+ >>> self.assertDictContainsSubset(expected_subset, actual_results) # This will pass.
+ """
+ for key, value in subset.items():
+ self.assertIn(key, main_dict)
+ self.assertEqual(main_dict[key], value)
diff --git a/src/Mod/BIM/bimtests/TestArchReport.py b/src/Mod/BIM/bimtests/TestArchReport.py
new file mode 100644
index 0000000000..c9103b46c6
--- /dev/null
+++ b/src/Mod/BIM/bimtests/TestArchReport.py
@@ -0,0 +1,2113 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+#
+# Copyright (c) 2025 The FreeCAD Project
+
+"""Unit tests for the ArchReport and ArchSql modules."""
+import FreeCAD
+import Arch
+import Draft
+import ArchSql
+import ArchReport
+from unittest.mock import patch
+from bimtests import TestArchBase
+from bimtests.fixtures.BimFixtures import create_test_model
+
+
+class TestArchReport(TestArchBase.TestArchBase):
+
+ def setUp(self):
+ super().setUp()
+ self.doc = self.document
+
+ self.wall_ext = Arch.makeWall(length=1000, name="Exterior Wall")
+ self.wall_ext.IfcType = "Wall"
+ self.wall_ext.Height = FreeCAD.Units.Quantity(
+ 3000, "mm"
+ ) # Store as Quantity for robust comparison
+
+ self.wall_int = Arch.makeWall(length=500, name="Interior partition wall")
+ self.wall_int.IfcType = "Wall"
+ self.wall_int.Height = FreeCAD.Units.Quantity(2500, "mm") # Store as Quantity
+
+ self.column = Arch.makeStructure(length=300, width=330, height=2000, name="Main Column")
+ self.column.IfcType = "Column"
+
+ self.beam = Arch.makeStructure(length=2000, width=200, height=400, name="Main Beam")
+ self.beam.IfcType = "Beam"
+
+ self.window = Arch.makeWindow(name="Living Room Window")
+ self.window.IfcType = "Window"
+
+ self.part_box = self.doc.addObject(
+ "Part::Box", "Generic Box"
+ ) # This object has no IfcType property
+
+ # Define a clean list of *only* the objects created by the test setUp
+ self.test_objects_in_doc = [
+ self.wall_ext,
+ self.wall_int,
+ self.column,
+ self.beam,
+ self.window,
+ self.part_box,
+ ]
+ self.test_object_labels = sorted([o.Label for o in self.test_objects_in_doc])
+
+ # We create the spreadsheet here, but it's part of the test setup, not a queryable object
+ self.spreadsheet = self.doc.addObject("Spreadsheet::Sheet", "ReportTarget")
+ self.doc.recompute()
+
+ def _run_query_for_objects(self, query_string):
+ """
+ Helper method to run a query using the public API and return filtered results.
+ This version is simplified to directly use Arch.select(), avoiding the
+ creation of a Report object and thus preventing the "still touched" error.
+ """
+ # Directly use the public API to execute the read-only query.
+ # This does not modify any objects in the document.
+ try:
+ headers, results_data_from_sql = Arch.select(query_string)
+ except (ArchSql.BimSqlSyntaxError, ArchSql.SqlEngineError) as e:
+ self.fail(f"The query '{query_string}' failed to execute with an exception: {e}")
+
+ self.assertIsInstance(headers, list, f"Headers should be a list for: {query_string}")
+ self.assertIsInstance(
+ results_data_from_sql, list, f"Results data should be a list for: {query_string}"
+ )
+
+ # For aggregate queries (e.g., containing COUNT, GROUP BY), the results are summaries,
+ # not direct object properties. The filtering logic below does not apply.
+ is_aggregate_query = any(
+ agg in h for h in headers for agg in ["COUNT", "SUM", "MIN", "MAX"]
+ )
+ if is_aggregate_query:
+ return headers, results_data_from_sql
+
+ # If SELECT *, results_data_from_sql is a list of lists, e.g., [['Exterior Wall'], ...].
+ # Extract a flat list of labels for easier assertion.
+ if headers == ["Object Label"]:
+ extracted_labels = [row[0] for row in results_data_from_sql]
+ # Filter against our defined test objects only.
+ filtered_labels = [
+ label for label in extracted_labels if label in self.test_object_labels
+ ]
+ return headers, filtered_labels
+
+ # For specific column selections, results_data_from_sql is a list of lists of values.
+ # Filter these rows based on whether their first element (assumed to be the label)
+ # is one of our test objects.
+ filtered_results_for_specific_columns = []
+ if results_data_from_sql and len(results_data_from_sql[0]) > 0:
+ for row in results_data_from_sql:
+ if row[0] in self.test_object_labels:
+ filtered_results_for_specific_columns.append(row)
+
+ return headers, filtered_results_for_specific_columns
+
+ # Category 1: Basic Object Creation and Validation
+ def test_makeReport_default(self):
+ report = Arch.makeReport()
+ self.assertIsNotNone(report, "makeReport failed to create an object.")
+ self.assertEqual(report.Label, "Report", "Default report label is incorrect.")
+
+ def test_report_properties(self):
+ report = Arch.makeReport()
+ self.assertTrue(
+ hasattr(report, "Statements"), "Report object is missing 'Statements' property."
+ )
+ self.assertTrue(hasattr(report, "Target"), "Report object is missing 'Target' property.")
+
+ # Category 2: Core SELECT Functionality
+ def test_select_all_from_document(self):
+ """Test a 'SELECT * FROM document' query."""
+ headers, results_labels = self._run_query_for_objects("SELECT * FROM document")
+
+ self.assertEqual(headers, ["Object Label"])
+ self.assertCountEqual(
+ results_labels, self.test_object_labels, "Should find all queryable objects."
+ )
+
+ def test_select_specific_columns_from_document(self):
+ """Test a 'SELECT Label, IfcType, Height FROM document' query."""
+ query_string = 'SELECT Label, IfcType, Height FROM document WHERE IfcType = "Wall"'
+ headers, results_data = self._run_query_for_objects(query_string)
+
+ self.assertEqual(headers, ["Label", "IfcType", "Height"])
+ self.assertEqual(len(results_data), 2)
+
+ expected_rows = [
+ ["Exterior Wall", "Wall", self.wall_ext.Height],
+ ["Interior partition wall", "Wall", self.wall_int.Height],
+ ]
+ self.assertCountEqual(results_data, expected_rows, "Specific column data mismatch.")
+
+ # Category 3: WHERE Clause Filtering
+ def test_where_equals_string(self):
+ _, results_labels = self._run_query_for_objects(
+ 'SELECT * FROM document WHERE IfcType = "Wall"'
+ )
+ self.assertEqual(len(results_labels), 2)
+ self.assertCountEqual(results_labels, [self.wall_ext.Label, self.wall_int.Label])
+
+ def test_where_not_equals_string(self):
+ """Test a WHERE clause with a not-equals check."""
+ _, results_labels = self._run_query_for_objects(
+ 'SELECT * FROM document WHERE IfcType != "Wall"'
+ )
+ # Strict SQL semantics: comparisons with NULL are treated as UNKNOWN
+ # and therefore excluded. Use IS NULL / IS NOT NULL to test for nulls.
+ expected_labels = [self.column.Label, self.beam.Label, self.window.Label]
+ self.assertEqual(len(results_labels), 3)
+ self.assertCountEqual(results_labels, expected_labels)
+
+ def test_where_is_null(self):
+ """Test a WHERE clause with an IS NULL check."""
+ _, results_labels = self._run_query_for_objects(
+ "SELECT * FROM document WHERE IfcType IS NULL"
+ )
+ # This expects only self.part_box as it's the only one in self.test_objects_in_doc with IfcType=None.
+ self.assertEqual(len(results_labels), 1)
+ self.assertEqual(results_labels[0], self.part_box.Label)
+
+ def test_where_is_not_null(self):
+ _, results_labels = self._run_query_for_objects(
+ "SELECT * FROM document WHERE IfcType IS NOT NULL"
+ )
+ self.assertEqual(len(results_labels), 5)
+ self.assertNotIn(self.part_box.Label, results_labels)
+
+ def test_where_like_case_insensitive(self):
+ _, results_labels = self._run_query_for_objects(
+ 'SELECT * FROM document WHERE Label LIKE "exterior wall"'
+ )
+ self.assertEqual(len(results_labels), 1)
+ self.assertEqual(results_labels[0], self.wall_ext.Label)
+
+ def test_where_like_wildcard_middle(self):
+ _, results_labels = self._run_query_for_objects(
+ 'SELECT * FROM document WHERE Label LIKE "%wall%"'
+ )
+ self.assertEqual(len(results_labels), 2)
+ self.assertCountEqual(results_labels, [self.wall_ext.Label, self.wall_int.Label])
+
+ def test_null_equality_is_excluded(self):
+ """Strict SQL: comparisons with NULL should be excluded; use IS NULL."""
+ _, results = self._run_query_for_objects("SELECT * FROM document WHERE IfcType = NULL")
+ # '=' with NULL should not match (UNKNOWN -> excluded)
+ self.assertEqual(len(results), 0)
+
+ def test_null_inequality_excludes_nulls(self):
+ """Strict SQL: IfcType != 'Wall' should exclude rows where IfcType is NULL."""
+ _, results_labels = self._run_query_for_objects(
+ 'SELECT * FROM document WHERE IfcType != "Wall"'
+ )
+ expected_labels = [self.column.Label, self.beam.Label, self.window.Label]
+ self.assertCountEqual(results_labels, expected_labels)
+
+ def test_is_null_and_is_not_null_behaviour(self):
+ _, isnull_labels = self._run_query_for_objects(
+ "SELECT * FROM document WHERE IfcType IS NULL"
+ )
+ self.assertIn(self.part_box.Label, isnull_labels)
+
+ _, isnotnull_labels = self._run_query_for_objects(
+ "SELECT * FROM document WHERE IfcType IS NOT NULL"
+ )
+ self.assertNotIn(self.part_box.Label, isnotnull_labels)
+
+ def test_where_like_wildcard_end(self):
+ _, results_labels = self._run_query_for_objects(
+ 'SELECT * FROM document WHERE Label LIKE "Exterior%"'
+ )
+ self.assertEqual(len(results_labels), 1)
+ self.assertEqual(results_labels[0], self.wall_ext.Label)
+
+ def test_where_boolean_and(self):
+ query = 'SELECT * FROM document WHERE IfcType = "Wall" AND Label LIKE "%Exterior%"'
+ _, results_labels = self._run_query_for_objects(query)
+ self.assertEqual(len(results_labels), 1)
+ self.assertEqual(results_labels[0], self.wall_ext.Label)
+
+ def test_where_boolean_or(self):
+ query = 'SELECT * FROM document WHERE IfcType = "Window" OR IfcType = "Column"'
+ _, results_labels = self._run_query_for_objects(query)
+ self.assertEqual(len(results_labels), 2)
+ self.assertCountEqual(results_labels, [self.window.Label, self.column.Label])
+
+ # Category 4: Edge Cases and Error Handling
+ def test_query_no_results(self):
+ _, results_labels = self._run_query_for_objects(
+ 'SELECT * FROM document WHERE Label = "NonExistentObject"'
+ )
+ self.assertEqual(len(results_labels), 0)
+
+ @patch("FreeCAD.Console.PrintError")
+ def test_query_invalid_syntax(self, mock_print_error):
+ # The low-level function now raises an exception on failure.
+ with self.assertRaises(Arch.BimSqlSyntaxError) as cm:
+ Arch.select("SELECT FROM document WHERE")
+ self.assertFalse(
+ cm.exception.is_incomplete, "A syntax error should not be marked as incomplete."
+ )
+
+ # The high-level function for the UI catches it and returns a simple string.
+ count, error_str = Arch.count("SELECT FROM document WHERE")
+ self.assertEqual(count, -1)
+ self.assertIsInstance(error_str, str)
+ self.assertIn("Syntax Error", error_str)
+
+ def test_incomplete_queries_are_handled_gracefully(self):
+ incomplete_queries = [
+ "SELECT",
+ "SELECT *",
+ "SELECT * FROM",
+ "SELECT * FROM document WHERE",
+ "SELECT * FROM document WHERE Label =",
+ "SELECT * FROM document WHERE Label LIKE",
+ 'SELECT * FROM document WHERE Label like "%wa', # Test case for incomplete string literal
+ ]
+
+ for query in incomplete_queries:
+ with self.subTest(query=query):
+ count, error = Arch.count(query)
+ self.assertEqual(
+ error, "INCOMPLETE", f"Query '{query}' should be marked as INCOMPLETE."
+ )
+
+ def test_invalid_partial_tokens_are_errors(self):
+ invalid_queries = {
+ "Mistyped keyword": "SELECT * FRM document",
+ }
+
+ for name, query in invalid_queries.items():
+ with self.subTest(name=name, query=query):
+ _, error = Arch.count(query)
+ self.assertNotEqual(
+ error,
+ "INCOMPLETE",
+ f"Query '{query}' should be a syntax error, not incomplete.",
+ )
+ self.assertIsNotNone(error, f"Query '{query}' should have returned an error.")
+
+ def test_report_no_target(self):
+ try:
+ report = Arch.makeReport()
+ # Creation initializes a target spreadsheet; verify it's set
+ self.assertIsNotNone(report.Target, "Report Target should be set on creation.")
+ # Set the first statement's query string
+ # Prefer operating on the proxy runtime objects when available
+ if hasattr(report, "Proxy"):
+ # Ensure live statements are hydrated from persisted storage
+ report.Proxy.hydrate_live_statements(report)
+
+ if not getattr(report.Proxy, "live_statements", None):
+ # No live statements: create a persisted starter and hydrate again
+ report.Statements = [
+ ArchReport.ReportStatement(
+ description="Statement 1", query_string="SELECT * FROM document"
+ ).dumps()
+ ]
+ report.Proxy.hydrate_live_statements(report)
+ else:
+ report.Proxy.live_statements[0].query_string = "SELECT * FROM document"
+ report.Proxy.commit_statements()
+ else:
+ # Fallback for environments without a proxy: persist a dict
+ if not hasattr(report, "Statements") or not report.Statements:
+ report.Statements = [
+ ArchReport.ReportStatement(
+ description="Statement 1", query_string="SELECT * FROM document"
+ ).dumps()
+ ]
+ else:
+ # Persist a fresh statement dict in the fallback path
+ report.Statements = [
+ ArchReport.ReportStatement(
+ description="Statement 1", query_string="SELECT * FROM document"
+ ).dumps()
+ ]
+ self.doc.recompute()
+ except Exception as e:
+ self.fail(f"Recomputing a report with no Target raised an unexpected exception: {e}")
+
+ # UX: when the report runs without a pre-set Target, it should create
+ # a spreadsheet, set the sheet.ReportName, and persist the Target link
+ # so subsequent runs are deterministic.
+ self.assertIsNotNone(
+ report.Target, "Report Target should be set after running with no pre-existing Target."
+ )
+ self.assertEqual(getattr(report.Target, "ReportName", None), report.Name)
+
+ def test_group_by_ifctype_with_count(self):
+ """Test GROUP BY with COUNT(*) to summarize objects by type."""
+ # Add a WHERE clause to exclude test scaffolding objects from the count.
+ query = (
+ "SELECT IfcType, COUNT(*) FROM document "
+ "WHERE TypeId != 'App::FeaturePython' AND TypeId != 'Spreadsheet::Sheet' "
+ "GROUP BY IfcType"
+ )
+ headers, results_data = self._run_query_for_objects(query)
+
+ self.assertEqual(headers, ["IfcType", "COUNT(*)"])
+
+ # Convert results to a dict for easy, order-independent comparison.
+ # Handle the case where IfcType is None, which becomes a key in the dict.
+ results_dict = {row[0] if row[0] != "None" else None: int(row[1]) for row in results_data}
+
+ expected_counts = {
+ "Wall": 2,
+ "Column": 1,
+ "Beam": 1,
+ "Window": 1,
+ None: 1, # The Part::Box has a NULL IfcType, which forms its own group
+ }
+ self.assertDictEqual(
+ results_dict, expected_counts, "The object counts per IfcType are incorrect."
+ )
+
+ def test_count_all_without_group_by(self):
+ """Test COUNT(*) on the whole dataset without grouping."""
+ # Add a WHERE clause to exclude test scaffolding objects from the count.
+ query = (
+ "SELECT COUNT(*) FROM document "
+ "WHERE TypeId != 'App::FeaturePython' AND TypeId != 'Spreadsheet::Sheet'"
+ )
+ headers, results_data = self._run_query_for_objects(query)
+
+ self.assertEqual(headers, ["COUNT(*)"])
+ self.assertEqual(len(results_data), 1, "Non-grouped aggregate should return a single row.")
+ self.assertEqual(
+ int(results_data[0][0]),
+ len(self.test_objects_in_doc),
+ "COUNT(*) did not return the total number of test objects.",
+ )
+
+ def test_group_by_with_sum(self):
+ """Test GROUP BY with SUM() on a numeric property."""
+ # Note: We filter for objects that are likely to have a Height property to get a clean sum.
+ query = (
+ "SELECT IfcType, SUM(Height) FROM document "
+ "WHERE IfcType = 'Wall' OR IfcType = 'Column' "
+ "GROUP BY IfcType"
+ )
+ headers, results_data = self._run_query_for_objects(query)
+
+ self.assertEqual(headers, ["IfcType", "SUM(Height)"])
+ results_dict = {row[0]: float(row[1]) for row in results_data}
+
+ # Expected sums:
+ # Walls: Exterior (3000) + Interior (2500) = 5500
+ # Columns: Main Column (2000)
+ expected_sums = {
+ "Wall": 5500.0,
+ "Column": 2000.0,
+ }
+ self.assertDictEqual(results_dict, expected_sums)
+ self.assertNotIn("Window", results_dict, "Groups excluded by WHERE should not appear.")
+
+ def test_min_and_max_functions(self):
+ """Test MIN() and MAX() functions on a numeric property."""
+ query = "SELECT MIN(Length), MAX(Length) FROM document WHERE IfcType = 'Wall'"
+ headers, results_data = self._run_query_for_objects(query)
+
+ self.assertEqual(headers, ["MIN(Length)", "MAX(Length)"])
+ self.assertEqual(
+ len(results_data), 1, "Aggregate query without GROUP BY should return one row."
+ )
+
+ # Expected: Interior wall is 500, Exterior wall is 1000
+ min_length = float(results_data[0][0])
+ max_length = float(results_data[0][1])
+
+ self.assertAlmostEqual(min_length, 500.0)
+ self.assertAlmostEqual(max_length, 1000.0)
+
+ def test_invalid_group_by_raises_error(self):
+ """A SELECT column not in GROUP BY and not in an aggregate should fail validation."""
+ # 'Label' is not aggregated and not in the 'GROUP BY' clause, making this query invalid.
+ query = "SELECT Label, COUNT(*) FROM document GROUP BY IfcType"
+
+ # _run_query should raise an exception for validation errors.
+ with self.assertRaises(ArchSql.SqlEngineError) as cm:
+ Arch.select(query)
+
+ # Check for the specific, user-friendly error message within the exception.
+ self.assertIn(
+ "must appear in the GROUP BY clause",
+ str(cm.exception),
+ "The validation error message is not descriptive enough.",
+ )
+
+ def test_non_grouped_sum_calculates_correctly(self):
+ """
+ Tests the SUM() aggregate function without a GROUP BY clause in isolation.
+ This test calls the SQL engine directly to ensure the summing logic is correct.
+ """
+ # The query sums the Height of the two wall objects created in setUp().
+ # Expected result: 3000mm + 2500mm = 5500mm.
+ query = "SELECT SUM(Height) FROM document WHERE IfcType = 'Wall'"
+
+ # We call the engine directly, bypassing the _run_query_for_objects helper.
+ _, results_data = Arch.select(query)
+
+ # --- Assertions ---
+ # 1. An aggregate query without a GROUP BY should always return exactly one row.
+ self.assertEqual(
+ len(results_data), 1, "A non-grouped aggregate query should return exactly one row."
+ )
+
+ # 2. The result in that row should be the correct sum.
+ actual_sum = float(results_data[0][0])
+ expected_sum = 5500.0
+ self.assertAlmostEqual(
+ actual_sum,
+ expected_sum,
+ "The SUM() result is incorrect. The engine is not accumulating the values correctly.",
+ )
+
+ def test_non_grouped_query_with_mixed_extractors(self):
+ """
+ Tests a non-grouped query with both a static value and a SUM() aggregate.
+ """
+ query = "SELECT 'Total Height', SUM(Height) FROM document WHERE IfcType = 'Wall'"
+
+ # We call the engine directly to isolate its behavior.
+ _, results_data = Arch.select(query)
+
+ # --- Assertions ---
+ # 1. The query should still return exactly one row.
+ self.assertEqual(
+ len(results_data), 1, "A non-grouped mixed query should return exactly one row."
+ )
+
+ # 2. Check the content of the single row.
+ # The first column should be the static string.
+ self.assertEqual(results_data[0][0], "Total Height")
+ # The second column should be the correct sum (3000 + 2500 = 5500).
+ actual_sum = float(results_data[0][1])
+ expected_sum = 5500.0
+ self.assertAlmostEqual(
+ actual_sum, expected_sum, "The SUM() result in a mixed non-grouped query is incorrect."
+ )
+
+ def test_sum_of_space_area_is_correct_and_returns_float(self):
+ """
+ Tests that SUM() on the 'Area' property of Arch.Space objects
+ returns the correct numerical sum as a float.
+ """
+ # --- Test Setup: Create two Arch.Space objects with discernible areas ---
+
+ # Space 1: Base is a 1000x2000 box, resulting in 2,000,000 mm^2 floor area
+ base_box1 = self.doc.addObject("Part::Box", "BaseBox1")
+ base_box1.Length = 1000
+ base_box1.Width = 2000
+ _ = Arch.makeSpace(base_box1, name="Office")
+
+ # Space 2: Base is a 3000x1500 box, resulting in 4,500,000 mm^2 floor area
+ base_box2 = self.doc.addObject("Part::Box", "BaseBox2")
+ base_box2.Length = 3000
+ base_box2.Width = 1500
+ _ = Arch.makeSpace(base_box2, name="Workshop")
+
+ self.doc.recompute() # Ensure space areas are calculated
+
+ query = "SELECT SUM(Area) FROM document WHERE IfcType = 'Space'"
+
+ # Call the engine directly to isolate its behavior
+ _, results_data = Arch.select(query)
+
+ # --- Assertions ---
+ # 1. An aggregate query should return exactly one row.
+ self.assertEqual(
+ len(results_data), 1, "A non-grouped aggregate query should return exactly one row."
+ )
+
+ # 2. The result in the row should be a float. This verifies the engine's
+ # design to return raw numbers for aggregates.
+ self.assertIsInstance(results_data[0][0], float, "The result of a SUM() should be a float.")
+
+ # 3. The value of the float should be the correct sum.
+ actual_sum = results_data[0][0]
+ expected_sum = 6500000.0 # 2,000,000 + 4,500,000
+
+ self.assertAlmostEqual(
+ actual_sum, expected_sum, "The SUM(Area) for Space objects is incorrect."
+ )
+
+ def test_min_and_max_aggregates(self):
+ """
+ Tests the MIN() and MAX() aggregate functions on a numeric property.
+ """
+ # Note: The test setup already includes two walls with different lengths.
+ # Exterior Wall: Length = 1000mm
+ # Interior Wall: Length = 500mm
+ query = "SELECT MIN(Length), MAX(Length) FROM document WHERE IfcType = 'Wall'"
+
+ _, results_data = Arch.select(query)
+
+ self.assertEqual(len(results_data), 1, "Aggregate query should return a single row.")
+ self.assertIsInstance(results_data[0][0], float, "MIN() should return a float.")
+ self.assertIsInstance(results_data[0][1], float, "MAX() should return a float.")
+
+ min_length = results_data[0][0]
+ max_length = results_data[0][1]
+
+ self.assertAlmostEqual(min_length, 500.0)
+ self.assertAlmostEqual(max_length, 1000.0)
+
+ def test_count_property_vs_count_star(self):
+ """
+ Tests that COUNT(property) correctly counts only non-null values,
+ while COUNT(*) counts all rows.
+ """
+ # --- Test Setup ---
+ # Use a unique property name that is guaranteed not to exist on any other object.
+ # This ensures the test is perfectly isolated.
+ unique_prop_name = "TestSpecificTag"
+
+ # Add the unique property to exactly two objects.
+ self.wall_ext.addProperty("App::PropertyString", unique_prop_name, "BIM")
+ setattr(self.wall_ext, unique_prop_name, "Exterior")
+
+ self.column.addProperty("App::PropertyString", unique_prop_name, "BIM")
+ setattr(self.column, unique_prop_name, "Structural")
+
+ self.doc.recompute()
+
+ # --- Test COUNT(TestSpecificTag) ---
+ # This query should now only find the two objects we explicitly modified.
+ query_count_prop = f"SELECT COUNT({unique_prop_name}) FROM document"
+ headers_prop, results_prop = Arch.select(query_count_prop)
+ self.assertEqual(
+ int(results_prop[0][0]),
+ 2,
+ f"COUNT({unique_prop_name}) should count exactly the 2 objects where the property was added.",
+ )
+
+ # --- Test COUNT(*) ---
+ # Build the WHERE clause dynamically from the actual object labels.
+ # This is the most robust way to ensure the test is correct and not
+ # dependent on FreeCAD's internal naming schemes.
+ labels_to_count = [
+ self.wall_ext.Label,
+ self.wall_int.Label,
+ self.column.Label,
+ self.beam.Label,
+ self.window.Label,
+ self.part_box.Label,
+ ]
+
+ # Create a chain of "Label = '...'" conditions
+ where_conditions = " OR ".join([f"Label = '{label}'" for label in labels_to_count])
+ query_count_star = f"SELECT COUNT(*) FROM document WHERE {where_conditions}"
+
+ headers_star, results_star = Arch.select(query_count_star)
+ self.assertEqual(int(results_star[0][0]), 6, "COUNT(*) should count all 6 test objects.")
+
+ def test_bundled_report_templates_are_valid(self):
+ """
+ Performs an integration test to ensure all bundled report templates
+ can be parsed and executed without errors against a sample model.
+ """
+ # 1. Load presets.
+ report_presets = ArchReport._get_presets("report")
+ self.assertGreater(
+ len(report_presets),
+ 0,
+ "No bundled report templates were found. Check CMakeLists.txt and file paths.",
+ )
+
+ # 2. Verify that the expected templates were loaded by their display name.
+ loaded_template_names = {preset["name"] for preset in report_presets.values()}
+ self.assertIn("Room and Area Schedule", loaded_template_names)
+ self.assertIn("Wall Quantities", loaded_template_names)
+
+ # 3. Execute every query in every statement of every template.
+ for filename, preset in report_presets.items():
+ # This test should only validate bundled system presets.
+ if preset.get("is_user"):
+ continue
+
+ template_name = preset["name"]
+ statements = preset["data"].get("statements", [])
+ self.assertGreater(
+ len(statements), 0, f"Template '{template_name}' contains no statements."
+ )
+
+ for i, statement_data in enumerate(statements):
+ query = statement_data.get("query_string")
+ self.assertIsNotNone(
+ query, f"Statement {i} in '{template_name}' is missing a 'query_string'."
+ )
+
+ with self.subTest(template=template_name, statement_index=i):
+ # We only care that the query executes without raising an exception.
+ try:
+ headers, _ = Arch.select(query)
+ self.assertIsInstance(headers, list)
+ except Exception as e:
+ self.fail(
+ f"Query '{query}' from template '{template_name}' (file: {filename}) failed with an exception: {e}"
+ )
+
+ def test_bundled_query_presets_are_valid(self):
+ """
+ Performs an integration test to ensure all bundled single-query presets
+ are syntactically valid and executable.
+ """
+ # 1. Load presets using the new, correct backend function.
+ query_presets = ArchReport._get_presets("query")
+ self.assertGreater(
+ len(query_presets),
+ 0,
+ "No bundled query presets were found. Check CMakeLists.txt and file paths.",
+ )
+
+ # 2. Verify that the expected presets were loaded.
+ loaded_preset_names = {preset["name"] for preset in query_presets.values()}
+ self.assertIn("All Walls", loaded_preset_names)
+ self.assertIn("Count by IfcType", loaded_preset_names)
+
+ # 3. Execute every query in the presets file.
+ for filename, preset in query_presets.items():
+ # This test should only validate bundled system presets.
+ if preset.get("is_user"):
+ continue
+
+ preset_name = preset["name"]
+ query = preset["data"].get("query")
+ self.assertIsNotNone(query, f"Preset '{preset_name}' is missing a 'query'.")
+
+ with self.subTest(preset=preset_name):
+ # We only care that the query executes without raising an exception.
+ try:
+ headers, _ = Arch.select(query)
+ self.assertIsInstance(headers, list)
+ except Exception as e:
+ self.fail(
+ f"Query '{query}' from preset '{preset_name}' (file: {filename}) failed with an exception: {e}"
+ )
+
+ def test_where_in_clause(self):
+ """
+ Tests the SQL 'IN' clause for filtering against a list of values.
+ """
+ # This query should select only the two wall objects from the setup.
+ query = "SELECT * FROM document WHERE Label IN ('Exterior Wall', 'Interior partition wall')"
+
+ # This will fail at the parsing stage until the 'IN' keyword is implemented.
+ _, results_data = Arch.select(query)
+
+ # --- Assertions ---
+ # 1. The query should return exactly two rows.
+ self.assertEqual(
+ len(results_data), 2, "The IN clause should have found exactly two matching objects."
+ )
+
+ # 2. Verify the labels of the returned objects.
+ returned_labels = sorted([row[0] for row in results_data])
+ expected_labels = sorted([self.wall_ext.Label, self.wall_int.Label])
+ self.assertListEqual(
+ returned_labels, expected_labels, "The objects returned by the IN clause are incorrect."
+ )
+
+ def test_type_function(self):
+ """
+ Tests the custom TYPE() function to ensure it returns the correct
+ programmatic class name for both simple and proxy-based objects.
+ """
+ # --- Query and Execution ---
+ # We want the type of the Part::Box and one of the Arch Walls.
+ query = "SELECT TYPE(*) FROM document WHERE Name IN ('Generic_Box', 'Wall')"
+
+ _, results_data = Arch.select(query)
+
+ # --- Assertions ---
+ # The query should return two rows, one for each object.
+ self.assertEqual(len(results_data), 2, "Query should have found the two target objects.")
+
+ # Convert the results to a simple list for easier checking.
+ # The result from the engine is a list of lists, e.g., [['Part.Box'], ['Arch.ArchWall']]
+ type_names = sorted([row[0] for row in results_data])
+
+ # 1. Verify the type of the Part::Box.
+ # The expected value is the C++ class name.
+ self.assertIn("Part::Box", type_names, "TYPE() failed to identify the Part::Box.")
+
+ # 2. Verify the type of the Arch Wall.
+ # Draft.get_type() returns the user-facing 'Wall' type from the proxy.
+ self.assertIn("Wall", type_names, "TYPE() failed to identify the ArchWall proxy class.")
+
+ def test_children_function(self):
+ """
+ Tests the unified CHILDREN() function for both direct containment (.Group)
+ and hosting relationships (.Hosts), including traversal of generic groups.
+ """
+
+ # --- Test Setup: Create a mini-model with all relationship types ---
+ # 1. A parent Floor for direct containment
+ floor = Arch.makeBuildingPart(name="Ground Floor")
+ # Use the canonical enumeration label used by the BIM module.
+ floor.IfcType = "Building Storey"
+
+ # 2. A host Wall for the hosting relationship
+ host_wall = Arch.makeWall(name="Host Wall For Window")
+
+ # 3. Child objects
+ space1 = Arch.makeSpace(name="Living Room")
+ space2 = Arch.makeSpace(name="Kitchen")
+ win_profile = Draft.makeRectangle(length=1000, height=1200)
+ window = Arch.makeWindow(baseobj=win_profile, name="Living Room Window")
+
+ # 4. An intermediate generic group
+ group = self.doc.addObject("App::DocumentObjectGroup", "Room Group")
+
+ # 5. Establish relationships
+ floor.addObject(space1) # Floor directly contains Space1
+ floor.addObject(group) # Floor also contains the Group
+ group.addObject(space2) # The Group contains Space2
+ Arch.addComponents(window, host=host_wall)
+ # Ensure the document is recomputed before running the report query
+ self.doc.recompute()
+
+ # --- Sub-Test 1: Direct containment and group traversal ---
+ with self.subTest(description="Direct containment with group traversal"):
+ query = (
+ f"SELECT Label FROM CHILDREN(SELECT * FROM document WHERE Label = '{floor.Label}')"
+ )
+ _, results = Arch.select(query)
+
+ returned_labels = sorted([row[0] for row in results])
+ # The result should contain the spaces, but NOT the intermediate group itself.
+ # Build the expected results from the actual object labels
+ expected_labels = sorted([space1.Label, space2.Label])
+ self.assertListEqual(returned_labels, expected_labels)
+
+ # --- Sub-Test 2: Hosting Relationship (Reverse Lookup) ---
+ with self.subTest(description="Hosting relationship"):
+ query = f"SELECT Label FROM CHILDREN(SELECT * FROM document WHERE Label = '{host_wall.Label}')"
+ _, results = Arch.select(query)
+
+ self.assertEqual(len(results), 1)
+ self.assertEqual(results[0][0], window.Label)
+
+ def test_order_by_label_desc(self):
+ """Tests the ORDER BY clause to sort results alphabetically."""
+ query = "SELECT Label FROM document WHERE IfcType = 'Wall' ORDER BY Label DESC"
+ _, results_data = Arch.select(query)
+
+ # The results should be a list of lists, e.g., [['Wall 2'], ['Wall 1']]
+ self.assertEqual(len(results_data), 2)
+ returned_labels = [row[0] for row in results_data]
+
+ # Wall labels from setUp are "Exterior Wall" and "Interior partition wall"
+ # In descending order, "Interior..." should come first.
+ expected_order = sorted([self.wall_ext.Label, self.wall_int.Label], reverse=True)
+
+ self.assertListEqual(
+ returned_labels,
+ expected_order,
+ "The results were not sorted by Label in descending order.",
+ )
+
+ def test_column_aliasing(self):
+ """Tests renaming columns using the AS keyword."""
+ # This query renames 'Label' to 'Wall Name' and sorts the results for a predictable check.
+ query = "SELECT Label AS 'Wall Name' FROM document WHERE IfcType = 'Wall' ORDER BY 'Wall Name' ASC"
+ headers, results_data = Arch.select(query)
+
+ # 1. Assert that the header is the alias, not the original property name.
+ self.assertEqual(headers, ["Wall Name"])
+
+ # 2. Assert that the data is still correct.
+ self.assertEqual(len(results_data), 2)
+ returned_labels = [row[0] for row in results_data]
+ # Wall labels from setUp: "Exterior Wall", "Interior partition wall". Sorted alphabetically.
+ expected_labels = sorted([self.wall_ext.Label, self.wall_int.Label])
+ self.assertListEqual(returned_labels, expected_labels)
+
+ def test_string_functions(self):
+ """Tests the CONCAT, LOWER, and UPPER string functions."""
+ # Use a predictable object for testing, e.g., the Main Column.
+ target_obj_name = self.column.Name
+ target_obj_label = self.column.Label # "Main Column"
+ target_obj_ifctype = self.column.IfcType # "Column"
+
+ with self.subTest(description="LOWER function"):
+ query = f"SELECT LOWER(Label) FROM document WHERE Name = '{target_obj_name}'"
+ _, data = Arch.select(query)
+ self.assertEqual(len(data), 1)
+ self.assertEqual(data[0][0], target_obj_label.lower())
+
+ with self.subTest(description="UPPER function"):
+ query = f"SELECT UPPER(Label) FROM document WHERE Name = '{target_obj_name}'"
+ _, data = Arch.select(query)
+ self.assertEqual(len(data), 1)
+ self.assertEqual(data[0][0], target_obj_label.upper())
+
+ with self.subTest(description="CONCAT function with properties and literals"):
+ query = f"SELECT CONCAT(Label, ': ', IfcType) FROM document WHERE Name = '{target_obj_name}'"
+ _, data = Arch.select(query)
+ self.assertEqual(len(data), 1)
+ expected_string = f"{target_obj_label}: {target_obj_ifctype}"
+ self.assertEqual(data[0][0], expected_string)
+
+ def test_meaningful_error_on_transformer_failure(self):
+ """
+ Tests that a low-level VisitError from the transformer is converted
+ into a high-level, user-friendly BimSqlSyntaxError.
+ """
+ # This query is syntactically correct but will fail during transformation
+ # because the TYPE function requires '*' as its argument, not a property.
+ query = "SELECT TYPE(Label) FROM document"
+
+ with self.assertRaises(ArchSql.BimSqlSyntaxError) as cm:
+ Arch.select(query)
+
+ # Assert that the error message is our clean, high-level message
+ # and not a raw, confusing traceback from deep inside the library.
+ # We check that it contains the key parts of our formatted error.
+ error_message = str(cm.exception)
+ self.assertIn("Transformer Error", error_message)
+ self.assertIn("Failed to process rule 'function'", error_message)
+ self.assertIn("requires exactly one argument: '*'", error_message)
+
+ def test_get_sql_keywords(self):
+ """Tests the public API for retrieving all SQL keywords."""
+ keywords = Arch.getSqlKeywords()
+ self.assertIsInstance(keywords, list, "get_sql_keywords should return a list.")
+ self.assertGreater(len(keywords), 10, "Should be a significant number of keywords.")
+
+ # Check for the presence of a few key, case-insensitive keywords.
+ self.assertIn("SELECT", keywords)
+ self.assertIn("FROM", keywords)
+ self.assertIn("WHERE", keywords)
+ self.assertIn("ORDER", keywords, "The ORDER keyword should be present.")
+ self.assertIn("BY", keywords, "The BY keyword should be present.")
+ self.assertIn("AS", keywords)
+ self.assertIn("COUNT", keywords, "Function names should be included as keywords.")
+
+ # Check that internal/non-keyword tokens are correctly filtered out.
+ self.assertNotIn("WS", keywords, "Whitespace token should be filtered out.")
+ self.assertNotIn("RPAR", keywords, "Punctuation tokens should be filtered out.")
+ self.assertNotIn("CNAME", keywords, "Regex-based tokens should be filtered out.")
+
+ def test_function_in_where_clause(self):
+ """Tests using a scalar function (LOWER) in the WHERE clause."""
+ # self.column.Label is "Main Column". This query should find it case-insensitively.
+ query = f"SELECT Label FROM document WHERE LOWER(Label) = 'main column'"
+ _, results_data = Arch.select(query)
+
+ self.assertEqual(len(results_data), 1, "Should find exactly one object.")
+ self.assertEqual(results_data[0][0], self.column.Label, "Did not find the correct object.")
+
+ # Also test that an aggregate function raises a proper exception.
+ error_query = "SELECT Label FROM document WHERE COUNT(*) > 1"
+
+ # 1. Test the "unsafe" public API: select() should re-raise the exception.
+ with self.assertRaises(Arch.SqlEngineError) as cm:
+ Arch.select(error_query)
+ self.assertIn(
+ "Aggregate functions (like COUNT, SUM) cannot be used in a WHERE clause",
+ str(cm.exception),
+ )
+
+ # 2. Test the "safe" public API: count() should catch the exception and return an error tuple.
+ count, error_str = Arch.count(error_query)
+ self.assertEqual(count, -1)
+ self.assertIn("Aggregate functions", error_str)
+
+ def test_null_as_operand(self):
+ """Tests using NULL as a direct operand in a comparison like '= NULL'."""
+ # In standard SQL, a comparison `SomeValue = NULL` evaluates to 'unknown'
+ # and thus filters out the row. The purpose of this test is to ensure
+ # that the query parses and executes without crashing, proving that our
+ # NULL terminal transformer is working correctly.
+ query = "SELECT * FROM document WHERE IfcType = NULL"
+ _, results_data = Arch.select(query)
+ self.assertEqual(
+ len(results_data), 0, "Comparing a column to NULL with '=' should return no rows."
+ )
+
+ def test_arithmetic_in_select_clause(self):
+ """Tests arithmetic operations in the SELECT clause."""
+ # Use the wall_ext object, which has Length=1000.0 (Quantity)
+ target_name = self.wall_ext.Name
+
+ with self.subTest(description="Simple multiplication with Quantity"):
+ # Test: 1000.0 * 2.0 = 2000.0
+ query = f"SELECT Length * 2 FROM document WHERE Name = '{target_name}'"
+ _, data = Arch.select(query)
+ self.assertEqual(len(data), 1)
+ self.assertAlmostEqual(data[0][0], 2000.0)
+
+ with self.subTest(description="Operator precedence"):
+ # Test: 100 + 1000.0 * 2 = 2100.0 (multiplication first)
+ query = f"SELECT 100 + Length * 2 FROM document WHERE Name = '{target_name}'"
+ _, data = Arch.select(query)
+ self.assertEqual(len(data), 1)
+ self.assertAlmostEqual(data[0][0], 2100.0)
+
+ with self.subTest(description="Parentheses overriding precedence"):
+ # Test: (100 + 1000.0) * 2 = 2200.0 (addition first)
+ query = f"SELECT (100 + Length) * 2 FROM document WHERE Name = '{target_name}'"
+ _, data = Arch.select(query)
+ self.assertEqual(len(data), 1)
+ self.assertAlmostEqual(data[0][0], 2200.0)
+
+ with self.subTest(description="Arithmetic with unitless float property"):
+ # self.wall_ext.Shape.Volume should be a float (200 * 3000 * 1000 = 600,000,000)
+ # Test: 600,000,000 / 1,000,000 = 600.0
+ query = f"SELECT Shape.Volume / 1000000 FROM document WHERE Name = '{target_name}'"
+ _, data = Arch.select(query)
+ self.assertEqual(len(data), 1)
+ self.assertAlmostEqual(data[0][0], 600.0)
+
+ def test_convert_function(self):
+ """Tests the CONVERT(value, 'unit') function."""
+ # Use wall_ext, which has Length = 1000.0 (mm Quantity)
+ target_name = self.wall_ext.Name
+
+ # --- Test 1: Successful Conversion ---
+ # This part of the test verifies that a valid conversion works correctly.
+ query = f"SELECT CONVERT(Length, 'm') FROM document WHERE Name = '{target_name}'"
+ _, data = Arch.select(query)
+
+ self.assertEqual(len(data), 1, "The query should return exactly one row.")
+ self.assertEqual(len(data[0]), 1, "The row should contain exactly one column.")
+ self.assertIsInstance(data[0][0], float, "The result of CONVERT should be a float.")
+ self.assertAlmostEqual(data[0][0], 1.0, msg="1000mm should be converted to 1.0m.")
+
+ # --- Test 2: Invalid Conversion Error Handling ---
+ # This part of the test verifies that an invalid conversion (e.g., mm to kg),
+ # which is an EXECUTION-TIME error, is handled correctly by the public API.
+ error_query = f"SELECT CONVERT(Length, 'kg') FROM document WHERE Name = '{target_name}'"
+
+ # 2a. Test the "unsafe" public API: select() should raise the execution-time error.
+ with self.assertRaises(Arch.SqlEngineError) as cm:
+ Arch.select(error_query)
+ self.assertIn("Unit conversion failed", str(cm.exception))
+
+ # 2b. Test the "safe" public API: count() should catch the execution-time error and return an error tuple.
+ count, error_str = Arch.count(error_query)
+ self.assertEqual(count, -1)
+ self.assertIsInstance(error_str, str)
+ self.assertIn("Unit conversion failed", error_str)
+
+ def test_get_sql_api_documentation(self):
+ """Tests the data structure returned by the SQL documentation API."""
+ api_data = Arch.getSqlApiDocumentation()
+
+ self.assertIsInstance(api_data, dict)
+ self.assertIn("clauses", api_data)
+ self.assertIn("functions", api_data)
+
+ # Check for a known clause and a known function category
+ self.assertIn("SELECT", api_data["clauses"])
+ self.assertIn("Aggregate", api_data["functions"])
+
+ # Check for a specific function's data
+ count_func = next(
+ (f for f in api_data["functions"]["Aggregate"] if f["name"] == "COUNT"), None
+ )
+ self.assertIsNotNone(count_func)
+ self.assertIn("description", count_func)
+ self.assertIn("snippet", count_func)
+ self.assertGreater(len(count_func["description"]), 0)
+
+ # GUI-specific tests were moved to TestArchReportGui.py
+
+ def test_count_with_group_by_is_correct_and_fast(self):
+ """
+ Ensures that Arch.count() on a GROUP BY query returns the number of
+ final groups (output rows), not the number of input objects.
+ This validates the performance refactoring.
+ """
+ # This query will have 5 input objects with an IfcType
+ # but only 4 output rows/groups (Wall, Column, Beam, Window).
+ query = "SELECT IfcType, COUNT(*) FROM document WHERE IfcType IS NOT NULL GROUP BY IfcType"
+
+ # The count() function should be fast and correct.
+ count, error = Arch.count(query)
+
+ self.assertIsNone(error, "The query should be valid.")
+ self.assertEqual(
+ count, 4, "Count should return the number of groups, not the number of objects."
+ )
+
+ def test_sql_comment_support(self):
+ """Tests that single-line and multi-line SQL comments are correctly ignored."""
+
+ with self.subTest(description="Single-line comments with --"):
+ # This query uses comments to explain and to disable the ORDER BY clause.
+ # The engine should ignore them and return an unsorted result.
+ query = """
+ SELECT Label -- Select the object's label
+ FROM document
+ WHERE IfcType = 'Wall' -- Only select walls
+ -- ORDER BY Label DESC
+ """
+ _, data = Arch.select(query)
+
+ # The query should run as if the comments were not there.
+ self.assertEqual(len(data), 2, "Should find the two wall objects.")
+ # Verify the content without assuming a specific order.
+ found_labels = {row[0] for row in data}
+ expected_labels = {self.wall_ext.Label, self.wall_int.Label}
+ self.assertSetEqual(found_labels, expected_labels)
+
+ with self.subTest(description="Multi-line comments with /* ... */"):
+ # This query uses a block comment to completely disable the WHERE clause.
+ query = """
+ SELECT Label
+ FROM document
+ /*
+ WHERE IfcType = 'Wall'
+ ORDER BY Label
+ */
+ """
+ _, data = Arch.select(query)
+ # Without the WHERE clause, it should return all test objects.
+ # The assertion must compare against all objects in the document,
+ # not just the list of BIM objects, as the setup also creates
+ # a spreadsheet.
+ self.assertEqual(len(data), len(self.doc.Objects))
+
+ def test_query_with_non_ascii_property_name(self):
+ """
+ Tests that the SQL engine can correctly handle non-ASCII (Unicode)
+ characters in property names, which is crucial for international users.
+ """
+ # --- Test Setup ---
+ # Add a dynamic property with a German name containing a non-ASCII character.
+ # This simulates a common international use case.
+ prop_name_unicode = "Fläche" # "Area" in German
+ self.column.addProperty("App::PropertyFloat", prop_name_unicode, "BIM")
+ setattr(self.column, prop_name_unicode, 42.5)
+ self.doc.recompute()
+
+ # --- The Query ---
+ # This query will fail at the parsing (lexing) stage with the old grammar.
+ query = f"SELECT {prop_name_unicode} FROM document WHERE Name = '{self.column.Name}'"
+
+ # --- Test Execution ---
+ # We call the "unsafe" select() API, as it should raise the parsing
+ # exception with the old grammar, and succeed with the new one.
+ try:
+ headers, results_data = Arch.select(query)
+ # --- Assertions for when the test passes ---
+ self.assertEqual(
+ len(results_data), 1, "The query should find the single target object."
+ )
+ self.assertEqual(headers, [prop_name_unicode])
+ self.assertAlmostEqual(results_data[0][0], 42.5)
+
+ except Arch.BimSqlSyntaxError as e:
+ # --- Assertion for when the test fails ---
+ # This makes the test's purpose clear: it's expected to fail
+ # with a syntax error until the grammar is fixed.
+ self.fail(f"Parser failed to handle Unicode identifier. Error: {e}")
+
+ def test_order_by_multiple_columns(self):
+ """Tests sorting by multiple columns in the ORDER BY clause."""
+ # This query selects a subset of objects and sorts them first by their
+ # IfcType alphabetically, and then by their Label alphabetically within
+ # each IfcType group. This requires a multi-column sort to verify.
+ query = """
+ SELECT Label, IfcType
+ FROM document
+ WHERE IfcType IN ('Wall', 'Column', 'Beam')
+ ORDER BY IfcType, Label ASC
+ """
+ _, data = Arch.select(query)
+
+ self.assertEqual(len(data), 4, "Should find the two walls, one column, and one beam.")
+
+ # Verify the final, multi-level sorted order.
+ # The engine should sort by IfcType first ('Beam' < 'Column' < 'Wall'),
+ # and then by Label for the two 'Wall' objects.
+ expected_order = [
+ [self.beam.Label, self.beam.IfcType], # Type: Beam
+ [self.column.Label, self.column.IfcType], # Type: Column
+ [self.wall_ext.Label, self.wall_ext.IfcType], # Type: Wall, Label: Exterior...
+ [self.wall_int.Label, self.wall_int.IfcType], # Type: Wall, Label: Interior...
+ ]
+
+ # We sort our expected list's inner items to be sure, in case the test setup changes.
+ expected_order = sorted(expected_order, key=lambda x: (x[1], x[0]))
+
+ self.assertListEqual(data, expected_order)
+
+ def test_parent_function_and_chaining(self):
+ """
+ Tests the PARENT(*) function with simple and chained calls,
+ and verifies the logic for transparently skipping generic groups.
+ """
+ # 1. ARRANGE: Create a comprehensive hierarchy
+ site = Arch.makeSite(name="Test Site")
+ building = Arch.makeBuilding(name="Test Building")
+ floor = Arch.makeFloor(name="Test Floor")
+ wall = Arch.makeWall(name="Test Wall")
+ win_profile = Draft.makeRectangle(1000, 1000)
+ window = Arch.makeWindow(win_profile, name="Test Window")
+
+ generic_group = self.doc.addObject("App::DocumentObjectGroup", "Test Generic Group")
+ space_profile = Draft.makeRectangle(2000, 2000)
+ space = Arch.makeSpace(space_profile, name="Test Space")
+
+ site.addObject(building)
+ building.addObject(floor)
+ floor.addObject(wall)
+ floor.addObject(generic_group)
+ generic_group.addObject(space)
+ Arch.addComponents(window, wall)
+ self.doc.recompute()
+
+ # 2. ACT & ASSERT
+
+ # Sub-Test A: Skipping a generic group
+ # The PARENT of the Space should be the Floor, not the Generic Group.
+ with self.subTest(description="Skipping generic group"):
+ query = f"SELECT PARENT(*).Label FROM document WHERE Label = '{space.Label}'"
+ _, data = Arch.select(query)
+ self.assertEqual(
+ data[0][0], floor.Label, "PARENT(Space) should skip the group and return the Floor."
+ )
+
+ # Sub-Test B: Chained parent finding for a contained object
+ # The significant grandparent of the Wall (Wall -> Floor -> Building) is the Building.
+ with self.subTest(description="Chained PARENT of Wall"):
+ query = f"SELECT PARENT(*).PARENT(*).Label FROM document WHERE Label = '{wall.Label}'"
+ _, data = Arch.select(query)
+ self.assertEqual(data[0][0], building.Label)
+
+ # Sub-Test C: Chained parent finding for a hosted object
+ # The significant great-grandparent of the Window (Window -> Wall -> Floor -> Building) is the Building.
+ with self.subTest(description="Chained PARENT of Window"):
+ query = f"SELECT PARENT(*).PARENT(*).PARENT(*).Label FROM document WHERE Label = '{window.Label}'"
+ _, data = Arch.select(query)
+ self.assertEqual(data[0][0], building.Label)
+
+ # Sub-Test D: Filtering by a logical grandparent
+ # This query should find all objects whose significant grandparent is the Building.
+ # This includes the Space (grandparent is Floor's parent) and the Wall (grandparent is Floor's parent).
+ with self.subTest(description="Filtering by logical grandparent"):
+ query = (
+ f"SELECT Label FROM document WHERE PARENT(*).PARENT(*).Label = '{building.Label}'"
+ )
+ _, data = Arch.select(query)
+
+ found_labels = sorted([row[0] for row in data])
+ expected_labels = sorted(
+ [space.Label, wall.Label, generic_group.Label]
+ ) # The group's logical grandparent is also the building.
+ self.assertListEqual(
+ found_labels,
+ expected_labels,
+ "Query did not find all objects with the correct logical grandparent.",
+ )
+
+ def test_ppa_and_query_permutations(self):
+ """
+ Runs a suite of integration tests against a complex model to
+ validate Pythonic Property Access and other query features.
+ """
+ # --- 1. ARRANGE: Create a complex model ---
+ # Build the model using the generator function
+ model = create_test_model(self.document)
+
+ # Get references to key objects from the returned dictionary
+ ground_floor = model["ground_floor"]
+ upper_floor = model["upper_floor"]
+ front_door = model["front_door"]
+ living_window = model["living_window"]
+ office_space = model["office_space"]
+ living_space = model["living_space"]
+ interior_wall = model["interior_wall"]
+ exterior_wall = model["exterior_wall"]
+
+ # --- 2. ACT & ASSERT: Run query permutations ---
+
+ # Sub-Test A: Chained PARENT in SELECT clause
+ with self.subTest(description="PPA in SELECT clause"):
+ query = (
+ f"SELECT PARENT(*).PARENT(*).Label FROM document WHERE Label = '{front_door.Label}'"
+ )
+ _, data = Arch.select(query)
+ self.assertEqual(
+ data[0][0], ground_floor.Label, "Grandparent of Front Door should be Ground Floor"
+ )
+
+ # Sub-Test B: Chained PARENT in WHERE clause
+ with self.subTest(description="PPA in WHERE clause"):
+ query = f"SELECT Label FROM document WHERE PARENT(*).PARENT(*).Label = '{ground_floor.Label}'"
+ _, data = Arch.select(query)
+ found_labels = sorted([row[0] for row in data])
+ expected_labels = sorted([front_door.Label, living_window.Label])
+ self.assertListEqual(
+ found_labels, expected_labels, "Should find the Door and Window on the Ground Floor"
+ )
+
+ # Sub-Test C: Chained PARENT in ORDER BY clause
+ with self.subTest(description="PPA in ORDER BY clause"):
+ # Create a proper 3D solid volume for the new space.
+ upper_box = self.document.addObject("Part::Box", "UpperSpaceVolume")
+ upper_box.Length, upper_box.Width, upper_box.Height = 1000.0, 1000.0, 3000.0
+
+ upper_space = Arch.makeSpace(baseobj=upper_box, name="Upper Space")
+ upper_floor.addObject(upper_space)
+ self.document.recompute()
+
+ # The query now selects both the space's label and its parent's label.
+ # This is the robust way to verify the sort order.
+ query = f"SELECT Label, PARENT(*).Label AS ParentLabel FROM document WHERE IfcType = 'Space' ORDER BY ParentLabel DESC"
+ _, data = Arch.select(query)
+
+ # data is now a list of lists, e.g., [['Upper Space', 'Upper Floor'], ['Office Space', 'Ground Floor'], ...]
+
+ # The assertion now directly checks the parent label returned by the query.
+ # This is self-contained and does not require error-prone lookups.
+ parent_label_of_first_result = data[0][1]
+ self.assertEqual(
+ parent_label_of_first_result,
+ upper_floor.Label,
+ "The first item in the sorted list should belong to the Upper Floor.",
+ )
+
+ # Sub-Test D: Accessing a sub-property of a parent
+ with self.subTest(description="PPA with sub-property access"):
+ # The Floor's placement Z is 0.0
+ query = f"SELECT Label FROM document WHERE PARENT(*).Placement.Base.z = 0.0 AND IfcType = 'Space'"
+ _, data = Arch.select(query)
+ found_labels = sorted([row[0] for row in data])
+ expected_labels = sorted([office_space.Label, living_space.Label])
+ self.assertListEqual(
+ found_labels,
+ expected_labels,
+ "Should find spaces on the ground floor by parent's placement",
+ )
+
+ # === Advanced Cross-Feature Permutation Tests ===
+
+ with self.subTest(description="Permutation: GROUP BY on a PPA result"):
+ query = "SELECT PARENT(*).Label AS FloorName, COUNT(*) FROM document WHERE IfcType = 'Space' GROUP BY PARENT(*).Label ORDER BY FloorName"
+ _, data = Arch.select(query)
+ # Expected: Ground Floor has 2 spaces, Upper Floor has 1.
+ self.assertEqual(len(data), 2)
+ self.assertEqual(data[0][0], ground_floor.Label)
+ self.assertEqual(data[0][1], 2)
+ self.assertEqual(data[1][0], upper_floor.Label)
+ self.assertEqual(data[1][1], 1)
+
+ with self.subTest(description="Permutation: GROUP BY on a Function result"):
+ query = "SELECT TYPE(*) AS BimType, COUNT(*) FROM document WHERE IfcType IS NOT NULL GROUP BY TYPE(*) ORDER BY BimType"
+ _, data = Arch.select(query)
+ results_dict = {row[0]: row[1] for row in data}
+ self.assertGreaterEqual(results_dict.get("Wall", 0), 2)
+ self.assertGreaterEqual(results_dict.get("Space", 0), 2)
+
+ with self.subTest(description="Permutation: Complex WHERE with PPA and Functions"):
+ query = f"SELECT Label FROM document WHERE TYPE(*) = 'Wall' AND LOWER(PARENT(*).Label) = 'ground floor' AND FireRating IS NOT NULL"
+ _, data = Arch.select(query)
+ self.assertEqual(len(data), 1)
+ self.assertEqual(data[0][0], exterior_wall.Label)
+
+ with self.subTest(description="Permutation: Filtering by a custom property on a parent"):
+ query = "SELECT Label FROM document WHERE PARENT(*).FireRating = '60 minutes' AND IfcType IN ('Door', 'Window')"
+ _, data = Arch.select(query)
+ found_labels = sorted([row[0] for row in data])
+ expected_labels = sorted([front_door.Label, living_window.Label])
+ self.assertListEqual(found_labels, expected_labels)
+
+ with self.subTest(description="Permutation: Arithmetic with parent properties"):
+ # The Interior Partition has height 3000, its parent (Ground Floor) has height 3200.
+ query = (
+ f"SELECT Label FROM document WHERE TYPE(*) = 'Wall' AND Height < PARENT(*).Height"
+ )
+ _, data = Arch.select(query)
+ self.assertEqual(len(data), 1)
+ self.assertEqual(data[0][0], interior_wall.Label)
+
+ def test_group_by_with_function_and_count(self):
+ """
+ Tests that GROUP BY correctly partitions results based on a function (TYPE)
+ and aggregates them with another function (COUNT). This is the canonical
+ non-regression test for the core GROUP BY functionality.
+ """
+ # ARRANGE: Create a simple, self-contained model for this test.
+ # This makes the test independent of the main setUp fixture.
+ doc = self.document # Use the document created by TestArchBase
+ Arch.makeWall(name="Unit Test Wall 1")
+ Arch.makeWall(name="Unit Test Wall 2")
+ Arch.makeSpace(baseobj=doc.addObject("Part::Box"), name="Unit Test Space")
+ doc.recompute()
+
+ # ACT: Run the query with GROUP BY a function expression.
+ query = "SELECT TYPE(*) AS BimType, COUNT(*) FROM document WHERE Label LIKE 'Unit Test %' AND IfcType IS NOT NULL GROUP BY TYPE(*)"
+ _, data = Arch.select(query)
+ engine_results_dict = {row[0]: row[1] for row in data}
+
+ # ASSERT: The results must be correctly grouped and counted.
+ # We only check for the objects created within this test.
+ expected_counts = {
+ "Wall": 2,
+ "Space": 1,
+ }
+
+ # The assertion should check that the expected items are a subset of the results,
+ # as the main test fixture might still be present.
+ self.assertDictContainsSubset(expected_counts, engine_results_dict)
+
+ def test_group_by_chained_parent_function(self):
+ """
+ Tests GROUP BY on a complex expression involving a chained function
+ call (PPA), ensuring the engine's signature generation and grouping
+ logic can handle nested extractors.
+ """
+ # ARRANGE: Use the complex model from the ppa_and_query_permutations test
+ model = create_test_model(self.document)
+ ground_floor = model["ground_floor"]
+ upper_floor = model["upper_floor"] # Has one space
+
+ # Add one more space to the upper floor for a meaningful group
+ upper_box = self.document.addObject("Part::Box", "UpperSpaceVolume2")
+ upper_box.Length, upper_box.Width, upper_box.Height = 1000.0, 1000.0, 3000.0
+ upper_space2 = Arch.makeSpace(baseobj=upper_box, name="Upper Space 2")
+ upper_floor.addObject(upper_space2)
+ self.document.recompute()
+
+ # ACT: Group windows and doors by the Label of their great-grandparent (the Floor)
+ query = """
+ SELECT PARENT(*).PARENT(*).Label AS FloorName, COUNT(*)
+ FROM document
+ WHERE IfcType IN ('Door', 'Window')
+ GROUP BY PARENT(*).PARENT(*).Label
+ """
+ _, data = Arch.select(query)
+ results_dict = {row[0]: row[1] for row in data}
+
+ # ASSERT: The ground floor should contain 2 items (1 door, 1 window)
+ self.assertEqual(results_dict.get(ground_floor.Label), 2)
+
+ def test_group_by_multiple_mixed_columns(self):
+ """
+ Tests GROUP BY with multiple columns of different types (a property
+ and a function result) to verify multi-part key generation.
+ """
+ # ARRANGE: Add a second column to the test fixture for a better test case
+ Arch.makeStructure(length=300, width=330, height=2500, name="Second Column")
+ self.document.recompute()
+
+ # ACT
+ query = "SELECT IfcType, TYPE(*), COUNT(*) FROM document GROUP BY IfcType, TYPE(*)"
+ _, data = Arch.select(query)
+
+ # ASSERT: Find the specific row for IfcType='Column' and TYPE='Column'
+ column_row = next((row for row in data if row[0] == "Column" and row[1] == "Column"), None)
+ self.assertIsNotNone(column_row, "A group for (Column, Column) should exist.")
+ self.assertEqual(column_row[2], 2, "The count for (Column, Column) should be 2.")
+
+ def test_invalid_group_by_with_aggregate_raises_error(self):
+ """
+ Ensures the engine's validation correctly rejects an attempt to
+ GROUP BY an aggregate function, which is invalid SQL.
+ """
+ query = "SELECT IfcType, COUNT(*) FROM document GROUP BY COUNT(*)"
+
+ # The "unsafe" select() API should raise the validation error
+ with self.assertRaisesRegex(ArchSql.SqlEngineError, "must appear in the GROUP BY clause"):
+ Arch.select(query)
+
+ def test_where_clause_with_arithmetic(self):
+ """
+ Tests that the WHERE clause can correctly filter rows based on an
+ arithmetic calculation involving multiple properties. This verifies
+ that the arithmetic engine is correctly integrated into the filtering
+ logic.
+ """
+ # ARRANGE: Create two walls with different dimensions.
+ # Wall 1 Area = 1000 * 200 = 200,000
+ large_wall = Arch.makeWall(name="Unit Test Large Wall", length=1000, width=200)
+ # Wall 2 Area = 500 * 200 = 100,000
+ _ = Arch.makeWall(name="Unit Test Small Wall", length=500, width=200)
+ self.document.recompute()
+
+ # ACT: Select walls where the calculated area is greater than 150,000.
+ query = (
+ "SELECT Label FROM document WHERE Label LIKE 'Unit Test %' AND Length * Width > 150000"
+ )
+ _, data = Arch.select(query)
+ print(data)
+
+ # ASSERT: Only the "Large Wall" should be returned.
+ self.assertEqual(len(data), 1, "The query should find exactly one matching wall.")
+ self.assertEqual(
+ data[0][0], f"{large_wall.Label}", "The found wall should be the large one."
+ )
+
+ def test_select_with_nested_functions(self):
+ """
+ Tests the engine's ability to handle a function (CONCAT) whose
+ arguments are a mix of properties, literals, and another function
+ (TYPE). This is a stress test for the recursive expression evaluator
+ and signature generator.
+ """
+ # ARRANGE: Create a single, predictable object.
+ Arch.makeWall(name="My Test Wall")
+ self.document.recompute()
+
+ # ACT: Construct a complex string using nested function calls.
+ query = "SELECT CONCAT(Label, ' (Type: ', TYPE(*), ')') FROM document WHERE Label = 'My Test Wall'"
+ _, data = Arch.select(query)
+
+ # ASSERT: The engine should correctly evaluate all parts and concatenate them.
+ self.assertEqual(len(data), 1, "The query should have found the target object.")
+ expected_string = "My Test Wall (Type: Wall)"
+ self.assertEqual(
+ data[0][0],
+ expected_string,
+ "The nested function expression was not evaluated correctly.",
+ )
+
+ def test_group_by_with_alias_is_not_supported(self):
+ """
+ Tests that GROUP BY with a column alias is not supported, as per the
+ dialect's known limitations. This test verifies that the engine's
+ validation correctly rejects this syntax.
+ """
+ # ARRANGE: A single object is sufficient for this validation test.
+ Arch.makeWall(name="Test Wall For Alias")
+ self.document.recompute()
+
+ # ACT: Use the "incorrect" syntax where GROUP BY refers to an alias.
+ query = "SELECT TYPE(*) AS BimType, COUNT(*) FROM document GROUP BY BimType"
+
+ # ASSERT: The engine's validator must raise an SqlEngineError because
+ # the signature of the SELECT column ('TYPE(*)') does not match the
+ # signature of the GROUP BY column ('BimType').
+ with self.assertRaisesRegex(ArchSql.SqlEngineError, "must appear in the GROUP BY clause"):
+ Arch.select(query)
+
+ def test_order_by_with_alias_is_supported(self):
+ """
+ Tests the supported ORDER BY behavior: sorting by an alias of a
+ function expression that is present in the SELECT list.
+ """
+ # ARRANGE: Create objects that require case-insensitive sorting.
+ Arch.makeWall(name="Wall_C")
+ Arch.makeWall(name="wall_b")
+ Arch.makeWall(name="WALL_A")
+ self.document.recompute()
+
+ # ACT: Use the correct syntax: include the expression in SELECT with an
+ # alias, and then ORDER BY that alias.
+ query = "SELECT Label, LOWER(Label) AS sort_key FROM document WHERE Label LIKE 'Wall_%' ORDER BY sort_key ASC"
+ _, data = Arch.select(query)
+
+ # Extract the original labels from the correctly sorted results.
+ sorted_labels = [row[0] for row in data]
+
+ # ASSERT: The results must be sorted correctly, proving the logic works.
+ expected_order = ["WALL_A", "wall_b", "Wall_C"]
+ self.assertListEqual(sorted_labels, expected_order)
+
+ def test_order_by_with_raw_expression_is_not_supported(self):
+ """
+ Tests the unsupported ORDER BY behavior, documenting that the engine
+ correctly rejects a query that tries to sort by a raw expression
+ not present in the SELECT list.
+ """
+ # ARRANGE: A single object is sufficient for this validation test.
+ Arch.makeWall(name="Test Wall")
+ self.document.recompute()
+
+ # ACT: Use the incorrect syntax.
+ query = "SELECT Label FROM document ORDER BY LOWER(Label) ASC"
+
+ # ASSERT: The engine's transformer must raise an error with a clear
+ # message explaining the correct syntax.
+ with self.assertRaisesRegex(
+ ArchSql.SqlEngineError, "ORDER BY expressions are not supported directly"
+ ):
+ Arch.select(query)
+
+ def test_core_engine_enhancements_for_pipeline(self):
+ """
+ Tests the Stage 1 enhancements to the internal SQL engine.
+ This test validates both regression (ensuring old functions still work)
+ and the new ability to query against a pre-filtered list of objects.
+ """
+ # --- 1. ARRANGE: Create a specific subset of objects for the test ---
+ # The main test setup already provides a diverse set of objects.
+ # We will create a specific list to act as our pipeline's source data.
+ pipeline_source_objects = [self.wall_ext, self.wall_int, self.window]
+ pipeline_source_labels = sorted([o.Label for o in pipeline_source_objects])
+ self.assertEqual(
+ len(pipeline_source_objects),
+ 3,
+ "Pre-condition failed: Source object list should have 3 items.",
+ )
+
+ # --- 2. ACT & ASSERT (REGRESSION TEST) ---
+ # First, prove that the existing public APIs still work perfectly.
+ # This test implicitly calls the original code path of _run_query where
+ # source_objects is None.
+ with self.subTest(description="Regression test for Arch.select"):
+ _, results_data = Arch.select('SELECT Label FROM document WHERE IfcType = "Wall"')
+ found_labels = sorted([row[0] for row in results_data])
+ self.assertListEqual(found_labels, sorted([self.wall_ext.Label, self.wall_int.Label]))
+
+ with self.subTest(description="Regression test for Arch.count"):
+ count, error = Arch.count('SELECT * FROM document WHERE IfcType = "Wall"')
+ self.assertIsNone(error)
+ self.assertEqual(count, 2)
+
+ # --- 3. ACT & ASSERT (NEW FUNCTIONALITY TEST) ---
+ # Now, test the new core functionality by calling the enhanced _run_query directly.
+ with self.subTest(description="Test _run_query with a source_objects list"):
+ # This query selects all objects (*) but should only run on our source list.
+ query = "SELECT * FROM document"
+
+ # Execute the query, passing our specific list as the source.
+ _, data_rows, resulting_objects = ArchSql._run_query(
+ query, mode="full_data", source_objects=pipeline_source_objects
+ )
+
+ # Assertions for the new behavior:
+ # a) The number of data rows should match the size of our source list.
+ self.assertEqual(
+ len(data_rows),
+ 3,
+ "_run_query did not return the correct number of rows for the provided source.",
+ )
+
+ # b) The content of the data should match the objects from our source list.
+ found_labels = sorted([row[0] for row in data_rows])
+ self.assertListEqual(
+ found_labels,
+ pipeline_source_labels,
+ "The data returned does not match the source objects.",
+ )
+
+ # c) The new third return value, `resulting_objects`, should contain the correct FreeCAD objects.
+ self.assertEqual(
+ len(resulting_objects), 3, "The returned object list has the wrong size."
+ )
+ self.assertIsInstance(
+ resulting_objects[0],
+ FreeCAD.DocumentObject,
+ "The resulting_objects list should contain DocumentObject instances.",
+ )
+ resulting_object_labels = sorted([o.Label for o in resulting_objects])
+ self.assertListEqual(
+ resulting_object_labels,
+ pipeline_source_labels,
+ "The list of resulting objects is incorrect.",
+ )
+
+ with self.subTest(description="Test _run_query with filtering on a source_objects list"):
+ # This query applies a WHERE clause to the pre-filtered source list.
+ query = "SELECT Label FROM document WHERE IfcType = 'Wall'"
+
+ _, data_rows, resulting_objects = ArchSql._run_query(
+ query, mode="full_data", source_objects=pipeline_source_objects
+ )
+
+ # Of the 3 source objects, only the 2 walls should be returned.
+ self.assertEqual(len(data_rows), 2, "Filtering on the source object list failed.")
+ found_labels = sorted([row[0] for row in data_rows])
+ expected_labels = sorted([self.wall_ext.Label, self.wall_int.Label])
+ self.assertListEqual(
+ found_labels,
+ expected_labels,
+ "The data returned after filtering the source is incorrect.",
+ )
+ self.assertEqual(
+ len(resulting_objects),
+ 2,
+ "The object list returned after filtering the source is incorrect.",
+ )
+
+ def test_execute_pipeline_orchestrator(self):
+ """
+ Tests the new `execute_pipeline` orchestrator function in ArchSql.
+ """
+
+ # --- ARRANGE: Create a set of statements for various scenarios ---
+
+ # Statement 1: Get all Wall objects. (Result: 2 objects)
+ stmt1 = ArchSql.ReportStatement(
+ query_string="SELECT * FROM document WHERE IfcType = 'Wall'", is_pipelined=False
+ )
+
+ # Statement 2: From the walls, get the one with "Exterior" in its name. (Result: 1 object)
+ stmt2 = ArchSql.ReportStatement(
+ query_string="SELECT * FROM document WHERE Label LIKE '%Exterior%'", is_pipelined=True
+ )
+
+ # Statement 3: A standalone query to get the Column object. (Result: 1 object)
+ stmt3 = ArchSql.ReportStatement(
+ query_string="SELECT * FROM document WHERE IfcType = 'Column'", is_pipelined=False
+ )
+
+ # Statement 4: A pipelined query that will run on an empty set from a failing previous step.
+ stmt4_failing = ArchSql.ReportStatement(
+ query_string="SELECT * FROM document WHERE IfcType = 'NonExistentType'",
+ is_pipelined=False,
+ )
+ stmt5_piped_from_fail = ArchSql.ReportStatement(
+ query_string="SELECT * FROM document", is_pipelined=True
+ )
+
+ # --- ACT & ASSERT ---
+
+ with self.subTest(description="Test a simple two-step pipeline"):
+ statements = [stmt1, stmt2]
+ results_generator = ArchSql.execute_pipeline(statements)
+
+ # The generator should yield exactly one result: the final one from stmt2.
+ output_list = list(results_generator)
+ self.assertEqual(
+ len(output_list), 1, "A simple pipeline should only yield one final result."
+ )
+
+ # Check the content of the single yielded result.
+ result_stmt, _, result_data = output_list[0]
+ self.assertIs(
+ result_stmt, stmt2, "The yielded statement should be the last one in the chain."
+ )
+ self.assertEqual(
+ len(result_data), 1, "The final pipeline result should contain one row."
+ )
+ self.assertEqual(
+ result_data[0][0],
+ self.wall_ext.Label,
+ "The final result is not the expected 'Exterior Wall'.",
+ )
+
+ with self.subTest(description="Test a mixed report with pipeline and standalone"):
+ statements = [stmt1, stmt2, stmt3]
+ results_generator = ArchSql.execute_pipeline(statements)
+
+ # The generator should yield two results: the end of the first pipeline (stmt2)
+ # and the standalone statement (stmt3).
+ output_list = list(results_generator)
+ self.assertEqual(len(output_list), 2, "A mixed report should yield two results.")
+
+ # Check the first result (from the pipeline)
+ self.assertEqual(output_list[0][2][0][0], self.wall_ext.Label)
+ # Check the second result (from the standalone query)
+ self.assertEqual(output_list[1][2][0][0], self.column.Label)
+
+ with self.subTest(description="Test a pipeline that runs dry"):
+ statements = [stmt4_failing, stmt5_piped_from_fail]
+ results_generator = ArchSql.execute_pipeline(statements)
+ output_list = list(results_generator)
+
+ # The generator should yield only one result: the final, empty output
+ # of the pipeline. The intermediate step's result should be suppressed.
+ self.assertEqual(len(output_list), 1)
+
+ # Check that the single yielded result has zero data rows.
+ result_stmt, _, result_data = output_list[0]
+ self.assertIs(result_stmt, stmt5_piped_from_fail)
+ self.assertEqual(
+ len(result_data), 0, "The final pipelined statement should yield 0 rows."
+ )
+
+ def test_public_api_for_pipelines(self):
+ """
+ Tests the new and enhanced public API functions for Stage 3.
+ """
+ # --- Test 1: Enhanced Arch.count() with source_objects ---
+ with self.subTest(description="Test Arch.count with a source_objects list"):
+ # Create a source list containing only the two wall objects.
+ source_list = [self.wall_ext, self.wall_int]
+
+ # This query would normally find 1 object (the column) in the full document.
+ query = "SELECT * FROM document WHERE IfcType = 'Column'"
+
+ # Run the count against our pre-filtered source list.
+ count, error = ArchSql.count(query, source_objects=source_list)
+
+ self.assertIsNone(error)
+ # The count should be 0, because there are no 'Column' objects in our source_list.
+ self.assertEqual(count, 0, "Arch.count failed to respect the source_objects list.")
+
+ # --- Test 2: New Arch.selectObjectsFromPipeline() ---
+ with self.subTest(description="Test Arch.selectObjectsFromPipeline"):
+ # Define a simple two-step pipeline.
+ stmt1 = ArchSql.ReportStatement(
+ query_string="SELECT * FROM document WHERE IfcType = 'Wall'", is_pipelined=False
+ )
+ stmt2 = ArchSql.ReportStatement(
+ query_string="SELECT * FROM document WHERE Label LIKE '%Exterior%'",
+ is_pipelined=True,
+ )
+
+ # Execute the pipeline via the new high-level API.
+ resulting_objects = Arch.selectObjectsFromPipeline([stmt1, stmt2])
+
+ # Assert that the result is correct.
+ self.assertIsInstance(resulting_objects, list)
+ self.assertEqual(
+ len(resulting_objects), 1, "Pipeline should result in one final object."
+ )
+ self.assertIsInstance(resulting_objects[0], FreeCAD.DocumentObject)
+ self.assertEqual(
+ resulting_objects[0].Name,
+ self.wall_ext.Name,
+ "The final object from the pipeline is incorrect.",
+ )
+
+ def test_pipeline_with_children_function(self):
+ """
+ Tests that the CHILDREN function correctly uses the input from a
+ previous pipeline step instead of running its own subquery.
+ """
+ # --- ARRANGE ---
+ # Create a parent Floor and a Wall that is a child of that floor.
+ floor = Arch.makeFloor(name="Pipeline Test Floor")
+ wall = Arch.makeWall(name="Wall on Test Floor")
+ floor.addObject(wall)
+
+ # Create a "distractor" wall that is NOT a child, to prove the filter works.
+ _ = Arch.makeWall(name="Unrelated Distractor Wall")
+ self.doc.recompute()
+
+ # Define a two-step pipeline.
+ # Step 1: Select only the 'Pipeline Test Floor'.
+ stmt1 = ArchReport.ReportStatement(
+ query_string="SELECT * FROM document WHERE Label = 'Pipeline Test Floor'",
+ is_pipelined=False,
+ )
+
+ # Step 2: Use CHILDREN to get the walls from the previous step's result.
+ stmt2 = ArchReport.ReportStatement(
+ query_string="SELECT * FROM CHILDREN(SELECT * FROM document) WHERE IfcType = 'Wall'",
+ is_pipelined=True,
+ )
+
+ # --- ACT ---
+ # Execute the pipeline and get the final list of objects.
+ resulting_objects = Arch.selectObjectsFromPipeline([stmt1, stmt2])
+
+ # --- ASSERT ---
+ # With the bug present, `resulting_objects` will be an empty list,
+ # causing this assertion to fail as expected.
+ self.assertEqual(
+ len(resulting_objects),
+ 1,
+ "The pipeline should have resulted in exactly one child object.",
+ )
+ self.assertEqual(
+ resulting_objects[0].Name, wall.Name, "The object found via the pipeline is incorrect."
+ )
+
+ def test_group_by_with_function_and_literal_argument(self):
+ """
+ Tests that a GROUP BY clause with a function that takes a literal
+ string argument (e.g., CONVERT(Area, 'm^2')) does not crash the
+ validation engine. This is the non-regression test for the TypeError
+ found in the _get_extractor_signature method.
+ """
+ # ARRANGE: Create a single object with a Quantity property.
+ # A 1000x1000 box gives an area of 1,000,000 mm^2, which is 1 m^2.
+ base_box = self.doc.addObject("Part::Box", "BaseBoxForConvertTest")
+ base_box.Length = 1000
+ base_box.Width = 1000
+ space = Arch.makeSpace(base_box, name="SpaceForGroupByConvertTest")
+ self.doc.recompute()
+
+ # ACT: Construct the query that was causing the crash.
+ query = """
+ SELECT
+ Label,
+ CONVERT(Area, 'm^2')
+ FROM
+ document
+ WHERE
+ Label = 'SpaceForGroupByConvertTest'
+ GROUP BY
+ Label, CONVERT(Area, 'm^2')
+ """
+
+ # ASSERT: The query should now execute without any exceptions.
+ headers, results_data = Arch.select(query)
+
+ # Assertions for the passing test
+ self.assertEqual(len(results_data), 1, "The query should return exactly one row.")
+ self.assertEqual(headers, ["Label", "CONVERT(Area, 'm^2')"])
+
+ # Check the content of the result
+ self.assertEqual(results_data[0][0], space.Label)
+ # Correctly call assertAlmostEqual with the message as a keyword argument
+ self.assertAlmostEqual(results_data[0][1], 1.0, msg="The converted area should be 1.0 m^2.")
+
+ def test_traverse_finds_all_descendants(self):
+ """
+ Tests that the basic recursive traversal finds all nested objects in a
+ simple hierarchy, following both containment (.Group) and hosting (.Hosts)
+ relationships. This is the first validation step for the new core
+ traversal function.
+ """
+ # ARRANGE: Create a multi-level hierarchy (Floor -> Wall -> Window)
+ floor = Arch.makeFloor(name="TraversalTestFloor")
+ wall = Arch.makeWall(name="TraversalTestWall")
+ win_profile = Draft.makeRectangle(1000, 1000)
+ window = Arch.makeWindow(win_profile, name="TraversalTestWindow")
+
+ # Establish the relationships
+ floor.addObject(wall) # Floor contains Wall
+ Arch.addComponents(window, host=wall) # Wall hosts Window
+ self.doc.recompute()
+
+ # ACT: Run the traversal starting from the top-level object
+ # We expect the initial object to be included in the results by default.
+ results = ArchSql._traverse_architectural_hierarchy([floor])
+ result_labels = sorted([obj.Label for obj in results])
+
+ # ASSERT: The final list must contain the initial object and all its descendants.
+ expected_labels = sorted(["TraversalTestFloor", "TraversalTestWall", "TraversalTestWindow"])
+
+ self.assertEqual(len(results), 3, "The traversal should have found 3 objects.")
+ self.assertListEqual(
+ result_labels,
+ expected_labels,
+ "The list of discovered objects does not match the expected hierarchy.",
+ )
+
+ def test_traverse_skips_generic_groups_in_results(self):
+ """
+ Tests that the traversal function transparently navigates through
+ generic App::DocumentObjectGroup objects but does not include them
+ in the final result set, ensuring the output is architecturally
+ significant.
+ """
+ # ARRANGE: Create a hierarchy with a generic group in the middle
+ # Floor -> Generic Group -> Space
+ floor = Arch.makeFloor(name="GroupTestFloor")
+ group = self.doc.addObject("App::DocumentObjectGroup", "GenericTestGroup")
+ space_profile = Draft.makeRectangle(500, 500)
+ space = Arch.makeSpace(space_profile, name="GroupTestSpace")
+
+ # Establish the relationships
+ floor.addObject(group)
+ group.addObject(space)
+ self.doc.recompute()
+
+ # ACT: Run the traversal, but this time with a flag to exclude groups
+ # The new `include_groups_in_result=False` parameter will be used here.
+ results = ArchSql._traverse_architectural_hierarchy([floor], include_groups_in_result=False)
+ result_labels = sorted([obj.Label for obj in results])
+
+ # ASSERT: The final list must contain the floor and the space,
+ # but NOT the generic group.
+ expected_labels = sorted(["GroupTestFloor", "GroupTestSpace"])
+
+ self.assertEqual(
+ len(results), 2, "The traversal should have found 2 objects (and skipped the group)."
+ )
+ self.assertListEqual(
+ result_labels,
+ expected_labels,
+ "The traversal incorrectly included the generic group in its results.",
+ )
+
+ def test_traverse_respects_max_depth(self):
+ """
+ Tests that the `max_depth` parameter correctly limits the depth of the
+ hierarchical traversal.
+ """
+ # ARRANGE: Create a 3-level hierarchy (Floor -> Wall -> Window)
+ floor = Arch.makeFloor(name="DepthTestFloor")
+ wall = Arch.makeWall(name="DepthTestWall")
+ win_profile = Draft.makeRectangle(1000, 1000)
+ window = Arch.makeWindow(win_profile, name="DepthTestWindow")
+
+ floor.addObject(wall)
+ Arch.addComponents(window, host=wall)
+ self.doc.recompute()
+
+ # --- ACT & ASSERT ---
+
+ # Sub-Test 1: max_depth = 1 (should find direct children only)
+ with self.subTest(depth=1):
+ results_depth_1 = ArchSql._traverse_architectural_hierarchy([floor], max_depth=1)
+ labels_depth_1 = sorted([o.Label for o in results_depth_1])
+ expected_labels_1 = sorted(["DepthTestFloor", "DepthTestWall"])
+ self.assertListEqual(
+ labels_depth_1,
+ expected_labels_1,
+ "With max_depth=1, should only find direct children.",
+ )
+
+ # Sub-Test 2: max_depth = 2 (should find grandchildren)
+ with self.subTest(depth=2):
+ results_depth_2 = ArchSql._traverse_architectural_hierarchy([floor], max_depth=2)
+ labels_depth_2 = sorted([o.Label for o in results_depth_2])
+ expected_labels_2 = sorted(["DepthTestFloor", "DepthTestWall", "DepthTestWindow"])
+ self.assertListEqual(
+ labels_depth_2, expected_labels_2, "With max_depth=2, should find grandchildren."
+ )
+
+ # Sub-Test 3: max_depth = 0 (unlimited, should find all)
+ with self.subTest(depth=0):
+ results_depth_0 = ArchSql._traverse_architectural_hierarchy([floor], max_depth=0)
+ labels_depth_0 = sorted([o.Label for o in results_depth_0])
+ expected_labels_0 = sorted(["DepthTestFloor", "DepthTestWall", "DepthTestWindow"])
+ self.assertListEqual(
+ labels_depth_0, expected_labels_0, "With max_depth=0, should find all descendants."
+ )
+
+ def test_sql_children_and_children_recursive_functions(self):
+ """
+ Performs a full integration test of the CHILDREN and CHILDREN_RECURSIVE
+ SQL functions, ensuring they are correctly registered with the engine
+ and call the traversal function with the correct parameters.
+ """
+ # ARRANGE: Create a multi-level hierarchy with a generic group
+ # Building -> Floor -> Generic Group -> Wall -> Window
+ building = Arch.makeBuilding(name="SQLFuncTestBuilding")
+ floor = Arch.makeFloor(name="SQLFuncTestFloor")
+ group = self.doc.addObject("App::DocumentObjectGroup", "SQLFuncTestGroup")
+ wall = Arch.makeWall(name="SQLFuncTestWall")
+ win_profile = Draft.makeRectangle(1000, 1000)
+ window = Arch.makeWindow(win_profile, name="SQLFuncTestWindow")
+
+ building.addObject(floor)
+ floor.addObject(group)
+ group.addObject(wall)
+ Arch.addComponents(window, host=wall)
+ self.doc.recompute()
+
+ # --- Sub-Test 1: CHILDREN (non-recursive, depth=1) ---
+ with self.subTest(function="CHILDREN"):
+ query_children = """
+ SELECT Label FROM CHILDREN(SELECT * FROM document WHERE Label = 'SQLFuncTestBuilding')
+ """
+ _, data = Arch.select(query_children)
+ labels = sorted([row[0] for row in data])
+ # Should only find the direct child (Floor), and not the group.
+ self.assertListEqual(labels, ["SQLFuncTestFloor"])
+
+ # --- Sub-Test 2: CHILDREN_RECURSIVE (default depth) ---
+ with self.subTest(function="CHILDREN_RECURSIVE"):
+ query_recursive = """
+ SELECT Label FROM CHILDREN_RECURSIVE(SELECT * FROM document WHERE Label = 'SQLFuncTestBuilding')
+ """
+ _, data = Arch.select(query_recursive)
+ labels = sorted([row[0] for row in data])
+ # Should find all descendants, but skip the generic group.
+ expected = sorted(["SQLFuncTestFloor", "SQLFuncTestWall", "SQLFuncTestWindow"])
+ self.assertListEqual(labels, expected)
+
+ # --- Sub-Test 3: CHILDREN_RECURSIVE (with max_depth parameter) ---
+ with self.subTest(function="CHILDREN_RECURSIVE with depth=2"):
+ query_recursive_depth = """
+ SELECT Label FROM CHILDREN_RECURSIVE(SELECT * FROM document WHERE Label = 'SQLFuncTestBuilding', 2)
+ """
+ _, data = Arch.select(query_recursive_depth)
+ labels = sorted([row[0] for row in data])
+ # Should find Floor (depth 1) and Wall (depth 2), but not Window (depth 3).
+ # The generic group at depth 2 is traversed but skipped in results.
+ expected = sorted(["SQLFuncTestFloor", "SQLFuncTestWall"])
+ self.assertListEqual(labels, expected)
+
+ def test_default_header_uses_internal_units(self):
+ """
+ Tests that when a Quantity property is selected, the generated header
+ uses the object's internal unit (e.g., 'mm') to match the raw data.
+ This test temporarily changes the unit schema to ensure it is
+ independent of user preferences.
+ """
+ # ARRANGE: Get the user's current schema to restore it later.
+ original_schema_index = FreeCAD.Units.getSchema()
+
+ try:
+ # Get the list of available schema names.
+ schema_names = FreeCAD.Units.listSchemas()
+ # Find the index for "Meter decimal", which is guaranteed to use 'm'.
+ meter_schema_index = schema_names.index("MeterDecimal")
+
+ # Set the schema, forcing getUserPreferred() to return 'm'.
+ FreeCAD.Units.setSchema(meter_schema_index)
+
+ # ARRANGE: Create a simple object with a known internal unit ('mm').
+ box = self.doc.addObject("Part::Box", "UnitHeaderTestBox")
+ box.Length = 1500.0 # This is 1500 mm
+ self.doc.recompute()
+
+ report = Arch.makeReport(name="UnitHeaderTestReport")
+ report.Proxy.live_statements[0].query_string = (
+ "SELECT Label, Length FROM document WHERE Name = 'UnitHeaderTestBox'"
+ )
+ report.Proxy.commit_statements()
+
+ # ACT: Execute the report.
+ self.doc.recompute()
+
+ # ASSERT: Check the headers in the resulting spreadsheet.
+ spreadsheet = report.Target
+ self.assertIsNotNone(spreadsheet)
+
+ header_length = spreadsheet.get("B1")
+
+ self.assertEqual(header_length, "Length (mm)")
+
+ finally:
+ # CLEANUP: Always restore the user's original schema.
+ FreeCAD.Units.setSchema(original_schema_index)
+
+ def test_numeric_comparisons_on_quantities(self):
+ """
+ Tests that all numeric comparison operators (>, <, >=, <=, =, !=)
+ work correctly on Quantity properties, independent of the current
+ unit schema. This ensures numeric comparisons are not affected by
+ string formatting or locales.
+ """
+ # ARRANGE: Get the user's current schema to restore it later.
+ original_schema_index = FreeCAD.Units.getSchema()
+
+ try:
+ # Set a "smart" schema (MKS) that uses different display units
+ # based on thresholds. This creates the most challenging scenario
+ # for string-based comparisons.
+ schema_names = FreeCAD.Units.listSchemas()
+ mks_schema_index = schema_names.index("MKS")
+ FreeCAD.Units.setSchema(mks_schema_index)
+
+ # ARRANGE: Create a set of objects above, below, and at the threshold.
+ threshold = 8000.0
+ test_prefix = "NumericTestWall_"
+ Arch.makeWall(name=test_prefix + "TallWall", height=threshold + 2000)
+ Arch.makeWall(name=test_prefix + "ShortWall", height=threshold - 1000)
+ Arch.makeWall(name=test_prefix + "ExactWall", height=threshold)
+ self.doc.recompute()
+
+ test_cases = {
+ ">": [test_prefix + "TallWall"],
+ "<": [test_prefix + "ShortWall"],
+ ">=": [test_prefix + "TallWall", test_prefix + "ExactWall"],
+ "<=": [test_prefix + "ShortWall", test_prefix + "ExactWall"],
+ "=": [test_prefix + "ExactWall"],
+ "!=": [test_prefix + "TallWall", test_prefix + "ShortWall"],
+ }
+
+ for op, expected_names in test_cases.items():
+ with self.subTest(operator=op):
+ # ACT: The query is isolated to only the walls from this test.
+ query = f"SELECT Label FROM document WHERE Label LIKE '{test_prefix}%' AND Height {op} {threshold}"
+ _, results_data = Arch.select(query)
+
+ # ASSERT: Check that the correct objects were returned.
+ result_labels = [row[0] for row in results_data]
+ self.assertCountEqual(
+ result_labels,
+ expected_names,
+ f"Query with operator '{op}' returned incorrect objects.",
+ )
+
+ finally:
+ # CLEANUP: Always restore the user's original schema.
+ FreeCAD.Units.setSchema(original_schema_index)
diff --git a/src/Mod/BIM/bimtests/TestArchReportGui.py b/src/Mod/BIM/bimtests/TestArchReportGui.py
new file mode 100644
index 0000000000..c37642a15e
--- /dev/null
+++ b/src/Mod/BIM/bimtests/TestArchReportGui.py
@@ -0,0 +1,193 @@
+"""GUI tests for ArchReport features that require a running FreeCAD GUI.
+
+These tests inherit from the GUI test base `TestArchBaseGui` which skips
+the whole class when `FreeCAD.GuiUp` is False.
+"""
+
+import FreeCAD
+import Arch
+import FreeCADGui
+import ArchReport
+
+from bimtests.TestArchBaseGui import TestArchBaseGui
+
+
+class TestArchReportGui(TestArchBaseGui):
+ """GUI-enabled tests ported from TestArchReport.
+
+ These tests rely on Qt widgets and the BIM workbench UI panels.
+ """
+
+ def setUp(self):
+ super().setUp()
+ self.doc = self.document
+ self.panel = None
+
+ # Recreate the same minimal scene that TestArchReport.setUp creates
+ self.wall_ext = Arch.makeWall(length=1000, name="Exterior Wall")
+ self.wall_ext.IfcType = "Wall"
+ self.wall_ext.Height = FreeCAD.Units.Quantity(3000, "mm")
+
+ self.wall_int = Arch.makeWall(length=500, name="Interior partition wall")
+ self.wall_int.IfcType = "Wall"
+ self.wall_int.Height = FreeCAD.Units.Quantity(2500, "mm")
+
+ self.column = Arch.makeStructure(length=300, width=330, height=2000, name="Main Column")
+ self.column.IfcType = "Column"
+
+ self.beam = Arch.makeStructure(length=2000, width=200, height=400, name="Main Beam")
+ self.beam.IfcType = "Beam"
+
+ self.window = Arch.makeWindow(name="Living Room Window")
+ self.window.IfcType = "Window"
+
+ self.part_box = self.doc.addObject("Part::Box", "Generic Box")
+
+ # Spreadsheet used by some report features
+ self.spreadsheet = self.doc.addObject("Spreadsheet::Sheet", "ReportTarget")
+ self.doc.recompute()
+
+ def tearDown(self):
+ # This method is automatically called after EACH test function.
+ if self.panel:
+ # If a panel was created, ensure it is closed.
+ FreeCADGui.Control.closeDialog()
+ self.panel = None # Clear the reference
+ super().tearDown()
+
+ def test_cheatsheet_dialog_creation(self):
+ """Tests that the Cheatsheet dialog can be created without errors."""
+ api_data = Arch.getSqlApiDocumentation()
+ dialog = ArchReport.CheatsheetDialog(api_data)
+ self.assertIsNotNone(dialog)
+
+ def DISABLED_test_preview_pane_toggle_and_refresh(self):
+ """
+ Tests the user workflow for the preview pane: toggling visibility,
+ refreshing with a valid query, and checking the results.
+ This replaces the obsolete test_task_panel_on_demand_preview.
+ """
+ # 1. Arrange: Create a report object and the task panel.
+ report_obj = Arch.makeReport(name="PreviewToggleTestReport")
+ self.panel = ArchReport.ReportTaskPanel(report_obj)
+ # Open the editor for the first (default) statement.
+ self.panel._start_edit_session(row_index=0)
+
+ # 2. Assert Initial State: The preview pane should be hidden.
+ self.assertFalse(
+ self.panel.preview_pane.isVisible(), "Preview pane should be hidden by default."
+ )
+
+ # 3. Act: Toggle the preview pane to show it.
+ # A user click on a checkable button toggles its checked state.
+ self.panel.btn_toggle_preview.setChecked(True)
+ self.pump_gui_events()
+
+ # 4. Assert Visibility: The pane and its contents should now be visible.
+ self.assertTrue(
+ self.panel.preview_pane.isVisible(),
+ "Preview pane should be visible after toggling it on.",
+ )
+ self.assertEqual(
+ self.panel.btn_toggle_preview.text(),
+ "Hide Preview",
+ "Button text should update to 'Hide Preview'.",
+ )
+ self.assertTrue(
+ self.panel.btn_refresh_preview.isVisible(),
+ "Refresh button should be visible when pane is open.",
+ )
+
+ # 5. Act: Set a valid query and refresh the preview.
+ query = "SELECT Label, IfcType FROM document WHERE IfcType = 'Wall' ORDER BY Label"
+ self.panel.sql_query_edit.setPlainText(query)
+ self.panel.btn_refresh_preview.click()
+ self.pump_gui_events()
+
+ # 6. Assert Correctness: The preview table should be populated correctly.
+ self.assertEqual(
+ self.panel.table_preview_results.columnCount(),
+ 2,
+ "Preview table should have 2 columns for the valid query.",
+ )
+ self.assertEqual(
+ self.panel.table_preview_results.rowCount(),
+ 2,
+ "Preview table should have 2 rows for the two wall objects.",
+ )
+ # Check cell content to confirm the query ran correctly.
+ self.assertEqual(self.panel.table_preview_results.item(0, 0).text(), self.wall_ext.Label)
+ self.assertEqual(self.panel.table_preview_results.item(1, 1).text(), self.wall_int.IfcType)
+
+ # 7. Act: Toggle the preview pane to hide it again.
+ self.panel.btn_toggle_preview.setChecked(False)
+ self.pump_gui_events()
+
+ # 8. Assert Final State: The pane should be hidden.
+ self.assertFalse(
+ self.panel.preview_pane.isVisible(),
+ "Preview pane should be hidden after toggling it off.",
+ )
+ self.assertEqual(
+ self.panel.btn_toggle_preview.text(),
+ "Show Preview",
+ "Button text should revert to 'Show Preview'.",
+ )
+
+ def DISABLED_test_preview_pane_displays_errors_gracefully(self):
+ """
+ Tests that the preview pane displays a user-friendly error message when
+ the query is invalid, instead of raising an exception.
+ This replaces the obsolete test_preview_button_handles_errors_gracefully_in_ui.
+ """
+ # 1. Arrange: Create the report and panel, then open the editor and preview.
+ report_obj = Arch.makeReport(name="PreviewErrorTestReport")
+ self.panel = ArchReport.ReportTaskPanel(report_obj)
+ self.panel._start_edit_session(row_index=0)
+ self.panel.btn_toggle_preview.setChecked(True)
+ self.pump_gui_events()
+
+ # 2. Act: Set an invalid query and click the refresh button.
+ invalid_query = "SELECT Label FRM document" # Deliberate syntax error
+ self.panel.sql_query_edit.setPlainText(invalid_query)
+ self.panel.btn_refresh_preview.click()
+ self.pump_gui_events()
+
+ # 3. Assert: The preview table should be visible and display the error.
+ self.assertTrue(
+ self.panel.table_preview_results.isVisible(),
+ "Preview table should remain visible to display the error.",
+ )
+ self.assertEqual(
+ self.panel.table_preview_results.rowCount(),
+ 1,
+ "Error display should occupy a single row.",
+ )
+ self.assertEqual(
+ self.panel.table_preview_results.columnCount(),
+ 1,
+ "Error display should occupy a single column.",
+ )
+
+ error_item = self.panel.table_preview_results.item(0, 0)
+ self.assertIsNotNone(error_item, "An error item should have been placed in the table.")
+ # Check for keywords that indicate a graceful error message.
+ self.assertIn(
+ "Syntax Error", error_item.text(), "The error message should indicate a syntax error."
+ )
+ self.assertIn(
+ "❌", error_item.text(), "The error message should contain a visual error indicator."
+ )
+
+ def test_hover_tooltips(self):
+ """Tests that the SQL editor can generate tooltips."""
+ editor = ArchReport.SqlQueryEditor()
+ api_docs = Arch.getSqlApiDocumentation()
+ editor.set_api_documentation(api_docs)
+
+ func_tooltip = editor._get_tooltip_for_word("CONVERT")
+ self.assertIn("CONVERT(quantity, 'unit')", func_tooltip)
+ self.assertIn("Utility", func_tooltip)
+
+ clause_tooltip = editor._get_tooltip_for_word("SELECT")
+ self.assertIn("SQL Clause", clause_tooltip)
diff --git a/src/Mod/BIM/bimtests/fixtures/BimFixtures.py b/src/Mod/BIM/bimtests/fixtures/BimFixtures.py
new file mode 100644
index 0000000000..c9068abcae
--- /dev/null
+++ b/src/Mod/BIM/bimtests/fixtures/BimFixtures.py
@@ -0,0 +1,214 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+#
+# Copyright (c) 2025 Furgo
+
+"""Reusable BIM test fixtures.
+
+Exports:
+ - create_test_model(document, **overrides)
+ - DEFAULTS, LABELS
+
+This module centralizes the complex sample model used across multiple tests.
+"""
+
+import FreeCAD
+import Arch
+import Draft
+
+__all__ = ["create_test_model", "DEFAULTS", "LABELS"]
+
+# Canonical defaults used by the fixture
+DEFAULTS = {
+ "building_length": 4000.0,
+ "building_width": 3200.0,
+ "ground_floor_height": 3200.0,
+ "interior_wall_height": 3000.0,
+ "slab_thickness": 200.0,
+ "roof_overhang": 200.0,
+}
+
+# Canonical labels used by the fixture for predictable queries
+LABELS = {
+ "site": "Main Site",
+ "building": "Main Building",
+ "ground_floor": "Ground Floor",
+ "upper_floor": "Upper Floor",
+ "exterior_wall": "Exterior Wall",
+ "interior_wall": "Interior Partition",
+ "front_door": "Front Door",
+ "living_window": "Living Room Window",
+ "office_space": "Office Space",
+ "living_space": "Living Space",
+ "generic_box": "Generic Box",
+}
+
+
+def create_test_model(document, **overrides):
+ """Create a complete, standard BIM model in the provided document.
+
+ The function returns a dict of key objects for tests to reference. It
+ accepts optional overrides for numeric defaults via keyword args that map
+ to keys in DEFAULTS (e.g. building_length=5000.0).
+
+ Returns None if document is falsy.
+ """
+ doc = document
+ if not doc:
+ FreeCAD.Console.PrintError(
+ "Error: No active document found. Please create a new document first.\n"
+ )
+ return {}
+
+ # Merge defaults with overrides
+ cfg = DEFAULTS.copy()
+ cfg.update(overrides)
+
+ building_length = cfg["building_length"]
+ building_width = cfg["building_width"]
+ ground_floor_height = cfg["ground_floor_height"]
+ interior_wall_height = cfg["interior_wall_height"]
+ slab_thickness = cfg["slab_thickness"]
+ roof_overhang = cfg["roof_overhang"]
+
+ # --- 1. BIM Hierarchy (Site, Building, Levels) ---
+ site = Arch.makeSite(name=LABELS["site"])
+ building = Arch.makeBuilding(name=LABELS["building"])
+ site.addObject(building)
+
+ level_0 = Arch.makeFloor(name=LABELS["ground_floor"])
+ level_0.Height = ground_floor_height
+ level_1 = Arch.makeFloor(name=LABELS["upper_floor"])
+ level_1.Height = ground_floor_height
+ level_1.Placement.Base.z = ground_floor_height
+ building.addObject(level_0)
+ building.addObject(level_1)
+
+ # --- 2. Ground Floor Walls ---
+ p1 = FreeCAD.Vector(0, 0, 0)
+ p2 = FreeCAD.Vector(building_length, 0, 0)
+ p3 = FreeCAD.Vector(building_length, building_width, 0)
+ p4 = FreeCAD.Vector(0, building_width, 0)
+ exterior_wire = Draft.makeWire([p1, p2, p3, p4], closed=True)
+ exterior_wall = Arch.makeWall(
+ exterior_wire, name=LABELS["exterior_wall"], height=ground_floor_height
+ )
+ level_0.addObject(exterior_wall)
+
+ p5 = FreeCAD.Vector(building_length / 2, 0, 0)
+ p6 = FreeCAD.Vector(building_length / 2, building_width, 0)
+ interior_wire = Draft.makeWire([p5, p6])
+ interior_wall = Arch.makeWall(
+ interior_wire, name=LABELS["interior_wall"], height=interior_wall_height
+ )
+ interior_wall.Width = 100.0
+ level_0.addObject(interior_wall)
+
+ doc.recompute()
+
+ # --- 3. Openings (Doors and Windows) ---
+ door = Arch.makeWindowPreset(
+ "Simple door", width=900, height=2100, h1=50, h2=50, h3=50, w1=100, w2=40, o1=0, o2=0
+ )
+ door.Placement = FreeCAD.Placement(
+ FreeCAD.Vector(800, 0, 0), FreeCAD.Rotation(FreeCAD.Vector(1, 0, 0), 90)
+ )
+ door.Label = LABELS["front_door"]
+ door.Hosts = [exterior_wall]
+
+ window = Arch.makeWindowPreset(
+ "Open 1-pane", width=1500, height=1200, h1=50, h2=50, h3=50, w1=100, w2=50, o1=0, o2=50
+ )
+ window.Placement = FreeCAD.Placement(
+ FreeCAD.Vector(building_length, building_width / 2, 900),
+ FreeCAD.Rotation(FreeCAD.Vector(0, 1, 0), 270),
+ )
+ window.Label = LABELS["living_window"]
+ window.Hosts = [exterior_wall]
+
+ doc.recompute()
+
+ # --- 4. Spaces (from Volumetric Shapes) ---
+ office_box = doc.addObject("Part::Box", "OfficeVolume")
+ office_box.Length = building_length / 2
+ office_box.Width = building_width
+ office_box.Height = interior_wall_height
+ room1_space = Arch.makeSpace(office_box, name=LABELS["office_space"])
+ level_0.addObject(room1_space)
+
+ living_box = doc.addObject("Part::Box", "LivingVolume")
+ living_box.Length = building_length / 2
+ living_box.Width = building_width
+ living_box.Height = interior_wall_height
+ living_box.Placement.Base = FreeCAD.Vector(building_length / 2, 0, 0)
+ room2_space = Arch.makeSpace(living_box, name=LABELS["living_space"])
+ level_0.addObject(room2_space)
+
+ doc.recompute()
+
+ # --- 5. Structural Elements ---
+ column = Arch.makeStructure(
+ length=200, width=200, height=interior_wall_height, name="Main Column"
+ )
+ column.IfcType = "Column"
+ column.Placement.Base = FreeCAD.Vector(
+ (building_length / 2) - 100, (building_width / 2) - 100, 0
+ )
+ level_0.addObject(column)
+
+ beam = Arch.makeStructure(length=building_length, width=150, height=300, name="Main Beam")
+ beam.IfcType = "Beam"
+ beam.Placement = FreeCAD.Placement(
+ FreeCAD.Vector(0, building_width / 2, interior_wall_height), FreeCAD.Rotation()
+ )
+ level_0.addObject(beam)
+
+ # --- 6. Upper Floor Slab and Roof ---
+ slab_profile = Draft.makeRectangle(
+ length=building_length,
+ height=building_width,
+ placement=FreeCAD.Placement(FreeCAD.Vector(0, 0, interior_wall_height), FreeCAD.Rotation()),
+ )
+ slab = Arch.makeStructure(slab_profile, height=slab_thickness, name="Floor Slab")
+ slab.IfcType = "Slab"
+ level_1.addObject(slab)
+
+ roof_profile = Draft.makeRectangle(
+ length=building_length + (2 * roof_overhang),
+ height=building_width + (2 * roof_overhang),
+ placement=FreeCAD.Placement(
+ FreeCAD.Vector(-roof_overhang, -roof_overhang, ground_floor_height), FreeCAD.Rotation()
+ ),
+ )
+ doc.recompute()
+
+ safe_run = (max(roof_profile.Length.Value, roof_profile.Height.Value) / 2) + 100
+
+ roof = Arch.makeRoof(roof_profile, angles=[30.0] * 4, run=[safe_run] * 4, name="Main Roof")
+ level_1.addObject(roof)
+
+ # --- 7. Non-BIM Object ---
+ generic_box = doc.addObject("Part::Box", LABELS["generic_box"])
+ generic_box.Placement.Base = FreeCAD.Vector(building_length + 1000, building_width + 1000, 0)
+
+ # --- 8. Custom Dynamic Property ---
+ exterior_wall.addProperty("App::PropertyString", "FireRating", "BIM")
+ exterior_wall.FireRating = "60 minutes"
+
+ # --- Final Step: Recompute and return references ---
+ doc.recompute()
+
+ model_objects = {
+ "site": site,
+ "building": building,
+ "ground_floor": level_0,
+ "upper_floor": level_1,
+ "exterior_wall": exterior_wall,
+ "interior_wall": interior_wall,
+ "front_door": door,
+ "living_window": window,
+ "office_space": room1_space,
+ "living_space": room2_space,
+ "column": column,
+ "slab": slab,
+ }
+ return model_objects
diff --git a/src/Mod/BIM/bimtests/fixtures/__init__.py b/src/Mod/BIM/bimtests/fixtures/__init__.py
new file mode 100644
index 0000000000..074c354847
--- /dev/null
+++ b/src/Mod/BIM/bimtests/fixtures/__init__.py
@@ -0,0 +1,6 @@
+"""Fixtures package for BIM test helpers.
+
+This package contains reusable test fixtures for the BIM module tests.
+"""
+
+__all__ = []