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__ = []