Verified Commit dc8d7541 authored by Bohdan Parkhomchuk's avatar Bohdan Parkhomchuk 💬 Committed by GitLab
Browse files

fix(indexer): map polymorphic noteable_type to WorkItem

parent 0a4681fc
Loading
Loading
Loading
Loading
+1 −1
Original line number Diff line number Diff line
1
2
+9 −0
Original line number Diff line number Diff line
@@ -125,6 +125,15 @@ etl:
      to_column: noteable_type
      as: HAS_NOTE
      direction: incoming
      # Rails stores the polymorphic base class in `noteable_type` via
      # `Note#noteable_type=` → `klass.base_class.to_s`. WorkItem subclasses of
      # Issue (Task, Incident, Ticket, Objective, KeyResult, TestCase,
      # Requirement) all collapse to `Issue`. Legacy Epic notes created via
      # `Groups::Epics::NotesController` still arrive as `Epic` until the
      # unified work-item model fully replaces the legacy record.
      type_mapping:
        Issue: WorkItem
        Epic: WorkItem

storage:
  primary_key: [traversal_path, id]
+5 −0
Original line number Diff line number Diff line
@@ -160,6 +160,11 @@
          "type": "string",
          "description": "Column containing the target node type (for polymorphic edges)."
        },
        "type_mapping": {
          "type": "object",
          "description": "Maps raw column values to ontology node types (e.g., Issue -> WorkItem). Only valid with `to_column`.",
          "additionalProperties": { "type": "string" }
        },
        "as": {
          "type": "string",
          "description": "Relationship kind name for the generated edge."
+25 −15
Original line number Diff line number Diff line
@@ -68,8 +68,7 @@ pub(in crate::modules::sdlc) enum EdgeId {

pub(in crate::modules::sdlc) enum EdgeKind {
    Literal(String),
    Column(String),
    TypeMapping {
    Column {
        column: String,
        mapping: BTreeMap<String, String>,
    },
@@ -171,8 +170,8 @@ fn collect_fk_extract_columns(etl: &EtlConfig, namespaced: bool) -> Vec<String>

    for (fk_column, mapping) in etl.edges() {
        columns.push(fk_column.clone());
        if let EdgeTarget::Column(type_column) = &mapping.target {
            columns.push(type_column.clone());
        if let EdgeTarget::Column { column, .. } = &mapping.target {
            columns.push(column.clone());
        }
    }

@@ -226,21 +225,36 @@ fn resolve_fk_edges(

            let (fk_kind, type_filter) = match &mapping.target {
                EdgeTarget::Literal(target_type) => (EdgeKind::Literal(target_type.clone()), None),
                EdgeTarget::Column(type_column) => {
                EdgeTarget::Column {
                    column: type_column,
                    type_mapping,
                } => {
                    let allowed = ontology.get_edge_target_types(
                        &mapping.relationship_kind,
                        node_name,
                        mapping.direction,
                    );
                    let filter = if allowed.is_empty() {
                    // Raw legacy values (e.g. "Issue") must survive the extract
                    // filter; the CASE below maps them to ontology names.
                    let mut filter_types = allowed;
                    for raw in type_mapping.keys() {
                        if !filter_types.iter().any(|t| t == raw) {
                            filter_types.push(raw.clone());
                        }
                    }
                    let filter = if filter_types.is_empty() {
                        None
                    } else {
                        Some(EdgeFilter::TypeIn {
                            column: type_column.clone(),
                            types: allowed,
                            types: filter_types,
                        })
                    };
                    (EdgeKind::Column(type_column.clone()), filter)
                    let kind = EdgeKind::Column {
                        column: type_column.clone(),
                        mapping: type_mapping.clone(),
                    };
                    (kind, filter)
                }
            };

@@ -395,13 +409,9 @@ fn resolve_endpoint(
                    types: allowed,
                })
            };
            let kind = if type_mapping.is_empty() {
                EdgeKind::Column(column.clone())
            } else {
                EdgeKind::TypeMapping {
            let kind = EdgeKind::Column {
                column: column.clone(),
                mapping: type_mapping.clone(),
                }
            };
            (id, kind, filter)
        }
+87 −2
Original line number Diff line number Diff line
@@ -183,8 +183,8 @@ fn lower_edge_id(id: &EdgeId) -> Expr {
fn lower_edge_kind(kind: &EdgeKind) -> Expr {
    match kind {
        EdgeKind::Literal(value) => Expr::raw(format!("'{value}'")),
        EdgeKind::Column(column) => Expr::col("", column),
        EdgeKind::TypeMapping { column, mapping } => {
        EdgeKind::Column { column, mapping } if mapping.is_empty() => Expr::col("", column),
        EdgeKind::Column { column, mapping } => {
            let cases: Vec<String> = mapping
                .iter()
                .map(|(from, to)| format!("WHEN {column} = '{from}' THEN '{to}'"))
@@ -385,6 +385,41 @@ mod tests {
        );
    }

    #[test]
    fn note_has_note_edge_transform_applies_type_mapping() {
        let ontology = ontology::Ontology::load_embedded().expect("should load ontology");
        let plans = build_plans(&ontology, 1_000_000);

        let note_plan = plans.namespaced.iter().find(|p| p.name == "Note").unwrap();
        let sql = note_plan
            .transforms
            .iter()
            .map(|t| emit(&t.query))
            .find(|sql| sql.contains("'HAS_NOTE' AS relationship_kind"))
            .expect("HAS_NOTE transform on Note plan");

        assert!(
            sql.contains("WHEN noteable_type = 'Issue' THEN 'WorkItem'"),
            "sql: {sql}"
        );
        assert!(
            sql.contains("WHEN noteable_type = 'Epic' THEN 'WorkItem'"),
            "sql: {sql}"
        );
        // Raw Rails values pass the extract TypeIn filter so the CASE can map them.
        assert!(
            sql.contains("'Issue'"),
            "sql should keep raw Issue for filter: {sql}"
        );
        assert!(
            sql.contains("'Epic'"),
            "sql should keep raw Epic for filter: {sql}"
        );
        // Ontology-native values (verbatim matches) stay allowed.
        assert!(sql.contains("'MergeRequest'"), "sql: {sql}");
        assert!(sql.contains("'Vulnerability'"), "sql: {sql}");
    }

    #[test]
    fn standalone_edges_produce_separate_plans() {
        let ontology = ontology::Ontology::load_embedded().expect("should load ontology");
@@ -485,6 +520,56 @@ mod tests {
        assert!(sql.contains("'Note' AS target_kind"));
    }

    #[test]
    fn fk_edge_transform_sql_type_mapping_collapses_raw_values() {
        let mut mapping = BTreeMap::new();
        mapping.insert("Issue".to_string(), "WorkItem".to_string());
        mapping.insert("Epic".to_string(), "WorkItem".to_string());

        let fk_edge = FkEdgeTransform {
            relationship_kind: "HAS_NOTE".to_string(),
            source_id: EdgeId::Column("noteable_id".to_string()),
            source_kind: EdgeKind::Column {
                column: "noteable_type".to_string(),
                mapping,
            },
            target_id: EdgeId::Column("id".to_string()),
            target_kind: EdgeKind::Literal("Note".to_string()),
            filters: vec![
                EdgeFilter::IsNotNull("noteable_id".to_string()),
                EdgeFilter::TypeIn {
                    column: "noteable_type".to_string(),
                    types: vec![
                        "MergeRequest".to_string(),
                        "WorkItem".to_string(),
                        "Vulnerability".to_string(),
                        "Issue".to_string(),
                        "Epic".to_string(),
                    ],
                },
            ],
            namespaced: true,
            destination_table: "gl_edge".to_string(),
        };

        let transform = lower_fk_edge_transform(&fk_edge);
        let sql = emit(&transform.query);

        // Mapped values collapse to ontology names via CASE.
        assert!(
            sql.contains("WHEN noteable_type = 'Issue' THEN 'WorkItem'"),
            "sql: {sql}"
        );
        assert!(
            sql.contains("WHEN noteable_type = 'Epic' THEN 'WorkItem'"),
            "sql: {sql}"
        );
        assert!(sql.contains("ELSE noteable_type END"), "sql: {sql}");
        // Raw legacy values must survive the extract filter so the CASE can map them.
        assert!(sql.contains("'Issue'"), "sql: {sql}");
        assert!(sql.contains("'Epic'"), "sql: {sql}");
    }

    #[test]
    fn fk_edge_transform_sql_multi_value_incoming() {
        let fk_edge = FkEdgeTransform {
Loading