diff --git a/entity/src/product_status.rs b/entity/src/product_status.rs index 3c46a684b..0df67f1c8 100644 --- a/entity/src/product_status.rs +++ b/entity/src/product_status.rs @@ -11,6 +11,10 @@ pub struct Model { pub package: Option, pub product_version_range_id: Uuid, pub context_cpe_id: Option, + /// Generated column: namespace part of package (NULL if no '/' in package) + pub package_namespace: Option, + /// Generated column: name part of package (everything after '/' or entire package if no '/') + pub package_name: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 2592b0bfc..e896d20f7 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -32,6 +32,7 @@ mod m0001170_non_null_source_document_id; mod m0001180_expand_spdx_licenses_with_mappings_function; mod m0001190_optimize_product_advisory_query; mod m0001200_source_document_fk_indexes; +mod m0001220_improve_product_status; pub struct Migrator; @@ -71,6 +72,7 @@ impl MigratorTrait for Migrator { Box::new(m0001180_expand_spdx_licenses_with_mappings_function::Migration), Box::new(m0001190_optimize_product_advisory_query::Migration), Box::new(m0001200_source_document_fk_indexes::Migration), + Box::new(m0001220_improve_product_status::Migration), ] } } diff --git a/migration/src/m0001220_improve_product_status.rs b/migration/src/m0001220_improve_product_status.rs new file mode 100644 index 000000000..5472ed7a4 --- /dev/null +++ b/migration/src/m0001220_improve_product_status.rs @@ -0,0 +1,83 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +#[allow(deprecated)] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Add generated columns for package_namespace and package_name + // These columns split the package field to enable indexed lookups: + // - package_namespace: NULL for packages without '/', otherwise the part before '/' + // - package_name: the part after '/' if present, otherwise the entire package value + // + // Examples: + // package = "lodash" -> namespace=NULL, name="lodash" + // package = "npmjs/lodash" -> namespace="npmjs", name="lodash" + // package = "@types/node" -> namespace="@types", name="node" + // + // This maintains compatibility with existing query patterns: + // - Match on name only: WHERE package_namespace IS NULL AND package_name = ? + // - Match on namespace/name: WHERE package_namespace = ? AND package_name = ? + manager + .get_connection() + .execute_unprepared( + "ALTER TABLE product_status \ + ADD COLUMN IF NOT EXISTS package_namespace text GENERATED ALWAYS AS (\ + CASE WHEN package LIKE '%/%' THEN split_part(package, '/', 1) ELSE NULL END\ + ) STORED", + ) + .await?; + + manager + .get_connection() + .execute_unprepared( + "ALTER TABLE product_status \ + ADD COLUMN IF NOT EXISTS package_name text GENERATED ALWAYS AS (\ + CASE WHEN package LIKE '%/%' THEN split_part(package, '/', 2) ELSE package END\ + ) STORED", + ) + .await?; + + // Backfill existing rows with UPDATE to trigger recalculation of generated columns + manager + .get_connection() + .execute_unprepared( + "UPDATE product_status SET package = package WHERE package_namespace IS NULL OR package_name IS NULL", + ) + .await?; + + // CONCURRENTLY (not supported by SeaORM) to avoid blocking writes + manager + .get_connection() + .execute_unprepared( + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_product_status_package_lookup \ + ON product_status (package_namespace, package_name)", + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared("DROP INDEX IF EXISTS idx_product_status_package_lookup") + .await?; + + manager + .get_connection() + .execute_unprepared("ALTER TABLE product_status DROP COLUMN IF EXISTS package_name") + .await?; + + manager + .get_connection() + .execute_unprepared( + "ALTER TABLE product_status DROP COLUMN IF EXISTS package_namespace", + ) + .await?; + + Ok(()) + } +} diff --git a/modules/fundamental/src/vulnerability/model/details/vulnerability_advisory.rs b/modules/fundamental/src/vulnerability/model/details/vulnerability_advisory.rs index 046ee07fd..8c31bdd60 100644 --- a/modules/fundamental/src/vulnerability/model/details/vulnerability_advisory.rs +++ b/modules/fundamental/src/vulnerability/model/details/vulnerability_advisory.rs @@ -243,7 +243,13 @@ impl VulnerabilityAdvisorySummary { JOIN "sbom" ON "product_version"."sbom_id" = "sbom"."sbom_id" -- find purls belonging to the sboms having a name matching package patterns - JOIN base_purl on "product_status"."package" LIKE CONCAT("base_purl"."namespace", '/', "base_purl"."name") OR "product_status"."package" = "base_purl"."name" + JOIN "base_purl" ON ( + ("product_status"."package_namespace" IS NULL AND "product_status"."package_name" = "base_purl"."name") + OR + ("product_status"."package_namespace" IS NOT NULL + AND "product_status"."package_namespace" IS NOT DISTINCT FROM "base_purl"."namespace" + AND "product_status"."package_name" = "base_purl"."name") + ) JOIN "versioned_purl" ON "versioned_purl"."base_purl_id" = "base_purl"."id" JOIN "qualified_purl" ON "qualified_purl"."versioned_purl_id" = "versioned_purl"."id" JOIN sbom_package_purl_ref on sbom_package_purl_ref.qualified_purl_id = qualified_purl.id AND sbom_package_purl_ref.sbom_id = sbom.sbom_id diff --git a/modules/ingestor/src/graph/advisory/product_status.rs b/modules/ingestor/src/graph/advisory/product_status.rs index 74697246c..13be3a541 100644 --- a/modules/ingestor/src/graph/advisory/product_status.rs +++ b/modules/ingestor/src/graph/advisory/product_status.rs @@ -66,6 +66,16 @@ impl ProductStatus { advisory_id: Uuid, vulnerability_id: String, ) -> product_status::ActiveModel { + let (package_namespace, package_name) = + self.package + .as_ref() + .map_or((None, None), |pkg| match pkg.split_once('/') { + Some((namespace, name)) => { + (Some(namespace.to_string()), Some(name.to_string())) + } + None => (None, Some(pkg.to_string())), + }); + product_status::ActiveModel { id: Set(self.uuid(advisory_id, vulnerability_id.clone())), advisory_id: Set(advisory_id), @@ -74,6 +84,8 @@ impl ProductStatus { package: Set(self.package), context_cpe_id: Set(self.cpe.as_ref().map(Cpe::uuid)), product_version_range_id: Set(self.product_version_range_id), + package_namespace: Set(package_namespace), + package_name: Set(package_name), } }