From 61e2ffc74e43e3a8720ce0e1e230f3216154c75b Mon Sep 17 00:00:00 2001 From: PoMa Date: Thu, 28 May 2026 13:39:44 +0200 Subject: [PATCH 1/7] [ADD] database: add unused-fields command to detect orphaned x_ fields Scans a local Odoo database for custom fields (x_ prefix) that are not referenced in views, server actions, automations, filters, record rules, mail templates, reports, or exports. Outputs a table or CSV with model, field name, and description. Ported and integrated from a standalone psycopg2 script. Co-Authored-By: Claude Sonnet 4.6 --- odev/commands/database/unused_fields.py | 133 ++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 odev/commands/database/unused_fields.py diff --git a/odev/commands/database/unused_fields.py b/odev/commands/database/unused_fields.py new file mode 100644 index 0000000..ddf222b --- /dev/null +++ b/odev/commands/database/unused_fields.py @@ -0,0 +1,133 @@ +"""Find custom fields (x_ prefix) that are not referenced anywhere in the database.""" + +import csv +import re +from io import StringIO + +from odev.common import args, progress +from odev.common.commands import LocalDatabaseCommand +from odev.common.console import TableHeader +from odev.common.logging import logging + + +logger = logging.getLogger(__name__) + + +class UnusedFieldsCommand(LocalDatabaseCommand): + """Find custom fields (prefixed with x_) that appear unused across views, server actions, + automations, filters, record rules, mail templates, reports, exports, and computed/related + field definitions. + """ + + _name = "unused-fields" + _aliases = ["uf"] + + csv = args.Flag(aliases=["--csv"], description="Format output as CSV.") + + _CONTENT_QUERIES: list[tuple[str, list[str]]] = [ + ("SELECT arch_db FROM ir_ui_view", ["arch_db"]), + ("SELECT code FROM ir_act_server WHERE code IS NOT NULL", ["code"]), + ("SELECT compute, related FROM ir_model_fields WHERE compute IS NOT NULL OR related IS NOT NULL", ["compute", "related"]), + ("SELECT domain FROM ir_filters WHERE domain IS NOT NULL", ["domain"]), + ("SELECT domain_force FROM ir_rule WHERE domain_force IS NOT NULL", ["domain_force"]), + ] + """Queries that always exist in an Odoo database.""" + + _OPTIONAL_CONTENT_QUERIES: list[tuple[str, str, list[str]]] = [ + ("mail_template", "SELECT body_html, subject FROM mail_template", ["body_html", "subject"]), + ("ir_actions_report", "SELECT help FROM ir_actions_report WHERE help IS NOT NULL", ["help"]), + ("ir_exports_line", "SELECT name FROM ir_exports_line WHERE name IS NOT NULL", ["name"]), + ("base_automation", "SELECT filter_pre_domain, filter_domain FROM base_automation", ["filter_pre_domain", "filter_domain"]), + ] + """Queries that require checking table existence first.""" + + def run(self): + with progress.spinner("Collecting x_ fields"): + fields = self._fetch_x_fields() + + if not fields: + logger.info("No custom x_ fields found in this database.") + return + + logger.debug(f"Found {len(fields)} x_ fields, scanning for usage...") + + with progress.spinner("Scanning database for field usage"): + all_content = self._collect_search_content() + + with progress.spinner("Detecting unused fields"): + unused = self._find_unused(fields, all_content) + + if not unused: + logger.info("All custom x_ fields appear to be in use.") + return + + headers = [ + TableHeader("Model"), + TableHeader("Field Name"), + TableHeader("Description"), + ] + rows = [[model, field_name, self._extract_label(description)] for model, field_name, description in unused] + + if self.args.csv: + self.print(self._format_csv([h.title for h in headers], rows)) + else: + self.table(headers, rows, title=f"Unused x_ Fields ({len(unused)} of {len(fields)})") + self.console.clear_line() + + def _fetch_x_fields(self) -> list[tuple[str, str, str]]: + """Return all (model, name, field_description) rows for fields starting with x_.""" + with self._database.psql() as psql: + result = psql.query( + "SELECT model, name, field_description FROM ir_model_fields WHERE name LIKE 'x_%'" + ) + return result or [] + + def _table_exists(self, psql, table: str) -> bool: + """Check whether a table exists in the database.""" + result = psql.query( + f"SELECT COUNT(*) FROM information_schema.tables WHERE table_name = '{table}'" + ) + return bool(result and result[0][0]) + + def _collect_search_content(self) -> str: + """Gather all searchable content from the database and return it as one concatenated string.""" + parts: list[str] = [] + + with self._database.psql() as psql: + for query, _ in self._CONTENT_QUERIES: + rows = psql.query(query) or [] + for row in rows: + parts.extend(str(v) for v in row if v) + + for table, query, _ in self._OPTIONAL_CONTENT_QUERIES: + if self._table_exists(psql, table): + rows = psql.query(query) or [] + for row in rows: + parts.extend(str(v) for v in row if v) + + return " ".join(parts) + + def _find_unused( + self, fields: list[tuple[str, str, str]], content: str + ) -> list[tuple[str, str, str]]: + """Return fields whose name does not appear (as a whole word) in the content string.""" + unused = [] + for model, field_name, description in fields: + pattern = rf"\b{re.escape(field_name)}\b" + if not re.search(pattern, content): + unused.append((model, field_name, description)) + return sorted(unused) + + def _extract_label(self, description) -> str: + """Return a plain string label from a field_description value (may be dict or str).""" + if isinstance(description, dict): + return description.get("en_US") or next(iter(description.values()), "") + return description or "" + + def _format_csv(self, headers: list[str], rows: list[list[str]]) -> str: + """Format rows as a CSV string.""" + output = StringIO() + writer = csv.writer(output) + writer.writerow(headers) + writer.writerows(rows) + return output.getvalue() From e3a6517a0b4168b79393e3f919a3723868a8e3f5 Mon Sep 17 00:00:00 2001 From: PoMa Date: Thu, 28 May 2026 13:45:15 +0200 Subject: [PATCH 2/7] [FIX] database: connect to target db and exclude x_plan fields - Use self._database.psql(name) instead of the default postgres connector - Exclude fields prefixed with x_plan from the unused field results Co-Authored-By: Claude Sonnet 4.6 --- odev/commands/database/unused_fields.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/odev/commands/database/unused_fields.py b/odev/commands/database/unused_fields.py index ddf222b..5509ee7 100644 --- a/odev/commands/database/unused_fields.py +++ b/odev/commands/database/unused_fields.py @@ -74,11 +74,16 @@ def run(self): self.table(headers, rows, title=f"Unused x_ Fields ({len(unused)} of {len(fields)})") self.console.clear_line() + def _psql(self): + """Return a connector to the target database (not the default 'postgres' db).""" + return self._database.psql(self._database.name) + def _fetch_x_fields(self) -> list[tuple[str, str, str]]: """Return all (model, name, field_description) rows for fields starting with x_.""" - with self._database.psql() as psql: + with self._psql() as psql: result = psql.query( "SELECT model, name, field_description FROM ir_model_fields WHERE name LIKE 'x_%'" + " AND name NOT LIKE 'x\\_plan%'" ) return result or [] @@ -93,7 +98,7 @@ def _collect_search_content(self) -> str: """Gather all searchable content from the database and return it as one concatenated string.""" parts: list[str] = [] - with self._database.psql() as psql: + with self._psql() as psql: for query, _ in self._CONTENT_QUERIES: rows = psql.query(query) or [] for row in rows: From 8d6837a13bfb50e16922216e93f73fc7cbc30abf Mon Sep 17 00:00:00 2001 From: PoMa Date: Thu, 28 May 2026 13:51:16 +0200 Subject: [PATCH 3/7] [IMP] database: improve log messages in unused-fields command Co-Authored-By: Claude Sonnet 4.6 --- odev/commands/database/unused_fields.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/odev/commands/database/unused_fields.py b/odev/commands/database/unused_fields.py index 5509ee7..047e467 100644 --- a/odev/commands/database/unused_fields.py +++ b/odev/commands/database/unused_fields.py @@ -46,10 +46,10 @@ def run(self): fields = self._fetch_x_fields() if not fields: - logger.info("No custom x_ fields found in this database.") + logger.info("No custom fields found in this database.") return - logger.debug(f"Found {len(fields)} x_ fields, scanning for usage...") + logger.debug(f"Found {len(fields)} custom field(s), scanning for usage...") with progress.spinner("Scanning database for field usage"): all_content = self._collect_search_content() @@ -58,7 +58,7 @@ def run(self): unused = self._find_unused(fields, all_content) if not unused: - logger.info("All custom x_ fields appear to be in use.") + logger.info("All custom fields appear to be in use.") return headers = [ From 362f658f3d32256186a1679ea9c4055b3b9a35bf Mon Sep 17 00:00:00 2001 From: PoMa Date: Thu, 28 May 2026 13:55:08 +0200 Subject: [PATCH 4/7] [IMP] database: flag fields with no meaningful data as unused Fields that are referenced in views/code but contain only null/false/0/empty values are now also reported as unused (reason: "no data"). Checked per ttype: - boolean: no TRUE rows - integer/float/monetary: all NULL or 0 - char/text/html/selection: all NULL or empty string - many2one/date/datetime/reference: all NULL - binary/one2many/many2many: skipped Co-Authored-By: Claude Sonnet 4.6 --- odev/commands/database/unused_fields.py | 77 ++++++++++++++++++++----- 1 file changed, 62 insertions(+), 15 deletions(-) diff --git a/odev/commands/database/unused_fields.py b/odev/commands/database/unused_fields.py index 047e467..71a5c60 100644 --- a/odev/commands/database/unused_fields.py +++ b/odev/commands/database/unused_fields.py @@ -12,11 +12,19 @@ logger = logging.getLogger(__name__) +# Field types where we skip the data-presence check entirely +_SKIP_DATA_CHECK = frozenset({"binary", "one2many", "many2many"}) + +# Reason labels shown in output +_REASON_NOT_REFERENCED = "not referenced" +_REASON_NO_DATA = "no data" + class UnusedFieldsCommand(LocalDatabaseCommand): """Find custom fields (prefixed with x_) that appear unused across views, server actions, automations, filters, record rules, mail templates, reports, exports, and computed/related - field definitions. + field definitions. Also flags fields that are referenced in views but contain no meaningful + data in the database. """ _name = "unused-fields" @@ -65,8 +73,12 @@ def run(self): TableHeader("Model"), TableHeader("Field Name"), TableHeader("Description"), + TableHeader("Reason"), + ] + rows = [ + [model, field_name, self._extract_label(description), reason] + for model, field_name, description, reason in unused ] - rows = [[model, field_name, self._extract_label(description)] for model, field_name, description in unused] if self.args.csv: self.print(self._format_csv([h.title for h in headers], rows)) @@ -78,12 +90,12 @@ def _psql(self): """Return a connector to the target database (not the default 'postgres' db).""" return self._database.psql(self._database.name) - def _fetch_x_fields(self) -> list[tuple[str, str, str]]: - """Return all (model, name, field_description) rows for fields starting with x_.""" + def _fetch_x_fields(self) -> list[tuple]: + """Return all x_ fields (excluding x_plan) as (model, name, description, ttype, store).""" with self._psql() as psql: result = psql.query( - "SELECT model, name, field_description FROM ir_model_fields WHERE name LIKE 'x_%'" - " AND name NOT LIKE 'x\\_plan%'" + "SELECT model, name, field_description, ttype, store " + "FROM ir_model_fields WHERE name LIKE 'x_%' AND name NOT LIKE 'x\\_plan%'" ) return result or [] @@ -112,16 +124,51 @@ def _collect_search_content(self) -> str: return " ".join(parts) - def _find_unused( - self, fields: list[tuple[str, str, str]], content: str - ) -> list[tuple[str, str, str]]: - """Return fields whose name does not appear (as a whole word) in the content string.""" - unused = [] - for model, field_name, description in fields: + def _find_unused(self, fields: list[tuple], all_content: str) -> list[tuple]: + """Return (model, field_name, description, reason) for all unused fields. + + A field is unused if: + - its name does not appear (as a whole word) anywhere in the scanned content, OR + - it is referenced in content but is stored and contains no meaningful data. + """ + not_referenced: list[tuple] = [] + to_check_data: list[tuple] = [] + + for model, field_name, description, ttype, store in fields: pattern = rf"\b{re.escape(field_name)}\b" - if not re.search(pattern, content): - unused.append((model, field_name, description)) - return sorted(unused) + if not re.search(pattern, all_content): + not_referenced.append((model, field_name, description, _REASON_NOT_REFERENCED)) + elif store and ttype not in _SKIP_DATA_CHECK: + to_check_data.append((model, field_name, description, ttype)) + + empty: list[tuple] = [] + if to_check_data: + with self._psql() as psql: + for model, field_name, description, ttype in to_check_data: + if not self._field_has_data(psql, model, field_name, ttype): + empty.append((model, field_name, description, _REASON_NO_DATA)) + + return sorted(not_referenced + empty) + + def _field_has_data(self, psql, model: str, field_name: str, ttype: str) -> bool: + """Return True if the field has at least one meaningful (non-empty) value in the DB.""" + table = model.replace(".", "_") + + if ttype == "boolean": + query = f'SELECT 1 FROM "{table}" WHERE "{field_name}" = TRUE LIMIT 1' + elif ttype in ("integer", "float", "monetary"): + query = f'SELECT 1 FROM "{table}" WHERE "{field_name}" IS NOT NULL AND "{field_name}" != 0 LIMIT 1' + elif ttype in ("char", "text", "html", "selection"): + query = f"SELECT 1 FROM \"{table}\" WHERE \"{field_name}\" IS NOT NULL AND \"{field_name}\" != '' LIMIT 1" + elif ttype in ("many2one", "date", "datetime", "reference"): + query = f'SELECT 1 FROM "{table}" WHERE "{field_name}" IS NOT NULL LIMIT 1' + else: + return True # unknown type — assume it has data + + try: + return bool(psql.query(query)) + except Exception: + return True # table or column missing — skip def _extract_label(self, description) -> str: """Return a plain string label from a field_description value (may be dict or str).""" From 8cb28731e86309e15f2ed7a93efb815a7cf56a92 Mon Sep 17 00:00:00 2001 From: PoMa Date: Thu, 28 May 2026 14:01:48 +0200 Subject: [PATCH 5/7] [IMP] database: rename and restructure unused-fields as check-unused - Rename command to check-unused (alias: cu), file to check_unused.py - Add --fields flag to explicitly select the x_ fields check - Add --all-fields flag stub (raises NotImplementedError) - Replace --csv stdout flag with --save [FILE] that writes to disk (defaults to unused_fields.csv when no path given) - Running without a check flag now shows a helpful error Co-Authored-By: Claude Sonnet 4.6 --- .../{unused_fields.py => check_unused.py} | 86 +++++++++++-------- 1 file changed, 51 insertions(+), 35 deletions(-) rename odev/commands/database/{unused_fields.py => check_unused.py} (72%) diff --git a/odev/commands/database/unused_fields.py b/odev/commands/database/check_unused.py similarity index 72% rename from odev/commands/database/unused_fields.py rename to odev/commands/database/check_unused.py index 71a5c60..a6e72cb 100644 --- a/odev/commands/database/unused_fields.py +++ b/odev/commands/database/check_unused.py @@ -1,8 +1,9 @@ -"""Find custom fields (x_ prefix) that are not referenced anywhere in the database.""" +"""Check a database for unused custom fields.""" import csv import re from io import StringIO +from pathlib import Path from odev.common import args, progress from odev.common.commands import LocalDatabaseCommand @@ -12,25 +13,40 @@ logger = logging.getLogger(__name__) -# Field types where we skip the data-presence check entirely _SKIP_DATA_CHECK = frozenset({"binary", "one2many", "many2many"}) -# Reason labels shown in output _REASON_NOT_REFERENCED = "not referenced" _REASON_NO_DATA = "no data" +_DEFAULT_CSV_FILENAME = "unused_fields.csv" -class UnusedFieldsCommand(LocalDatabaseCommand): - """Find custom fields (prefixed with x_) that appear unused across views, server actions, - automations, filters, record rules, mail templates, reports, exports, and computed/related - field definitions. Also flags fields that are referenced in views but contain no meaningful - data in the database. - """ - _name = "unused-fields" - _aliases = ["uf"] +class CheckUnusedCommand(LocalDatabaseCommand): + """Check a database for unused custom resources. + + Use --fields to detect x_ custom fields that are either not referenced anywhere + (views, server actions, automations, filters, rules, templates, reports, exports) + or are referenced but contain no meaningful data. + """ - csv = args.Flag(aliases=["--csv"], description="Format output as CSV.") + _name = "check-unused" + _aliases = ["cu"] + + fields = args.Flag( + aliases=["--fields"], + description="Check for unused x_ custom fields (excludes x_plan fields).", + ) + all_fields = args.Flag( + aliases=["--all-fields"], + description="Check for unused fields across all non-standard fields, not just x_ ones. (not yet implemented)", + ) + save = args.String( + aliases=["--save"], + description=f"Save results to a CSV file instead of printing to the terminal. " + f"Defaults to '{_DEFAULT_CSV_FILENAME}' when no path is given.", + nargs="?", + const=_DEFAULT_CSV_FILENAME, + ) _CONTENT_QUERIES: list[tuple[str, list[str]]] = [ ("SELECT arch_db FROM ir_ui_view", ["arch_db"]), @@ -39,7 +55,6 @@ class UnusedFieldsCommand(LocalDatabaseCommand): ("SELECT domain FROM ir_filters WHERE domain IS NOT NULL", ["domain"]), ("SELECT domain_force FROM ir_rule WHERE domain_force IS NOT NULL", ["domain_force"]), ] - """Queries that always exist in an Odoo database.""" _OPTIONAL_CONTENT_QUERIES: list[tuple[str, str, list[str]]] = [ ("mail_template", "SELECT body_html, subject FROM mail_template", ["body_html", "subject"]), @@ -47,17 +62,29 @@ class UnusedFieldsCommand(LocalDatabaseCommand): ("ir_exports_line", "SELECT name FROM ir_exports_line WHERE name IS NOT NULL", ["name"]), ("base_automation", "SELECT filter_pre_domain, filter_domain FROM base_automation", ["filter_pre_domain", "filter_domain"]), ] - """Queries that require checking table existence first.""" def run(self): + if self.args.all_fields: + raise NotImplementedError("--all-fields is not yet implemented.") + + if not self.args.fields: + raise self.error("Specify at least one check to run: --fields or --all-fields") + + self._run_fields_check() + + # ------------------------------------------------------------------ + # Fields check + # ------------------------------------------------------------------ + + def _run_fields_check(self): with progress.spinner("Collecting x_ fields"): fields = self._fetch_x_fields() if not fields: - logger.info("No custom fields found in this database.") + logger.info("No custom x_ fields found in this database.") return - logger.debug(f"Found {len(fields)} custom field(s), scanning for usage...") + logger.debug(f"Found {len(fields)} custom x_ field(s), scanning for usage...") with progress.spinner("Scanning database for field usage"): all_content = self._collect_search_content() @@ -66,7 +93,7 @@ def run(self): unused = self._find_unused(fields, all_content) if not unused: - logger.info("All custom fields appear to be in use.") + logger.info("All custom x_ fields appear to be in use.") return headers = [ @@ -80,8 +107,8 @@ def run(self): for model, field_name, description, reason in unused ] - if self.args.csv: - self.print(self._format_csv([h.title for h in headers], rows)) + if self.args.save is not None: + self._save_csv(Path(self.args.save), [h.title for h in headers], rows) else: self.table(headers, rows, title=f"Unused x_ Fields ({len(unused)} of {len(fields)})") self.console.clear_line() @@ -91,7 +118,6 @@ def _psql(self): return self._database.psql(self._database.name) def _fetch_x_fields(self) -> list[tuple]: - """Return all x_ fields (excluding x_plan) as (model, name, description, ttype, store).""" with self._psql() as psql: result = psql.query( "SELECT model, name, field_description, ttype, store " @@ -100,14 +126,12 @@ def _fetch_x_fields(self) -> list[tuple]: return result or [] def _table_exists(self, psql, table: str) -> bool: - """Check whether a table exists in the database.""" result = psql.query( f"SELECT COUNT(*) FROM information_schema.tables WHERE table_name = '{table}'" ) return bool(result and result[0][0]) def _collect_search_content(self) -> str: - """Gather all searchable content from the database and return it as one concatenated string.""" parts: list[str] = [] with self._psql() as psql: @@ -125,12 +149,6 @@ def _collect_search_content(self) -> str: return " ".join(parts) def _find_unused(self, fields: list[tuple], all_content: str) -> list[tuple]: - """Return (model, field_name, description, reason) for all unused fields. - - A field is unused if: - - its name does not appear (as a whole word) anywhere in the scanned content, OR - - it is referenced in content but is stored and contains no meaningful data. - """ not_referenced: list[tuple] = [] to_check_data: list[tuple] = [] @@ -151,7 +169,6 @@ def _find_unused(self, fields: list[tuple], all_content: str) -> list[tuple]: return sorted(not_referenced + empty) def _field_has_data(self, psql, model: str, field_name: str, ttype: str) -> bool: - """Return True if the field has at least one meaningful (non-empty) value in the DB.""" table = model.replace(".", "_") if ttype == "boolean": @@ -163,23 +180,22 @@ def _field_has_data(self, psql, model: str, field_name: str, ttype: str) -> bool elif ttype in ("many2one", "date", "datetime", "reference"): query = f'SELECT 1 FROM "{table}" WHERE "{field_name}" IS NOT NULL LIMIT 1' else: - return True # unknown type — assume it has data + return True try: return bool(psql.query(query)) except Exception: - return True # table or column missing — skip + return True def _extract_label(self, description) -> str: - """Return a plain string label from a field_description value (may be dict or str).""" if isinstance(description, dict): return description.get("en_US") or next(iter(description.values()), "") return description or "" - def _format_csv(self, headers: list[str], rows: list[list[str]]) -> str: - """Format rows as a CSV string.""" + def _save_csv(self, path: Path, headers: list[str], rows: list[list[str]]) -> None: output = StringIO() writer = csv.writer(output) writer.writerow(headers) writer.writerows(rows) - return output.getvalue() + path.write_text(output.getvalue(), encoding="utf-8") + logger.info(f"Results saved to {path.resolve()}") From 6192cff9eb8f42025e6b7e74fc6f3afbac1e2008 Mon Sep 17 00:00:00 2001 From: PoMa Date: Thu, 28 May 2026 14:12:43 +0200 Subject: [PATCH 6/7] [IMP] database: replace --all-fields with --fields --non-custom --non-custom is a modifier for --fields that will extend the scope beyond x_ prefixed fields to all non-standard fields (not yet implemented). Co-Authored-By: Claude Sonnet 4.6 --- odev/commands/database/check_unused.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/odev/commands/database/check_unused.py b/odev/commands/database/check_unused.py index a6e72cb..353a018 100644 --- a/odev/commands/database/check_unused.py +++ b/odev/commands/database/check_unused.py @@ -26,7 +26,8 @@ class CheckUnusedCommand(LocalDatabaseCommand): Use --fields to detect x_ custom fields that are either not referenced anywhere (views, server actions, automations, filters, rules, templates, reports, exports) - or are referenced but contain no meaningful data. + or are referenced but contain no meaningful data. Add --non-custom to extend the + scope beyond x_ fields to all non-standard fields (not yet implemented). """ _name = "check-unused" @@ -34,11 +35,11 @@ class CheckUnusedCommand(LocalDatabaseCommand): fields = args.Flag( aliases=["--fields"], - description="Check for unused x_ custom fields (excludes x_plan fields).", + description="Check for unused custom fields. Without --non-custom, restricted to x_ fields (excludes x_plan).", ) - all_fields = args.Flag( - aliases=["--all-fields"], - description="Check for unused fields across all non-standard fields, not just x_ ones. (not yet implemented)", + non_custom = args.Flag( + aliases=["--non-custom"], + description="Extend --fields to all non-standard fields, not just x_ ones. (not yet implemented)", ) save = args.String( aliases=["--save"], @@ -64,11 +65,11 @@ class CheckUnusedCommand(LocalDatabaseCommand): ] def run(self): - if self.args.all_fields: - raise NotImplementedError("--all-fields is not yet implemented.") - if not self.args.fields: - raise self.error("Specify at least one check to run: --fields or --all-fields") + raise self.error("Specify at least one check to run: --fields") + + if self.args.non_custom: + raise NotImplementedError("--fields --non-custom is not yet implemented.") self._run_fields_check() From 329f8b893f7a67c3e288cf681ea8eb3d18f27d3b Mon Sep 17 00:00:00 2001 From: PoMa Date: Thu, 28 May 2026 14:22:53 +0200 Subject: [PATCH 7/7] [IMP] database: fix BLE001 and bump version to 4.30.0 - Replace bare except Exception with RuntimeError in _field_has_data - Bump minor version for new check-unused command Co-Authored-By: Claude Sonnet 4.6 --- odev/_version.py | 2 +- odev/commands/database/check_unused.py | 21 +++++++++++++-------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/odev/_version.py b/odev/_version.py index 3273ca4..552d766 100644 --- a/odev/_version.py +++ b/odev/_version.py @@ -22,4 +22,4 @@ # or merged change. # ------------------------------------------------------------------------------ -__version__ = "4.29.3" +__version__ = "4.30.0" diff --git a/odev/commands/database/check_unused.py b/odev/commands/database/check_unused.py index 353a018..e4070be 100644 --- a/odev/commands/database/check_unused.py +++ b/odev/commands/database/check_unused.py @@ -52,7 +52,10 @@ class CheckUnusedCommand(LocalDatabaseCommand): _CONTENT_QUERIES: list[tuple[str, list[str]]] = [ ("SELECT arch_db FROM ir_ui_view", ["arch_db"]), ("SELECT code FROM ir_act_server WHERE code IS NOT NULL", ["code"]), - ("SELECT compute, related FROM ir_model_fields WHERE compute IS NOT NULL OR related IS NOT NULL", ["compute", "related"]), + ( + "SELECT compute, related FROM ir_model_fields WHERE compute IS NOT NULL OR related IS NOT NULL", + ["compute", "related"], + ), ("SELECT domain FROM ir_filters WHERE domain IS NOT NULL", ["domain"]), ("SELECT domain_force FROM ir_rule WHERE domain_force IS NOT NULL", ["domain_force"]), ] @@ -61,7 +64,11 @@ class CheckUnusedCommand(LocalDatabaseCommand): ("mail_template", "SELECT body_html, subject FROM mail_template", ["body_html", "subject"]), ("ir_actions_report", "SELECT help FROM ir_actions_report WHERE help IS NOT NULL", ["help"]), ("ir_exports_line", "SELECT name FROM ir_exports_line WHERE name IS NOT NULL", ["name"]), - ("base_automation", "SELECT filter_pre_domain, filter_domain FROM base_automation", ["filter_pre_domain", "filter_domain"]), + ( + "base_automation", + "SELECT filter_pre_domain, filter_domain FROM base_automation", + ["filter_pre_domain", "filter_domain"], + ), ] def run(self): @@ -127,9 +134,7 @@ def _fetch_x_fields(self) -> list[tuple]: return result or [] def _table_exists(self, psql, table: str) -> bool: - result = psql.query( - f"SELECT COUNT(*) FROM information_schema.tables WHERE table_name = '{table}'" - ) + result = psql.query(f"SELECT COUNT(*) FROM information_schema.tables WHERE table_name = '{table}'") return bool(result and result[0][0]) def _collect_search_content(self) -> str: @@ -177,7 +182,7 @@ def _field_has_data(self, psql, model: str, field_name: str, ttype: str) -> bool elif ttype in ("integer", "float", "monetary"): query = f'SELECT 1 FROM "{table}" WHERE "{field_name}" IS NOT NULL AND "{field_name}" != 0 LIMIT 1' elif ttype in ("char", "text", "html", "selection"): - query = f"SELECT 1 FROM \"{table}\" WHERE \"{field_name}\" IS NOT NULL AND \"{field_name}\" != '' LIMIT 1" + query = f'SELECT 1 FROM "{table}" WHERE "{field_name}" IS NOT NULL AND "{field_name}" != \'\' LIMIT 1' elif ttype in ("many2one", "date", "datetime", "reference"): query = f'SELECT 1 FROM "{table}" WHERE "{field_name}" IS NOT NULL LIMIT 1' else: @@ -185,8 +190,8 @@ def _field_has_data(self, psql, model: str, field_name: str, ttype: str) -> bool try: return bool(psql.query(query)) - except Exception: - return True + except RuntimeError: + return True # table or column does not exist in the DB def _extract_label(self, description) -> str: if isinstance(description, dict):