Loading config/SCHEMA_VERSION +1 −1 Original line number Diff line number Diff line 1 2 config/ontology/nodes/core/note.yaml +9 −0 Original line number Diff line number Diff line Loading @@ -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] Loading config/schemas/ontology.schema.json +5 −0 Original line number Diff line number Diff line Loading @@ -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." Loading crates/indexer/src/modules/sdlc/plan/input.rs +25 −15 Original line number Diff line number Diff line Loading @@ -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>, }, Loading Loading @@ -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()); } } Loading Loading @@ -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) } }; Loading Loading @@ -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) } Loading crates/indexer/src/modules/sdlc/plan/lower.rs +87 −2 Original line number Diff line number Diff line Loading @@ -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}'")) Loading Loading @@ -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"); Loading Loading @@ -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 Loading
config/ontology/nodes/core/note.yaml +9 −0 Original line number Diff line number Diff line Loading @@ -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] Loading
config/schemas/ontology.schema.json +5 −0 Original line number Diff line number Diff line Loading @@ -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." Loading
crates/indexer/src/modules/sdlc/plan/input.rs +25 −15 Original line number Diff line number Diff line Loading @@ -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>, }, Loading Loading @@ -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()); } } Loading Loading @@ -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) } }; Loading Loading @@ -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) } Loading
crates/indexer/src/modules/sdlc/plan/lower.rs +87 −2 Original line number Diff line number Diff line Loading @@ -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}'")) Loading Loading @@ -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"); Loading Loading @@ -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