diff --git a/spp_metric_service/README.rst b/spp_metric_service/README.rst index e306d7f60..9fa8846d9 100644 --- a/spp_metric_service/README.rst +++ b/spp_metric_service/README.rst @@ -45,27 +45,27 @@ Key Capabilities Key Models ~~~~~~~~~~ -+---------------------------------+------------------------------------+ -| Model | Description | -+=================================+====================================+ -| ``spp.demographic.dimension`` | Configurable dimension for | -| | breakdowns (field or CEL) | -+---------------------------------+------------------------------------+ -| ``spp.metrics.fairness`` | Abstract service: equity/parity | -| | analysis | -+---------------------------------+------------------------------------+ -| ``spp.metrics.distribution`` | Abstract service: distribution | -| | statistics | -+---------------------------------+------------------------------------+ -| ``spp.metrics.breakdown`` | Abstract service: | -| | multi-dimensional grouping | -+---------------------------------+------------------------------------+ -| ``spp.metrics.privacy`` | Abstract service: k-anonymity | -| | enforcement | -+---------------------------------+------------------------------------+ -| ``spp.metrics.dimension.cache`` | Abstract service: dimension | -| | evaluation cache | -+---------------------------------+------------------------------------+ ++--------------------------------+-------------------------------------+ +| Model | Description | ++================================+=====================================+ +| ``spp.demographic.dimension`` | Configurable dimension for | +| | breakdowns (field or CEL) | ++--------------------------------+-------------------------------------+ +| ``spp.metric.fairness`` | Abstract service: equity/parity | +| | analysis | ++--------------------------------+-------------------------------------+ +| ``spp.metric.distribution`` | Abstract service: distribution | +| | statistics | ++--------------------------------+-------------------------------------+ +| ``spp.metric.breakdown`` | Abstract service: multi-dimensional | +| | grouping | ++--------------------------------+-------------------------------------+ +| ``spp.metric.privacy`` | Abstract service: k-anonymity | +| | enforcement | ++--------------------------------+-------------------------------------+ +| ``spp.metric.dimension.cache`` | Abstract service: dimension | +| | evaluation cache | ++--------------------------------+-------------------------------------+ Configuration ~~~~~~~~~~~~~ @@ -96,10 +96,10 @@ Group Access Extension Points ~~~~~~~~~~~~~~~~ -- Override ``_analyze_dimension()`` in ``spp.metrics.fairness`` for +- Override ``_analyze_dimension()`` in ``spp.metric.fairness`` for custom analysis logic - Add new dimension types by extending ``spp.demographic.dimension`` -- Override ``enforce()`` in ``spp.metrics.privacy`` for custom +- Override ``enforce()`` in ``spp.metric.privacy`` for custom suppression strategies Dependencies diff --git a/spp_metric_service/__manifest__.py b/spp_metric_service/__manifest__.py index e77c4ce98..dea316e7d 100644 --- a/spp_metric_service/__manifest__.py +++ b/spp_metric_service/__manifest__.py @@ -3,7 +3,7 @@ "name": "OpenSPP Metric Service", "summary": "Computation services for fairness, distribution, breakdown, and privacy", "category": "OpenSPP", - "version": "19.0.2.0.0", + "version": "19.0.2.1.0", "sequence": 1, "author": "OpenSPP.org", "website": "https://github.com/OpenSPP/OpenSPP2", diff --git a/spp_metric_service/data/demographic_dimensions.xml b/spp_metric_service/data/demographic_dimensions.xml index 862bb3f52..8a1e85788 100644 --- a/spp_metric_service/data/demographic_dimensions.xml +++ b/spp_metric_service/data/demographic_dimensions.xml @@ -7,8 +7,8 @@ Gender identity from ISO 5218 vocabulary 10 field - gender_id - all + gender_id.code + individuals {"0": "Not Known", "1": "Male", "2": "Female", "9": "Not Applicable"} @@ -56,23 +56,27 @@ >{"true": "Group/Household", "false": "Individual"} - + age_group Age Group - Age group based on birth date + Age group based on birth date (UNICEF/WHO-aligned) 50 expression individuals {"child": "Child (0-17)", "adult": "Adult (18-59)", "elderly": "Elderly (60+)", "unknown": "Unknown"} + >{"under_5": "Under 5", "child": "Child (5-14)", "adolescent": "Adolescent (15-17)", "adult": "Adult (18-59)", "elderly": "Elderly (60+)", "unknown": "Unknown"} diff --git a/spp_metric_service/models/breakdown_service.py b/spp_metric_service/models/breakdown_service.py index acb1524bd..9144cef76 100644 --- a/spp_metric_service/models/breakdown_service.py +++ b/spp_metric_service/models/breakdown_service.py @@ -56,6 +56,14 @@ def compute_breakdown(self, registrant_ids, group_by, statistics=None, context=N if not dimensions: return {} + # Auto-expand groups to members when any dimension applies to individuals only + needs_expansion = any(d.applies_to == "individuals" for d in dimensions) + if needs_expansion: + registrant_ids = self._expand_groups_to_members(registrant_ids) + + if not registrant_ids: + return {} + # Get cache service cache_service = self.env["spp.metric.dimension.cache"] @@ -95,3 +103,41 @@ def compute_breakdown(self, registrant_ids, group_by, statistics=None, context=N # TODO: Add per-cell statistics if needed return breakdown + + @api.model + def _expand_groups_to_members(self, registrant_ids): + """ + Expand group IDs to their individual member IDs. + + Groups are replaced by their active members. Individual IDs pass through. + The result is deduplicated. + + :param registrant_ids: List of partner IDs (groups and/or individuals) + :returns: Deduplicated list of individual partner IDs + :rtype: list + """ + # sudo: aggregate breakdown metrics must expand groups to their members + # across all registrants regardless of the caller's record rules. + # Read-only (no writes); callers are authorized at the service entry point. + Partner = self.env["res.partner"].sudo() # nosemgrep: odoo-sudo-without-context,odoo-sudo-on-sensitive-models + records = Partner.browse(registrant_ids).exists() + + groups = records.filtered("is_group") + group_ids = groups.ids + individual_ids = set((records - groups).ids) + + if not group_ids: + return list(individual_ids) + + # Expand groups via active memberships + Membership = self.env["spp.group.membership"].sudo() # nosemgrep: odoo-sudo-without-context + memberships = Membership.search( + [ + ("group", "in", group_ids), + ("is_ended", "=", False), + ] + ) + + individual_ids.update(memberships.individual.ids) + + return list(individual_ids) diff --git a/spp_metric_service/models/demographic_dimension.py b/spp_metric_service/models/demographic_dimension.py index 03900eb7a..29bfd7285 100644 --- a/spp_metric_service/models/demographic_dimension.py +++ b/spp_metric_service/models/demographic_dimension.py @@ -1,13 +1,25 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. import json import logging +from dataclasses import dataclass +from dataclasses import field as dataclass_field from odoo import _, api, fields, models from odoo.exceptions import ValidationError +from odoo.tools.sql import SQL _logger = logging.getLogger(__name__) +@dataclass +class SQLColumnResult: + """Result of compiling a demographic dimension to a SQL expression.""" + + expression: SQL + joins: list[SQL] = dataclass_field(default_factory=list) + alias_counter: int = 0 + + class DemographicDimension(models.Model): """ Configurable demographic dimensions for group_by breakdowns. @@ -173,9 +185,11 @@ def _evaluate_field(self, record): return self.default_value or "unknown" # Convert to string key - if hasattr(value, "id"): - # Many2one - use code or display_name for meaningful keys - if hasattr(value, "code") and value.code: + if hasattr(value, "_name"): + # Odoo recordset (Many2one). Empty recordset means the field is unset. + if not value: + return self.default_value or "unknown" + if "code" in value._fields and value.code: key = str(value.code) else: key = value.display_name or str(value.id) @@ -211,28 +225,73 @@ def get_label_for_value(self, value): """ Get the display label for a dimension value. + First checks value_labels_json for a static mapping. If not found and + the dimension is field-based pointing to a Many2one, dynamically looks + up the display_name from the related model. + :param value: The raw dimension value :returns: Display label :rtype: str """ self.ensure_one() - if not self.value_labels_json: - return value - - # Handle case where value_labels_json is a string (JSON not yet parsed) - labels = self.value_labels_json - if isinstance(labels, str): - try: - labels = json.loads(labels) - except (json.JSONDecodeError, TypeError): - return value - - # Convert value to string for lookup (keys are strings in JSON) - str_value = str(value) if value is not None else "null" - if str_value in labels: - return labels[str_value] + + # Check static labels first + if self.value_labels_json: + labels = self.value_labels_json + if isinstance(labels, str): + try: + labels = json.loads(labels) + except (json.JSONDecodeError, TypeError): + labels = {} + + str_value = str(value) if value is not None else "null" + if str_value in labels: + return labels[str_value] + + # For field-based dimensions pointing to a Many2one, try dynamic lookup + if self.dimension_type == "field" and self.field_path: + label = self._lookup_m2o_label(value) + if label: + return label + return value + def _lookup_m2o_label(self, raw_value): + """Look up display_name for a Many2one field-based dimension value. + + When field_path points to a Many2one (e.g. area_id, gender_id), the raw + value is typically the record's code. This method searches the related + model by code to return the display_name. + + :param raw_value: The raw dimension value (typically a code string) + :returns: display_name or None if not found + """ + if not raw_value: + return None + + # Only handle simple field paths (one segment, no dotted traversal + # like "gender_id.code" where the user explicitly chose a sub-field) + if "." in self.field_path: + return None + + field_name = self.field_path + partner_fields = self.env["res.partner"]._fields + if field_name not in partner_fields: + return None + + field = partner_fields[field_name] + if field.type != "many2one": + return None + + comodel = self.env[field.comodel_name] + # Search by code if the related model has a code field + if "code" in comodel._fields: + record = comodel.search([("code", "=", raw_value)], limit=1) + if record: + return record.display_name + + return None + @api.model def get_by_name(self, name): """ @@ -260,6 +319,176 @@ def get_active_dimensions(self, applies_to=None): domain.append(("applies_to", "=", applies_to)) return self.search(domain, order="sequence, name") + # ------------------------------------------------------------------------- + # SQL Column Generation + # ------------------------------------------------------------------------- + def to_sql_column(self, alias="ind", alias_counter=0): + """Generate a SQL expression for this dimension's value. + + Compiles this dimension to a SQL expression that can be used in a + SELECT clause. For field-based dimensions, generates column references + (with JOINs for Many2one). For CEL expression dimensions, delegates to + the CEL-to-SQL compiler. + + Args: + alias: SQL alias for the res_partner table (default "ind") + alias_counter: Counter for generating unique join aliases + + Returns: + SQLColumnResult | None: SQL expression + joins, or None if + SQL compilation is not possible (fall back to Python). + """ + self.ensure_one() + default = self.default_value or "unknown" + + if self.dimension_type == "field": + result = self._to_sql_column_field(alias, alias_counter, default) + elif self.dimension_type == "expression": + result = self._to_sql_column_expression(alias, alias_counter, default) + else: + return None + + if result is None: + return None + + # Wrap with applies_to filter: return default for non-matching registrants + if self.applies_to == "individuals": + result = SQLColumnResult( + expression=SQL( + "CASE WHEN %s.%s = FALSE THEN %s ELSE %s END", + SQL.identifier(alias), + SQL.identifier("is_group"), + result.expression, + SQL("%s", default), + ), + joins=result.joins, + alias_counter=result.alias_counter, + ) + elif self.applies_to == "groups": + result = SQLColumnResult( + expression=SQL( + "CASE WHEN %s.%s = TRUE THEN %s ELSE %s END", + SQL.identifier(alias), + SQL.identifier("is_group"), + result.expression, + SQL("%s", default), + ), + joins=result.joins, + alias_counter=result.alias_counter, + ) + + return result + + def _to_sql_column_field(self, alias, alias_counter, default): + """Generate SQL for a field-based dimension.""" + if not self.field_path: + return None + + parts = self.field_path.split(".") + partner_fields = self.env["res.partner"]._fields + + if len(parts) == 1: + # Simple field (e.g., is_group, area_id) + field_name = parts[0] + if field_name not in partner_fields: + return None + + field_def = partner_fields[field_name] + if field_def.type == "many2one": + # Many2one direct: use code from related model if available + return self._to_sql_column_m2o_direct(alias, alias_counter, field_name, field_def, default) + else: + # Scalar field: CAST to text + col = SQL("%s.%s", SQL.identifier(alias), SQL.identifier(field_name)) + expr = SQL("COALESCE(CAST(%s AS TEXT), %s)", col, default) + return SQLColumnResult(expression=expr, alias_counter=alias_counter) + + elif len(parts) == 2: + # Dotted path (e.g., gender_id.code) + field_name, sub_field = parts + if field_name not in partner_fields: + return None + + field_def = partner_fields[field_name] + if field_def.type != "many2one": + return None + + return self._to_sql_column_m2o_sub(alias, alias_counter, field_name, field_def, sub_field, default) + + # Deeper paths not supported in SQL + return None + + def _to_sql_column_m2o_direct(self, alias, alias_counter, field_name, field_def, default): + """Generate SQL for a direct Many2one field (e.g., gender_id, area_id). + + JOINs to the comodel and uses code if available, otherwise id as text. + """ + comodel_name = field_def.comodel_name + comodel = self.env[comodel_name] + join_alias = f"_dim{alias_counter}" + comodel_table = comodel._table + + fk_col = SQL("%s.%s", SQL.identifier(alias), SQL.identifier(field_name)) + join_id = SQL("%s.id", SQL.identifier(join_alias)) + join_sql = SQL( + "LEFT JOIN %s %s ON %s = %s", + SQL.identifier(comodel_table), + SQL.identifier(join_alias), + join_id, + fk_col, + ) + + if "code" in comodel._fields: + code_col = SQL("%s.%s", SQL.identifier(join_alias), SQL.identifier("code")) + expr = SQL("COALESCE(CAST(%s AS TEXT), %s)", code_col, default) + else: + id_col = SQL("%s.id", SQL.identifier(join_alias)) + expr = SQL("COALESCE(CAST(%s AS TEXT), %s)", id_col, default) + + return SQLColumnResult(expression=expr, joins=[join_sql], alias_counter=alias_counter + 1) + + def _to_sql_column_m2o_sub(self, alias, alias_counter, field_name, field_def, sub_field, default): + """Generate SQL for a Many2one dotted path (e.g., gender_id.code).""" + comodel_name = field_def.comodel_name + comodel = self.env[comodel_name] + join_alias = f"_dim{alias_counter}" + comodel_table = comodel._table + + if sub_field not in comodel._fields: + return None + + fk_col = SQL("%s.%s", SQL.identifier(alias), SQL.identifier(field_name)) + join_id = SQL("%s.id", SQL.identifier(join_alias)) + join_sql = SQL( + "LEFT JOIN %s %s ON %s = %s", + SQL.identifier(comodel_table), + SQL.identifier(join_alias), + join_id, + fk_col, + ) + + sub_col = SQL("%s.%s", SQL.identifier(join_alias), SQL.identifier(sub_field)) + expr = SQL("COALESCE(CAST(%s AS TEXT), %s)", sub_col, default) + + return SQLColumnResult(expression=expr, joins=[join_sql], alias_counter=alias_counter + 1) + + def _to_sql_column_expression(self, alias, alias_counter, default): + """Generate SQL for a CEL expression-based dimension.""" + if not self.cel_expression: + return None + + try: + translator = self.env["spp.cel.translator"] + except KeyError: + return None + + sql_expr = translator.to_sql_case(self.cel_expression, "res.partner", alias) + if sql_expr is None: + return None + + expr = SQL("COALESCE(CAST(%s AS TEXT), %s)", sql_expr, default) + return SQLColumnResult(expression=expr, alias_counter=alias_counter) + # ------------------------------------------------------------------------- # Cache Invalidation # ------------------------------------------------------------------------- diff --git a/spp_metric_service/readme/DESCRIPTION.md b/spp_metric_service/readme/DESCRIPTION.md index 13cad0309..40f6fd6c7 100644 --- a/spp_metric_service/readme/DESCRIPTION.md +++ b/spp_metric_service/readme/DESCRIPTION.md @@ -16,11 +16,11 @@ dashboards. No standalone UI; provides only programmatic service models. | Model | Description | | -------------------------------- | ---------------------------------------------------- | | `spp.demographic.dimension` | Configurable dimension for breakdowns (field or CEL) | -| `spp.metrics.fairness` | Abstract service: equity/parity analysis | -| `spp.metrics.distribution` | Abstract service: distribution statistics | -| `spp.metrics.breakdown` | Abstract service: multi-dimensional grouping | -| `spp.metrics.privacy` | Abstract service: k-anonymity enforcement | -| `spp.metrics.dimension.cache` | Abstract service: dimension evaluation cache | +| `spp.metric.fairness` | Abstract service: equity/parity analysis | +| `spp.metric.distribution` | Abstract service: distribution statistics | +| `spp.metric.breakdown` | Abstract service: multi-dimensional grouping | +| `spp.metric.privacy` | Abstract service: k-anonymity enforcement | +| `spp.metric.dimension.cache` | Abstract service: dimension evaluation cache | ### Configuration @@ -44,9 +44,9 @@ by `spp_aggregation`. ### Extension Points -- Override `_analyze_dimension()` in `spp.metrics.fairness` for custom analysis logic +- Override `_analyze_dimension()` in `spp.metric.fairness` for custom analysis logic - Add new dimension types by extending `spp.demographic.dimension` -- Override `enforce()` in `spp.metrics.privacy` for custom suppression strategies +- Override `enforce()` in `spp.metric.privacy` for custom suppression strategies ### Dependencies diff --git a/spp_metric_service/readme/HISTORY.md b/spp_metric_service/readme/HISTORY.md index 4aaf9afef..c3b826bb5 100644 --- a/spp_metric_service/readme/HISTORY.md +++ b/spp_metric_service/readme/HISTORY.md @@ -1,3 +1,7 @@ +### 19.0.2.1.0 + +- feat: demographic breakdown expansion and SQL column support for metric disaggregation (re-land from #76; uses the spp_cel_domain SQL CASE compiler). + ### 19.0.2.0.0 - Initial migration to OpenSPP2 diff --git a/spp_metric_service/tests/__init__.py b/spp_metric_service/tests/__init__.py index 0c7a9d58c..ba8bf44af 100644 --- a/spp_metric_service/tests/__init__.py +++ b/spp_metric_service/tests/__init__.py @@ -3,3 +3,5 @@ from . import test_services from . import test_dimension_cache from . import test_coverage +from . import test_sql_column +from . import test_breakdown_expansion diff --git a/spp_metric_service/tests/test_breakdown_expansion.py b/spp_metric_service/tests/test_breakdown_expansion.py new file mode 100644 index 000000000..944492898 --- /dev/null +++ b/spp_metric_service/tests/test_breakdown_expansion.py @@ -0,0 +1,129 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for BreakdownService group-to-member expansion.""" + +from odoo.tests import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestBreakdownExpansion(TransactionCase): + """Test that BreakdownService expands groups to members for individual-level dimensions.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.breakdown = cls.env["spp.metric.breakdown"] + cls.dim_model = cls.env["spp.demographic.dimension"] + + # Create a dimension that applies to individuals only + cls.gender_dim = cls.dim_model.search([("name", "=", "gender")], limit=1) + if not cls.gender_dim: + cls.gender_dim = cls.dim_model.create( + { + "name": "gender", + "label": "Gender", + "dimension_type": "field", + "field_path": "gender_id.code", + "applies_to": "individuals", + "default_value": "unknown", + } + ) + else: + # Ensure applies_to is set for this test + cls.gender_dim.applies_to = "individuals" + + # Create a dimension that applies to all + cls.type_dim = cls.dim_model.search([("name", "=", "registrant_type")], limit=1) + if not cls.type_dim: + cls.type_dim = cls.dim_model.create( + { + "name": "registrant_type", + "label": "Registrant Type", + "dimension_type": "field", + "field_path": "is_group", + "applies_to": "all", + "default_value": "unknown", + } + ) + + # Create a group with two individual members + cls.group = cls.env["res.partner"].create( + { + "name": "Expansion Test Group", + "is_registrant": True, + "is_group": True, + } + ) + + cls.member1 = cls.env["res.partner"].create( + { + "name": "Member 1", + "is_registrant": True, + "is_group": False, + } + ) + cls.member2 = cls.env["res.partner"].create( + { + "name": "Member 2", + "is_registrant": True, + "is_group": False, + } + ) + + # Create memberships + cls.env["spp.group.membership"].create({"group": cls.group.id, "individual": cls.member1.id}) + cls.env["spp.group.membership"].create({"group": cls.group.id, "individual": cls.member2.id}) + + def test_expansion_with_individual_dimension(self): + """Passing group IDs with individual-level dimensions expands to members.""" + result = self.breakdown.compute_breakdown([self.group.id], ["gender"]) + + # Should have breakdown entries (from the 2 members, not the 1 group) + total = sum(cell["count"] for cell in result.values()) + self.assertEqual(total, 2, "Should count 2 individual members, not 1 group") + + def test_no_expansion_with_all_dimension(self): + """Passing group IDs with applies_to='all' dimensions does NOT expand.""" + result = self.breakdown.compute_breakdown([self.group.id], ["registrant_type"]) + + total = sum(cell["count"] for cell in result.values()) + self.assertEqual(total, 1, "Should count 1 group record without expansion") + + def test_mixed_group_and_individual_ids(self): + """Mixed group + individual IDs: groups expand, individuals pass through.""" + result = self.breakdown.compute_breakdown( + [self.group.id, self.member1.id], + ["gender"], + ) + + # group expands to member1 + member2, plus member1 directly = 3 IDs + # but member1 appears twice, so after dedup = 2 unique individuals + total = sum(cell["count"] for cell in result.values()) + self.assertEqual(total, 2, "Duplicates from expansion should be deduplicated") + + def test_empty_group_no_phantom_entries(self): + """Group with no active members produces no entries.""" + empty_group = self.env["res.partner"].create( + { + "name": "Empty Group", + "is_registrant": True, + "is_group": True, + } + ) + result = self.breakdown.compute_breakdown([empty_group.id], ["gender"]) + total = sum(cell["count"] for cell in result.values()) + self.assertEqual(total, 0, "Empty group should produce no breakdown entries") + + def test_existing_tests_unaffected(self): + """Empty group_by still returns empty dict.""" + result = self.breakdown.compute_breakdown([self.group.id], []) + self.assertEqual(result, {}) + + def test_individual_ids_with_individual_dimension(self): + """Passing individual IDs with individual-level dimensions works without expansion.""" + result = self.breakdown.compute_breakdown( + [self.member1.id, self.member2.id], + ["gender"], + ) + total = sum(cell["count"] for cell in result.values()) + self.assertEqual(total, 2, "Individual IDs should pass through without expansion") diff --git a/spp_metric_service/tests/test_coverage.py b/spp_metric_service/tests/test_coverage.py index 16ed98612..c8afd4789 100644 --- a/spp_metric_service/tests/test_coverage.py +++ b/spp_metric_service/tests/test_coverage.py @@ -299,6 +299,62 @@ def test_evaluate_for_record_error_no_default(self): # get_label_for_value # ------------------------------------------------------------------------- + def test_evaluate_field_empty_many2one_returns_default(self): + """Empty Many2one (unset field) returns default_value, not 'False'.""" + dim = self.dim_model.create( + { + "name": "test_empty_m2o", + "label": "Empty M2O", + "dimension_type": "field", + "field_path": "company_id", + "applies_to": "all", + "default_value": "unset", + } + ) + # Individual with company_id explicitly cleared + partner = self.partner_model.create( + { + "name": "No Company", + "is_registrant": True, + "is_group": False, + "company_id": False, + } + ) + result = dim.evaluate_for_record(partner) + self.assertEqual(result, "unset") + + def test_get_label_for_value_m2o_dynamic_lookup(self): + """get_label_for_value dynamically looks up Many2one display_name.""" + # Use company_id which doesn't have a 'code' field on res.company + dim = self.dim_model.create( + { + "name": "test_m2o_label_lookup", + "label": "Area Label", + "dimension_type": "field", + "field_path": "company_id", + "applies_to": "all", + } + ) + # For a field without code, dynamic lookup won't match + # (res.company has no code field), so it falls back to raw value + result = dim.get_label_for_value("some_code") + self.assertEqual(result, "some_code") + + def test_get_label_for_value_static_takes_precedence(self): + """Static value_labels_json takes precedence over dynamic lookup.""" + dim = self.dim_model.create( + { + "name": "test_static_precedence", + "label": "Static", + "dimension_type": "field", + "field_path": "company_id", + "applies_to": "all", + "value_labels_json": {"MY_CODE": "My Display Name"}, + } + ) + result = dim.get_label_for_value("MY_CODE") + self.assertEqual(result, "My Display Name") + def test_get_label_for_value_with_json_mapping(self): """Value label lookup from JSON mapping.""" dim = self.dim_model.create( diff --git a/spp_metric_service/tests/test_sql_column.py b/spp_metric_service/tests/test_sql_column.py new file mode 100644 index 000000000..d00a5befd --- /dev/null +++ b/spp_metric_service/tests/test_sql_column.py @@ -0,0 +1,155 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for DemographicDimension.to_sql_column() SQL compilation.""" + +from odoo.tests import TransactionCase, tagged +from odoo.tools.sql import SQL + +from ..models.demographic_dimension import SQLColumnResult + + +@tagged("post_install", "-at_install") +class TestDimensionSqlColumn(TransactionCase): + """Test to_sql_column() for field-based and expression-based dimensions.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + Dim = cls.env["spp.demographic.dimension"] + + # Simple boolean field + cls.dim_is_group = Dim.create( + { + "name": "test_is_group", + "label": "Is Group", + "dimension_type": "field", + "field_path": "is_group", + "default_value": "unknown", + } + ) + + # Many2one with .code path (gender_id.code) + cls.dim_gender_code = Dim.create( + { + "name": "test_gender_code", + "label": "Gender Code", + "dimension_type": "field", + "field_path": "gender_id.code", + "default_value": "unknown", + } + ) + + # Many2one direct (gender_id) + cls.dim_gender_direct = Dim.create( + { + "name": "test_gender_direct", + "label": "Gender Direct", + "dimension_type": "field", + "field_path": "gender_id", + "default_value": "unknown", + } + ) + + # CEL expression dimension + cls.dim_age_group = Dim.create( + { + "name": "test_age_group", + "label": "Age Group", + "dimension_type": "expression", + "cel_expression": ( + 'r.birthdate == null ? "unknown" : ' + 'age_years(r.birthdate) < 18 ? "child" : ' + 'age_years(r.birthdate) < 60 ? "adult" : "elderly"' + ), + "default_value": "unknown", + } + ) + + def test_field_simple_returns_sql(self): + """Test simple field produces CAST + COALESCE SQL.""" + result = self.dim_is_group.to_sql_column("p", 0) + self.assertIsNotNone(result) + self.assertIsInstance(result, SQLColumnResult) + self.assertIsInstance(result.expression, SQL) + self.assertEqual(result.joins, []) + sql_str = str(result.expression) + self.assertIn("COALESCE", sql_str) + self.assertIn("CAST", sql_str) + self.assertIn("is_group", sql_str) + + def test_field_m2o_code_path_produces_join(self): + """Test M2O dotted path (gender_id.code) produces LEFT JOIN.""" + result = self.dim_gender_code.to_sql_column("ind", 0) + self.assertIsNotNone(result) + self.assertEqual(len(result.joins), 1) + join_str = str(result.joins[0]) + self.assertIn("LEFT JOIN", join_str) + sql_str = str(result.expression) + self.assertIn("code", sql_str) + # alias_counter should be incremented + self.assertEqual(result.alias_counter, 1) + + def test_field_m2o_direct_produces_join(self): + """Test direct M2O field (gender_id) produces LEFT JOIN with code lookup.""" + result = self.dim_gender_direct.to_sql_column("ind", 0) + self.assertIsNotNone(result) + self.assertEqual(len(result.joins), 1) + join_str = str(result.joins[0]) + self.assertIn("LEFT JOIN", join_str) + + def test_expression_produces_case_when(self): + """Test CEL expression dimension compiles to CASE WHEN SQL.""" + result = self.dim_age_group.to_sql_column("ind", 0) + self.assertIsNotNone(result) + self.assertIsInstance(result, SQLColumnResult) + sql_str = str(result.expression) + self.assertIn("CASE WHEN", sql_str) + self.assertIn("COALESCE", sql_str) + + def test_unsupported_expression_returns_none(self): + """Test unsupported CEL expression returns None for fallback.""" + dim = self.env["spp.demographic.dimension"].create( + { + "name": "test_unsupported", + "label": "Unsupported", + "dimension_type": "expression", + "cel_expression": "r.income + r.bonus", + "default_value": "n/a", + } + ) + result = dim.to_sql_column("ind", 0) + self.assertIsNone(result) + + def test_invalid_field_path_returns_none(self): + """Test invalid field path returns None.""" + dim = self.env["spp.demographic.dimension"].create( + { + "name": "test_invalid_field", + "label": "Invalid Field", + "dimension_type": "field", + "field_path": "nonexistent_field", + "default_value": "n/a", + } + ) + result = dim.to_sql_column("ind", 0) + self.assertIsNone(result) + + def test_alias_counter_increments(self): + """Test multiple dimensions get unique join aliases.""" + r1 = self.dim_gender_code.to_sql_column("ind", 0) + r2 = self.dim_gender_direct.to_sql_column("ind", r1.alias_counter) + self.assertNotEqual(r1.alias_counter, r2.alias_counter) + # Join aliases should differ + join1_str = str(r1.joins[0]) + join2_str = str(r2.joins[0]) + self.assertIn("_dim0", join1_str) + self.assertIn("_dim1", join2_str) + + def test_cross_module_cel_call(self): + """Test to_sql_column works cross-module (spp_metric_service calling spp_cel_domain).""" + # This is the key cross-module integration test + result = self.dim_age_group.to_sql_column("t", 5) + self.assertIsNotNone(result) + sql_str = str(result.expression) + # Should reference the correct alias + self.assertIn("t", sql_str) + self.assertIn("birthdate", sql_str)