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"

{func_data['signature']}
" + f"{func_data['category']}
" + f"{func_data['description']}

" + ) + + # 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}:" + 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.svg icons/BIM_Reextrude.svg icons/BIM_Reorder.svg + icons/BIM_Report.svg icons/BIM_ResetCloneColors.svg icons/BIM_Rewire.svg icons/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 @@ + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + FreeCAD LGPL2+ + + + + + FreeCAD + + + https://www.freecad.org/wiki/index.php?title=Artwork + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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__ = []