1use std::collections::HashMap;
27use std::sync::Weak;
28
29use async_trait::async_trait;
30use auth::{
31 PermissionChecker, PermissionCheckerRef, PermissionReq, PermissionTableTarget,
32 PermissionTableTargets, SEMANTIC_GRAPH_QUERY,
33};
34use catalog::CatalogManager;
35use catalog::system_schema::semantic_graph::EntityGraphProvider;
36use common_catalog::consts::{
37 DEFAULT_PRIVATE_SCHEMA_NAME, DEFAULT_SCHEMA_NAME, INFORMATION_SCHEMA_NAME, OBSERVED_AT_COLUMN,
38 PG_CATALOG_NAME, SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME,
39};
40use common_error::ext::{BoxedError, ErrorExt};
41use common_error::status_code::StatusCode;
42use common_query::OutputData;
43use common_recordbatch::SendableRecordBatchStream;
44use common_telemetry::{debug, warn};
45use common_time::timestamp::TimeUnit;
46use datafusion::dataframe::DataFrame;
47use datafusion_expr::LogicalPlan;
48use futures::TryStreamExt;
49use operator::statement::semantic_graph::{
50 CallsSource, CoDeclaredSource, Conventions, DeclaredSource, ENTITY_TYPE_GEN_AI_AGENT,
51 ENTITY_TYPE_SERVICE, EntityDeclaration, GraphQueryWindow, ImplicitEntity, RegistrySource,
52 RelationshipSources, build_registry_plan, build_relationships_plan, conventions,
53 declared_relationships_schema_matches,
54};
55use query::QueryEngineRef;
56use session::context::{QueryContext, QueryContextBuilder, QueryContextRef};
57use snafu::ResultExt;
58use store_api::storage::ScanRequest;
59use table::TableRef;
60use table::metadata::TableInfo;
61use table::predicate::{TimeRangeExtraction, extract_time_range_strict};
62use table::requests::{
63 EntityRole, SEMANTIC_SIGNAL_TYPE, SEMANTIC_SOURCE, SIGNAL_TYPE_METRIC, SOURCE_PROMETHEUS,
64 is_trace_v1_table, parse_entity_columns, parse_entity_option_key,
65};
66
67use crate::error;
68
69pub struct EntityGraphProviderImpl {
71 query_engine: QueryEngineRef,
72 catalog_manager: Weak<dyn CatalogManager>,
73 permission_checker: Option<PermissionCheckerRef>,
74}
75
76struct EntitySource {
77 declarations: Vec<EntityDeclaration>,
78 is_trace: bool,
79 table: TableRef,
80}
81
82struct TraceSource {
83 service: Option<EntityDeclaration>,
84 agent: Option<EntityDeclaration>,
85 table: TableRef,
86}
87
88impl EntityGraphProviderImpl {
89 pub fn new(
90 query_engine: QueryEngineRef,
91 catalog_manager: Weak<dyn CatalogManager>,
92 permission_checker: Option<PermissionCheckerRef>,
93 ) -> Self {
94 Self {
95 query_engine,
96 catalog_manager,
97 permission_checker,
98 }
99 }
100
101 fn authorize_sources(
105 &self,
106 query_ctx: Option<&QueryContext>,
107 targets: PermissionTableTargets,
108 ) -> Result<bool, BoxedError> {
109 let Some(ctx) = query_ctx else {
110 return Ok(true);
111 };
112 match self
113 .permission_checker
114 .as_ref()
115 .check_permission_with_table_targets(
116 ctx.current_user(),
117 PermissionReq::Action(SEMANTIC_GRAPH_QUERY),
118 targets,
119 ) {
120 Ok(_) => Ok(true),
121 Err(err) if err.status_code() == StatusCode::PermissionDenied => Ok(false),
122 Err(err) => Err(BoxedError::new(err)),
123 }
124 }
125
126 fn parse_declarations(table_info: &TableInfo) -> Vec<EntityDeclaration> {
129 let Some(time_index) = table_info
130 .meta
131 .schema
132 .timestamp_column()
133 .map(|c| c.name.clone())
134 else {
135 return vec![];
136 };
137
138 type RoleColumns = (Vec<String>, Vec<String>, Vec<String>);
140 let mut by_type: HashMap<String, RoleColumns> = HashMap::new();
141 for (key, value) in &table_info.meta.options.extra_options {
142 let Some((entity_type, role)) = parse_entity_option_key(key) else {
143 continue;
144 };
145 let cols = parse_entity_columns(value);
146 let entry = by_type.entry(entity_type.to_string()).or_default();
147 match role {
148 EntityRole::Id => entry.0 = cols,
149 EntityRole::Descriptive => entry.1 = cols,
150 EntityRole::Scope => entry.2 = cols,
151 }
152 }
153
154 by_type
155 .into_iter()
156 .filter(|(_, (id, _, _))| !id.is_empty())
157 .filter_map(
158 |(entity_type, (id_columns, descriptive_columns, scope_columns))| {
159 let schema = &table_info.meta.schema;
162 if let Some(missing) = id_columns
163 .iter()
164 .chain(&descriptive_columns)
165 .chain(&scope_columns)
166 .find(|c| schema.column_schema_by_name(c).is_none())
167 {
168 warn!(
169 "Skipping entity declaration `{}` of table `{}`: column `{}` not found",
170 entity_type, table_info.name, missing
171 );
172 return None;
173 }
174 Some(EntityDeclaration {
175 schema: table_info.schema_name.clone(),
176 table: table_info.name.clone(),
177 time_index: time_index.clone(),
178 entity_type,
179 id_columns,
180 descriptive_columns,
181 scope_columns,
182 })
183 },
184 )
185 .collect()
186 }
187
188 fn declarations_for(
196 table_info: &TableInfo,
197 conventions: &Conventions,
198 ) -> Vec<EntityDeclaration> {
199 let mut declarations = Self::parse_declarations(table_info);
200 if is_trace_v1_table(table_info) {
201 Self::extend_with_implicit_entities(
202 table_info,
203 &conventions.otlp_trace_entities,
204 &mut declarations,
205 );
206 }
207 Self::extend_with_prometheus_conventions(table_info, conventions, &mut declarations);
208 declarations
209 }
210
211 fn explicitly_declares(table_info: &TableInfo, entity_type: &str) -> bool {
213 table_info.meta.options.extra_options.keys().any(|key| {
214 parse_entity_option_key(key)
215 .is_some_and(|(ty, role)| ty == entity_type && role == EntityRole::Id)
216 })
217 }
218
219 fn extend_with_prometheus_conventions(
226 table_info: &TableInfo,
227 conventions: &Conventions,
228 declarations: &mut Vec<EntityDeclaration>,
229 ) {
230 let Some(implicit_entities) = conventions.prometheus_info_metrics.get(&table_info.name)
231 else {
232 return;
233 };
234 let options = &table_info.meta.options.extra_options;
235 if options.get(SEMANTIC_SIGNAL_TYPE).map(String::as_str) != Some(SIGNAL_TYPE_METRIC)
236 || options.get(SEMANTIC_SOURCE).map(String::as_str) != Some(SOURCE_PROMETHEUS)
237 || table_info.is_physical_table()
238 {
239 debug!(
240 "Table `{}` matches the info-metric whitelist but is not a prometheus logical \
241 metric table; skipping its implicit declarations",
242 table_info.name
243 );
244 return;
245 }
246 Self::extend_with_implicit_entities(table_info, implicit_entities, declarations);
247 }
248
249 fn extend_with_implicit_entities(
253 table_info: &TableInfo,
254 entities: &[ImplicitEntity],
255 declarations: &mut Vec<EntityDeclaration>,
256 ) {
257 let schema = &table_info.meta.schema;
258 let Some(time_index) = schema.timestamp_column().map(|c| c.name.clone()) else {
259 debug!(
260 "Table `{}` has no time index; skipping its implicit declarations",
261 table_info.name
262 );
263 return;
264 };
265 for implicit in entities {
266 if Self::explicitly_declares(table_info, &implicit.entity) {
267 debug!(
268 "Table `{}` explicitly declares `{}`; the implicit declaration is suppressed",
269 table_info.name, implicit.entity
270 );
271 continue;
272 }
273 if let Some(missing) = implicit
274 .id
275 .iter()
276 .find(|c| schema.column_schema_by_name(c).is_none())
277 {
278 debug!(
279 "Table `{}` lacks the id column `{}`; skipping the implicit `{}` declaration",
280 table_info.name, missing, implicit.entity
281 );
282 continue;
283 }
284 let descriptive_columns = if implicit.descriptive_rest {
285 table_info
286 .meta
287 .row_key_column_names()
288 .filter(|c| !implicit.id.contains(c))
289 .cloned()
290 .collect()
291 } else {
292 implicit
293 .descriptive
294 .iter()
295 .filter(|c| schema.column_schema_by_name(c).is_some())
296 .cloned()
297 .collect()
298 };
299 declarations.push(EntityDeclaration {
300 schema: table_info.schema_name.clone(),
301 table: table_info.name.clone(),
302 time_index: time_index.clone(),
303 entity_type: implicit.entity.clone(),
304 id_columns: implicit.id.clone(),
305 descriptive_columns,
306 scope_columns: vec![],
307 });
308 }
309 }
310
311 async fn enumerate(
316 &self,
317 catalog: &str,
318 query_ctx: Option<&QueryContext>,
319 ) -> Result<(Vec<EntitySource>, Vec<TraceSource>), BoxedError> {
320 let Some(catalog_manager) = self.catalog_manager.upgrade() else {
321 return Ok((vec![], vec![]));
322 };
323 let conventions = conventions()
324 .map_err(datafusion::error::DataFusionError::Internal)
325 .context(error::DataFusionSnafu)
326 .map_err(BoxedError::new)?;
327
328 let per_table_auth = match self.permission_checker.as_ref() {
331 Some(checker) if query_ctx.is_some() => {
332 if checker.uses_table_targets() {
333 true
334 } else if self
335 .authorize_sources(query_ctx, PermissionTableTargets::resolved(vec![]))?
336 {
337 false
338 } else {
339 debug!(
340 "Caller lacks the entity-graph read permission; deriving an empty graph \
341 (catalog: {catalog})"
342 );
343 return Ok((vec![], vec![]));
344 }
345 }
346 _ => false,
347 };
348
349 let mut declarations = vec![];
350 let mut traces = vec![];
351 let schemas = catalog_manager
352 .schema_names(catalog, query_ctx)
353 .await
354 .map_err(BoxedError::new)?;
355 for schema in schemas {
356 if schema == INFORMATION_SCHEMA_NAME
359 || schema == PG_CATALOG_NAME
360 || schema == DEFAULT_PRIVATE_SCHEMA_NAME
361 {
362 continue;
363 }
364 let mut tables = catalog_manager.tables(catalog, &schema, query_ctx);
365 while let Some(table) = tables.try_next().await.map_err(BoxedError::new)? {
366 let table_info = table.table_info();
367 let table_declarations = Self::declarations_for(&table_info, conventions);
368 let is_trace = is_trace_v1_table(&table_info);
369 if per_table_auth
371 && (is_trace || !table_declarations.is_empty())
372 && !self.authorize_sources(
373 query_ctx,
374 PermissionTableTargets::resolved(vec![PermissionTableTarget::new(
375 catalog,
376 &schema,
377 &table_info.name,
378 )]),
379 )?
380 {
381 debug!(
382 "Excluding `{schema}.{}` from the entity-graph derivation: caller lacks \
383 read permission",
384 table_info.name
385 );
386 continue;
387 }
388 if is_trace {
389 let find = |entity_type: &str| {
390 table_declarations
391 .iter()
392 .find(|d| d.entity_type == entity_type)
393 .cloned()
394 };
395 let service = find(ENTITY_TYPE_SERVICE);
396 let agent = find(ENTITY_TYPE_GEN_AI_AGENT);
397 if service.is_none() {
398 warn!(
401 "Trace table `{}` has no usable service declaration; skipping calls derivation",
402 table_info.name
403 );
404 }
405 if service.is_some() || agent.is_some() {
406 traces.push(TraceSource {
407 service,
408 agent,
409 table: table.clone(),
410 });
411 }
412 }
413 if !table_declarations.is_empty() {
414 declarations.push(EntitySource {
415 declarations: table_declarations,
416 is_trace,
417 table,
418 });
419 }
420 }
421 }
422 Ok((declarations, traces))
423 }
424
425 fn query_window(request: &ScanRequest) -> Result<Option<GraphQueryWindow>, BoxedError> {
437 let invalid =
438 |err_msg: String| Err(BoxedError::new(error::InvalidSqlSnafu { err_msg }.build()));
439 match extract_time_range_strict(OBSERVED_AT_COLUMN, TimeUnit::Millisecond, &request.filters)
440 {
441 TimeRangeExtraction::Absent => Ok(Some(GraphQueryWindow::default_last_hour())),
442 TimeRangeExtraction::Extracted(range) => {
443 let Some(start) = range.start() else {
444 return invalid(format!(
445 "the {OBSERVED_AT_COLUMN} filter has no lower bound; the graph cannot \
446 derive over unbounded history — add e.g. {OBSERVED_AT_COLUMN} >= \
447 '2026-01-01 00:00:00'"
448 ));
449 };
450 let end_ms = range
451 .end()
452 .map(|ts| ts.value())
453 .unwrap_or_else(common_time::util::current_time_millis);
454 if start.value() >= end_ms {
455 return Ok(None);
456 }
457 Ok(Some(GraphQueryWindow::from_observed(start.value(), end_ms)))
458 }
459 TimeRangeExtraction::Unsupported => invalid(format!(
460 "cannot derive the graph window from the {OBSERVED_AT_COLUMN} filter; use plain \
461 range predicates (>=, <, BETWEEN) with literal bounds"
462 )),
463 }
464 }
465
466 fn read_table(&self, table: TableRef) -> Result<DataFrame, BoxedError> {
467 self.query_engine.read_table(table).map_err(BoxedError::new)
468 }
469
470 async fn declared_source(
475 &self,
476 catalog: &str,
477 query_ctx: Option<&QueryContext>,
478 ) -> Result<Option<DeclaredSource>, BoxedError> {
479 let Some(catalog_manager) = self.catalog_manager.upgrade() else {
480 return Ok(None);
481 };
482 let Some(table) = catalog_manager
483 .table(
484 catalog,
485 DEFAULT_PRIVATE_SCHEMA_NAME,
486 SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME,
487 query_ctx,
488 )
489 .await
490 .map_err(BoxedError::new)?
491 else {
492 return Ok(None);
493 };
494 if !self.authorize_sources(
495 query_ctx,
496 PermissionTableTargets::resolved(vec![PermissionTableTarget::new(
497 catalog,
498 DEFAULT_PRIVATE_SCHEMA_NAME,
499 SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME,
500 )]),
501 )? {
502 debug!(
503 "Excluding declared edges: caller lacks read permission on \
504 `{DEFAULT_PRIVATE_SCHEMA_NAME}.{SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME}`"
505 );
506 return Ok(None);
507 }
508 let table_info = table.table_info();
509 if !declared_relationships_schema_matches(&table_info) {
510 return Err(BoxedError::new(
511 error::InvalidSqlSnafu {
512 err_msg: format!(
513 "{DEFAULT_PRIVATE_SCHEMA_NAME}.{SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME} \
514 does not match its canonical schema; declared edges cannot be derived"
515 ),
516 }
517 .build(),
518 ));
519 }
520 Ok(Some(DeclaredSource {
521 scan: self.read_table(table)?,
522 }))
523 }
524
525 async fn execute_plan(
529 &self,
530 catalog: &str,
531 plan: LogicalPlan,
532 query_ctx: Option<QueryContextRef>,
533 ) -> Result<Option<SendableRecordBatchStream>, BoxedError> {
534 let query_ctx = query_ctx.unwrap_or_else(|| {
535 QueryContextBuilder::default()
536 .current_catalog(catalog.to_string())
537 .current_schema(DEFAULT_SCHEMA_NAME.to_string())
538 .build()
539 .into()
540 });
541 let output = self
542 .query_engine
543 .execute(plan, query_ctx)
544 .await
545 .map_err(BoxedError::new)?;
546 let stream = match output.data {
547 OutputData::Stream(stream) => stream,
548 OutputData::RecordBatches(batches) => batches.as_stream(),
549 OutputData::AffectedRows(_) => return Ok(None),
550 };
551 Ok(Some(stream))
552 }
553}
554
555#[async_trait]
556impl EntityGraphProvider for EntityGraphProviderImpl {
557 async fn scan_entities(
558 &self,
559 catalog: &str,
560 request: ScanRequest,
561 query_ctx: Option<QueryContextRef>,
562 ) -> Result<Option<SendableRecordBatchStream>, BoxedError> {
563 let (sources, _) = self.enumerate(catalog, query_ctx.as_deref()).await?;
564 let mut plans = Vec::with_capacity(sources.len());
565 for source in sources {
566 plans.push(RegistrySource {
567 declarations: source.declarations,
568 scan: self.read_table(source.table)?,
569 });
570 }
571 let Some(window) = Self::query_window(&request)? else {
572 return Ok(None);
573 };
574 let Some(plan) = build_registry_plan(plans, &window)
575 .context(error::DataFusionSnafu)
576 .map_err(BoxedError::new)?
577 else {
578 return Ok(None);
579 };
580 self.execute_plan(catalog, plan, query_ctx).await
581 }
582
583 async fn scan_relationships(
584 &self,
585 catalog: &str,
586 request: ScanRequest,
587 query_ctx: Option<QueryContextRef>,
588 ) -> Result<Option<SendableRecordBatchStream>, BoxedError> {
589 let (sources, traces) = self.enumerate(catalog, query_ctx.as_deref()).await?;
590 let mut calls = Vec::with_capacity(traces.len());
591 for trace in traces {
592 calls.push(CallsSource {
593 service: trace.service,
594 agent: trace.agent,
595 scan: self.read_table(trace.table)?,
596 });
597 }
598 let mut co_declared = Vec::with_capacity(sources.len());
599 for source in sources {
600 co_declared.push(CoDeclaredSource {
601 declarations: source.declarations,
602 is_trace: source.is_trace,
603 scan: self.read_table(source.table)?,
604 });
605 }
606 let declared = self.declared_source(catalog, query_ctx.as_deref()).await?;
607 let Some(window) = Self::query_window(&request)? else {
608 return Ok(None);
609 };
610 let Some(plan) = build_relationships_plan(
611 RelationshipSources {
612 traces: calls,
613 co_declared,
614 declared,
615 },
616 &window,
617 )
618 .context(error::DataFusionSnafu)
619 .map_err(BoxedError::new)?
620 else {
621 return Ok(None);
622 };
623 self.execute_plan(catalog, plan, query_ctx).await
624 }
625}
626
627#[cfg(test)]
628mod tests {
629 use std::collections::HashMap;
630 use std::sync::Arc;
631
632 use common_catalog::consts::{DEFAULT_CATALOG_NAME, MITO_ENGINE};
633 use datatypes::prelude::ConcreteDataType;
634 use datatypes::schema::{ColumnSchema, SchemaBuilder};
635 use store_api::metric_engine_consts::PHYSICAL_TABLE_METADATA_KEY;
636 use table::metadata::{TableInfoBuilder, TableMeta, TableType};
637 use table::requests::{TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1, TableOptions};
638
639 use super::*;
640
641 fn table_info(columns: &[&str], extra: &[(&str, &str)]) -> TableInfo {
642 let mut column_schemas = vec![
643 ColumnSchema::new(
644 "ts",
645 ConcreteDataType::timestamp_millisecond_datatype(),
646 false,
647 )
648 .with_time_index(true),
649 ];
650 column_schemas.extend(
651 columns
652 .iter()
653 .map(|c| ColumnSchema::new(*c, ConcreteDataType::string_datatype(), true)),
654 );
655 assemble_table_info(column_schemas, extra)
656 }
657
658 fn assemble_table_info(column_schemas: Vec<ColumnSchema>, extra: &[(&str, &str)]) -> TableInfo {
659 named_table_info("t1", column_schemas, vec![], extra)
660 }
661
662 fn named_table_info(
663 name: &str,
664 column_schemas: Vec<ColumnSchema>,
665 primary_key_indices: Vec<usize>,
666 extra: &[(&str, &str)],
667 ) -> TableInfo {
668 let schema = Arc::new(
669 SchemaBuilder::try_from_columns(column_schemas)
670 .unwrap()
671 .build()
672 .unwrap(),
673 );
674 let options = TableOptions {
675 extra_options: extra
676 .iter()
677 .map(|(k, v)| (k.to_string(), v.to_string()))
678 .collect::<HashMap<_, _>>(),
679 ..Default::default()
680 };
681 let meta = TableMeta {
682 schema,
683 primary_key_indices,
684 value_indices: vec![],
685 engine: MITO_ENGINE.to_string(),
686 next_column_id: 1,
687 options,
688 created_on: Default::default(),
689 updated_on: Default::default(),
690 partition_key_indices: vec![],
691 column_ids: vec![],
692 };
693 TableInfoBuilder::default()
694 .table_id(1)
695 .name(name)
696 .catalog_name(DEFAULT_CATALOG_NAME)
697 .schema_name(DEFAULT_SCHEMA_NAME)
698 .table_version(0)
699 .table_type(TableType::Base)
700 .meta(meta)
701 .build()
702 .unwrap()
703 }
704
705 fn prom_table_info(name: &str, tags: &[&str], extra: &[(&str, &str)]) -> TableInfo {
707 let mut column_schemas = vec![
708 ColumnSchema::new(
709 "greptime_timestamp",
710 ConcreteDataType::timestamp_millisecond_datatype(),
711 false,
712 )
713 .with_time_index(true),
714 ];
715 column_schemas.extend(
716 tags.iter()
717 .map(|c| ColumnSchema::new(*c, ConcreteDataType::string_datatype(), true)),
718 );
719 let primary_key_indices = (1..=tags.len()).collect();
720 named_table_info(name, column_schemas, primary_key_indices, extra)
721 }
722
723 #[test]
724 fn future_lower_only_window_matches_nothing() {
725 let future_ms = common_time::util::current_time_millis() + 86_400_000;
726 let request = ScanRequest {
727 filters: vec![
728 datafusion_expr::col(OBSERVED_AT_COLUMN).gt_eq(datafusion_expr::lit(
729 datafusion::common::ScalarValue::TimestampMillisecond(Some(future_ms), None),
730 )),
731 ],
732 ..Default::default()
733 };
734 assert!(
735 EntityGraphProviderImpl::query_window(&request)
736 .unwrap()
737 .is_none()
738 );
739
740 assert!(
741 EntityGraphProviderImpl::query_window(&ScanRequest::default())
742 .unwrap()
743 .is_some()
744 );
745 }
746
747 #[test]
748 fn declaration_referencing_missing_column_is_skipped() {
749 let info = table_info(
750 &["service_name"],
751 &[
752 ("greptime.semantic.entity.service.id", "service_name"),
753 ("greptime.semantic.entity.host.id", "gone"),
754 ],
755 );
756 let declarations = EntityGraphProviderImpl::declarations_for(&info, conventions().unwrap());
757 assert_eq!(declarations.len(), 1);
758 assert_eq!(declarations[0].entity_type, "service");
759
760 let info = table_info(
761 &["service_name"],
762 &[
763 ("greptime.semantic.entity.service.id", "service_name"),
764 ("greptime.semantic.entity.service.descriptive", "gone"),
765 ],
766 );
767 assert!(
768 EntityGraphProviderImpl::declarations_for(&info, conventions().unwrap()).is_empty()
769 );
770 }
771
772 const PROM_STAMPS: &[(&str, &str)] = &[
773 (SEMANTIC_SIGNAL_TYPE, SIGNAL_TYPE_METRIC),
774 (SEMANTIC_SOURCE, SOURCE_PROMETHEUS),
775 ];
776
777 fn sorted_declarations(info: &TableInfo) -> Vec<EntityDeclaration> {
778 let mut declarations =
779 EntityGraphProviderImpl::declarations_for(info, conventions().unwrap());
780 declarations.sort_by(|a, b| a.entity_type.cmp(&b.entity_type));
781 declarations
782 }
783
784 #[test]
785 fn prometheus_info_metric_gets_implicit_declarations() {
786 let info = prom_table_info(
787 "kube_pod_info",
788 &["namespace", "pod", "uid", "node", "job", "instance"],
789 PROM_STAMPS,
790 );
791 let declarations = sorted_declarations(&info);
792 assert_eq!(declarations.len(), 2);
793 assert_eq!(declarations[0].entity_type, "k8s.node");
794 assert_eq!(declarations[0].id_columns, vec!["node"]);
795 assert_eq!(declarations[1].entity_type, "k8s.pod");
796 assert_eq!(declarations[1].id_columns, vec!["uid"]);
797 assert_eq!(
800 declarations[1].descriptive_columns,
801 vec!["namespace", "pod", "node"]
802 );
803 assert_eq!(declarations[1].time_index, "greptime_timestamp");
804 }
805
806 #[test]
807 fn trace_table_gets_implicit_resource_entities() {
808 let full = table_info(
809 &[
810 "service_name",
811 "resource_attributes.service.instance.id",
812 "resource_attributes.k8s.pod.uid",
813 "resource_attributes.k8s.pod.name",
814 ],
815 &[(TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1)],
816 );
817 let declarations = sorted_declarations(&full);
818 let types: Vec<&str> = declarations
819 .iter()
820 .map(|d| d.entity_type.as_str())
821 .collect();
822 assert_eq!(types, vec!["k8s.pod", "service", "service.instance"]);
823 assert_eq!(
824 declarations[0].id_columns,
825 vec!["resource_attributes.k8s.pod.uid"]
826 );
827 assert_eq!(
828 declarations[0].descriptive_columns,
829 vec!["resource_attributes.k8s.pod.name"]
830 );
831 assert_eq!(
832 declarations[2].id_columns,
833 vec!["service_name", "resource_attributes.service.instance.id"]
834 );
835
836 let no_uid = table_info(
838 &["service_name", "resource_attributes.k8s.pod.name"],
839 &[(TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1)],
840 );
841 let types: Vec<String> = sorted_declarations(&no_uid)
842 .into_iter()
843 .map(|d| d.entity_type)
844 .collect();
845 assert_eq!(types, vec!["service"]);
846
847 let invalid_explicit = table_info(
850 &["service_name"],
851 &[
852 (TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1),
853 ("greptime.semantic.entity.service.id", "gone"),
854 ],
855 );
856 assert!(sorted_declarations(&invalid_explicit).is_empty());
857 }
858
859 #[test]
860 fn prometheus_implicit_declarations_are_gated() {
861 let labels: &[&str] = &["namespace", "pod", "node"];
862 assert!(
864 sorted_declarations(&prom_table_info(
865 "kube_pod_info",
866 labels,
867 &[
868 (SEMANTIC_SIGNAL_TYPE, SIGNAL_TYPE_METRIC),
869 (SEMANTIC_SOURCE, "opentelemetry"),
870 ],
871 ))
872 .is_empty()
873 );
874 assert!(
876 sorted_declarations(&prom_table_info("http_requests_total", labels, PROM_STAMPS))
877 .is_empty()
878 );
879 let mut stamps = PROM_STAMPS.to_vec();
880 stamps.push((PHYSICAL_TABLE_METADATA_KEY, "true"));
881 assert!(sorted_declarations(&prom_table_info("kube_pod_info", labels, &stamps)).is_empty());
882
883 let info = prom_table_info("kube_pod_info", &["namespace", "pod", "node"], PROM_STAMPS);
885 let declarations = sorted_declarations(&info);
886 assert_eq!(declarations.len(), 1);
887 assert_eq!(declarations[0].entity_type, "k8s.node");
888 }
889
890 #[test]
891 fn explicit_declaration_suppresses_the_implicit_one() {
892 let mut stamps = PROM_STAMPS.to_vec();
893 stamps.push(("greptime.semantic.entity.k8s.pod.id", "pod"));
894 let info = prom_table_info("kube_pod_info", &["namespace", "pod", "node"], &stamps);
895 let declarations = sorted_declarations(&info);
896 assert_eq!(declarations.len(), 2);
897 assert_eq!(declarations[0].entity_type, "k8s.node");
898 assert_eq!(declarations[1].entity_type, "k8s.pod");
899 assert_eq!(declarations[1].id_columns, vec!["pod"]);
901 }
902
903 #[test]
904 fn target_info_descriptive_rest_covers_remaining_tags() {
905 let info = prom_table_info(
906 "target_info",
907 &["job", "instance", "k8s_cluster_name", "service_version"],
908 PROM_STAMPS,
909 );
910 let declarations = sorted_declarations(&info);
911 assert_eq!(declarations.len(), 2);
912 assert_eq!(declarations[0].entity_type, "service");
913 assert_eq!(declarations[0].id_columns, vec!["job"]);
914 assert!(declarations[0].descriptive_columns.is_empty());
915 assert_eq!(declarations[1].entity_type, "service.instance");
918 assert_eq!(declarations[1].id_columns, vec!["job", "instance"]);
919 assert_eq!(
920 declarations[1].descriptive_columns,
921 vec!["k8s_cluster_name", "service_version"]
922 );
923 }
924}