From c23782095c274b29bbd015394a3dde1e08a11b8e Mon Sep 17 00:00:00 2001 From: gti Date: Tue, 22 Sep 2026 21:29:09 -0300 Subject: [PATCH] fix: keep migration and blocker SQL postgres-safe --- .../0018_blocker_installation_identity.py | 2 +- central/app/control_plane.py | 7 ++++++- central/tests/test_migration_revision_lengths.py | 15 +++++++++++++++ central/tests/test_operational_view_models.py | 13 ++++++++++++- 4 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 central/tests/test_migration_revision_lengths.py diff --git a/central/alembic/versions/0018_blocker_installation_identity.py b/central/alembic/versions/0018_blocker_installation_identity.py index 5fcec5e..644a7cc 100644 --- a/central/alembic/versions/0018_blocker_installation_identity.py +++ b/central/alembic/versions/0018_blocker_installation_identity.py @@ -4,7 +4,7 @@ from alembic import op import sqlalchemy as sa -revision = "0018_blocker_installation_identity" +revision = "0018_blocker_installation" down_revision = "0017_control_plane_foundation" branch_labels = None depends_on = None diff --git a/central/app/control_plane.py b/central/app/control_plane.py index 446da02..748321b 100644 --- a/central/app/control_plane.py +++ b/central/app/control_plane.py @@ -264,7 +264,7 @@ def blocker_filter_summary(session, *, tenant_id=None, agent_id=None, product_co query = _blockers_query(session, tenant_id=tenant_id, agent_id=agent_id, product_code=product_code, blocker_type=blocker_type, field=field, status=status, client=client, installation=installation, search=search, age=age) open_count = func.sum(case((BlockedProduct.resolved_at.is_(None), 1), else_=0)) open_count, = session.execute(query.with_only_columns(open_count).order_by(None)).one() - rows = session.execute(query.with_only_columns(func.coalesce(BlockedProduct.field, "OTHER"), func.count(BlockedProduct.id)).group_by(func.coalesce(BlockedProduct.field, "OTHER")).order_by(None)).all() + rows = session.execute(_blocker_breakdown_query(query)).all() breakdown = {field: 0 for field in BLOCKER_FIELDS} for field_name, count in rows: key = str(field_name or "").upper() @@ -279,6 +279,11 @@ def blocker_filter_summary(session, *, tenant_id=None, agent_id=None, product_co } +def _blocker_breakdown_query(query): + """Group on the source column itself for PostgreSQL-safe SQL semantics.""" + return query.with_only_columns(BlockedProduct.field, func.count(BlockedProduct.id)).group_by(BlockedProduct.field).order_by(None) + + def list_blockers(session, *, tenant_id=None, agent_id=None, product_code=None, page=1, page_size=50, blocker_type=None, field=None, resolved=False, client=None, installation=None, status="OPEN", age=None, search=None): page, page_size = _page(page, page_size) if resolved and status == "OPEN": diff --git a/central/tests/test_migration_revision_lengths.py b/central/tests/test_migration_revision_lengths.py new file mode 100644 index 0000000..c64d379 --- /dev/null +++ b/central/tests/test_migration_revision_lengths.py @@ -0,0 +1,15 @@ +import importlib.util +from pathlib import Path + + +def test_alembic_revision_ids_fit_version_num_column(): + versions = Path(__file__).parents[1] / "alembic" / "versions" + too_long = [] + for path in versions.glob("*.py"): + spec = importlib.util.spec_from_file_location(f"migration_{path.stem}", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + revision = getattr(module, "revision", None) + if revision is not None and len(revision) > 32: + too_long.append((path.name, revision, len(revision))) + assert too_long == [] diff --git a/central/tests/test_operational_view_models.py b/central/tests/test_operational_view_models.py index 2f675c7..600ed5f 100644 --- a/central/tests/test_operational_view_models.py +++ b/central/tests/test_operational_view_models.py @@ -1,10 +1,11 @@ from datetime import datetime, timedelta, timezone from sqlalchemy import event +from sqlalchemy.dialects import postgresql from central.app.audit import record_audit from central.app.blocked_products import upsert_blocker -from central.app.control_plane import dashboard_summary, list_blockers, list_installations, list_jobs +from central.app.control_plane import _blocker_breakdown_query, dashboard_summary, list_blockers, list_installations, list_jobs from central.app.events import record_event from central.app.models import Agent, AgentSnapshot, AuditEvent, BlockedProduct, Installation, Job, Tenant from central.tests.test_control_plane import setup_db @@ -310,3 +311,13 @@ def test_lowercase_required_blocker_field_reaches_attention_candidates(): attention = dashboard_summary(session, now=NOW)["attention_items"] assert any(item["link"] == f"/blockers/{blocker.id}" for item in attention) + + +def test_blocker_breakdown_query_groups_on_same_postgres_expression(): + from sqlalchemy import select + + query = select(BlockedProduct, Tenant).join(Tenant, Tenant.id == BlockedProduct.tenant_id) + sql = str(_blocker_breakdown_query(query).compile(dialect=postgresql.dialect())) + + assert "GROUP BY blocked_products.field" in sql + assert "GROUP BY coalesce" not in sql