from __future__ import annotations import json from dataclasses import dataclass from typing import Any import pymysql @dataclass(frozen=True) class FieldValueRule: field_name_jp: str legacy_value: str standardized_field: str standardized_value: str geom_scope: tuple[str, ...] source_layer_scope: tuple[str, ...] canonical_object_scope: tuple[str, ...] def matches(self, context: dict[str, str]) -> bool: if self.geom_scope and context.get("geom_type", "") not in self.geom_scope: return False if self.source_layer_scope and context.get("source_layer", "") not in self.source_layer_scope: return False if self.canonical_object_scope and context.get("canonical_object_type", "") not in self.canonical_object_scope: return False return True @dataclass(frozen=True) class RenderRule: rule_id: str priority: int match_expr: dict[str, Any] output: dict[str, Any] def matches(self, context: dict[str, Any]) -> bool: return evaluate_match_expr(self.match_expr, context) def normalize_csv_scope(value: str | None) -> tuple[str, ...]: if not value: return () return tuple(part.strip() for part in str(value).split(",") if part.strip()) def evaluate_match_expr(match_expr: dict[str, Any], context: dict[str, Any]) -> bool: clauses = match_expr.get("all") or [] for clause in clauses: field = clause.get("field") op = clause.get("op") expected = clause.get("value") actual = context.get(str(field)) actual_text = "" if actual is None else str(actual) if op == "eq": if actual_text != str(expected): return False elif op == "neq": if actual_text == str(expected): return False elif op == "in": if actual_text not in {str(item) for item in (expected or [])}: return False elif op == "contains": if str(expected) not in actual_text: return False elif op == "contains_any": values = [str(item) for item in (expected or [])] if not any(item in actual_text for item in values): return False elif op == "is_null": if actual not in (None, ""): return False elif op == "not_null": if actual in (None, ""): return False else: return False return True class NavSeaMappingRegistry: def __init__( self, bundle_id: str, bundle_version: str, source_layer_rules: dict[str, dict[str, str]], field_value_rules: dict[tuple[str, str], list[FieldValueRule]], render_rules: list[RenderRule], ) -> None: self.bundle_id = bundle_id self.bundle_version = bundle_version self.source_layer_rules = source_layer_rules self.field_value_rules = field_value_rules self.render_rules = render_rules @classmethod def load( cls, conn: pymysql.Connection, bundle_id: str | None = None, ) -> "NavSeaMappingRegistry": with conn.cursor() as cur: if bundle_id: cur.execute( """ SELECT bundle_id, bundle_version FROM navsea_rule_bundle WHERE bundle_id = %s ORDER BY created_at DESC LIMIT 1 """, (bundle_id,), ) else: cur.execute( """ SELECT bundle_id, bundle_version FROM navsea_rule_bundle ORDER BY created_at DESC LIMIT 1 """ ) bundle = cur.fetchone() if not bundle: raise RuntimeError("navsea mapping registry is empty") resolved_bundle_id = str(bundle["bundle_id"]) resolved_bundle_version = str(bundle["bundle_version"]) cur.execute( """ SELECT source_layer_jp, source_layer_std FROM navsea_source_layer_rules WHERE bundle_id = %s AND bundle_version = %s """, (resolved_bundle_id, resolved_bundle_version), ) source_layer_rules = { str(row["source_layer_jp"]): { "source_layer_std": str(row["source_layer_std"]), "source_layer_rule_id": f"SLR:{row['source_layer_jp']}", } for row in cur.fetchall() } cur.execute( """ SELECT field_name_jp, legacy_value, standardized_field, standardized_value, geom_scope, source_layer_scope, canonical_object_scope FROM navsea_field_value_rules WHERE bundle_id = %s AND bundle_version = %s ORDER BY rule_priority ASC, rule_id ASC, rule_revision ASC """, (resolved_bundle_id, resolved_bundle_version), ) field_value_rules: dict[tuple[str, str], list[FieldValueRule]] = {} for row in cur.fetchall(): key = (str(row["field_name_jp"]), str(row["legacy_value"])) field_value_rules.setdefault(key, []).append( FieldValueRule( field_name_jp=str(row["field_name_jp"]), legacy_value=str(row["legacy_value"]), standardized_field=str(row["standardized_field"]), standardized_value=str(row["standardized_value"]), geom_scope=normalize_csv_scope(row.get("geom_scope")), source_layer_scope=normalize_csv_scope(row.get("source_layer_scope")), canonical_object_scope=normalize_csv_scope(row.get("canonical_object_scope")), ) ) cur.execute( """ SELECT rule_id, priority, match_expr_json, output_json FROM navsea_render_rules WHERE bundle_id = %s AND bundle_version = %s AND enabled = 1 ORDER BY priority ASC, rule_id ASC, rule_revision ASC """, (resolved_bundle_id, resolved_bundle_version), ) render_rules = [] for row in cur.fetchall(): render_rules.append( RenderRule( rule_id=str(row["rule_id"]), priority=int(row["priority"]), match_expr=json.loads(row["match_expr_json"]), output=json.loads(row["output_json"]), ) ) return cls( bundle_id=resolved_bundle_id, bundle_version=resolved_bundle_version, source_layer_rules=source_layer_rules, field_value_rules=field_value_rules, render_rules=render_rules, ) def resolve_source_layer(self, source_layer_jp: str) -> tuple[str, str]: rule = self.source_layer_rules.get(source_layer_jp) if not rule: return source_layer_jp, f"SLR:{source_layer_jp}:identity" return rule["source_layer_std"], rule["source_layer_rule_id"] def standardize_field_value( self, field_name_jp: str, legacy_value: object, standardized_field: str, *, context: dict[str, str] | None = None, ) -> str | None: if legacy_value in (None, ""): return None key = (field_name_jp, str(legacy_value)) rules = self.field_value_rules.get(key, []) match_context = context or {} for rule in rules: if rule.standardized_field != standardized_field: continue if rule.matches(match_context): return rule.standardized_value return None def resolve_render_rule(self, context: dict[str, Any]) -> tuple[dict[str, Any], str | None]: for rule in self.render_rules: if rule.matches(context): return dict(rule.output), rule.rule_id return {}, None