Skip to main content

operator/statement/
semantic_graph.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! The entity-relationship graph: the physical declared-edge table DDL and the
16//! typed DataFusion plan builders for the read-time derivation behind the
17//! computed `semantic_entities` / `semantic_relationships` tables.
18//!
19//! In OSS the graph is derived at read time, so the *only* stored part is the
20//! declared-edge table: edges a user asserts by hand (`provenance = 'declared'`).
21//!
22//! The derivation is built as typed [`Expr`]s over [`DataFrame`]s (never as SQL
23//! text), so user-controlled identifiers are plain values — no quoting or SQL
24//! injection surface — and the plans compose with DataFusion's optimizer,
25//! including filter pushdown into the source table scans. See
26//! `docs/rfcs/2026-06-25-entity-relationships-and-graph-query.md`.
27
28mod conventions;
29mod relationships;
30
31use std::sync::{Arc, LazyLock};
32
33use api::v1::column_data_type_extension::TypeExt;
34use api::v1::{
35    ColumnDataType, ColumnDataTypeExtension, ColumnDef, CreateTableExpr, JsonTypeExtension,
36    SemanticType,
37};
38use common_catalog::consts::{
39    CONFIDENCE_COLUMN, DEFAULT_CATALOG_NAME, DEFAULT_PRIVATE_SCHEMA_NAME, DST_ID_COLUMN,
40    DST_TYPE_COLUMN, DURATION_COUNT_COLUMN, DURATION_SUM_COLUMN, EDGE_ATTRIBUTES_COLUMN,
41    ENTITY_DESCRIPTIVE_COLUMN, ENTITY_ID_ATTRS_COLUMN, ENTITY_ID_COLUMN, ENTITY_SCOPE_COLUMN,
42    ENTITY_TYPE_COLUMN, ERROR_COUNT_COLUMN, FRESH_UNTIL_COLUMN, GENERATION_ID_COLUMN,
43    OBSERVED_AT_COLUMN, PROVENANCE_COLUMN, REL_TYPE_COLUMN, REQUEST_COUNT_COLUMN,
44    SEMANTIC_GRAPH_WINDOW_NANOS, SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME, SOURCE_TABLES_COLUMN,
45    SRC_ID_COLUMN, SRC_TYPE_COLUMN, VALID_FROM_COLUMN, VALID_UNTIL_COLUMN, WINDOW_END_COLUMN,
46    WINDOW_START_COLUMN,
47};
48use common_function::function::FunctionContext;
49use common_function::function_registry::FUNCTION_REGISTRY;
50pub use conventions::{
51    Conventions, ENTITY_TYPE_GEN_AI_AGENT, ENTITY_TYPE_GEN_AI_MODEL, ENTITY_TYPE_GEN_AI_TOOL,
52    ENTITY_TYPE_HOST, ENTITY_TYPE_K8S_CONTAINER, ENTITY_TYPE_K8S_NODE, ENTITY_TYPE_K8S_POD,
53    ENTITY_TYPE_K8S_WORKLOAD, ENTITY_TYPE_PROCESS, ENTITY_TYPE_SERVICE,
54    ENTITY_TYPE_SERVICE_INSTANCE, ImplicitEntity, PROVENANCE_AGENT, PROVENANCE_ATTRIBUTE,
55    PROVENANCE_DECLARED, PROVENANCE_TRACE, REL_TYPE_CALLS, REL_TYPE_CONTAINS, REL_TYPE_DEPENDS_ON,
56    REL_TYPE_INVOKES, REL_TYPE_OWNS, REL_TYPE_PART_OF, REL_TYPE_RUNS_ON, REL_TYPE_USES,
57    conventions,
58};
59use datafusion::arrow::datatypes::{DataType, TimeUnit};
60use datafusion::dataframe::DataFrame;
61use datafusion::functions::{core as core_fns, datetime as datetime_fns, string as string_fns};
62use datafusion::functions_nested::expr_fn::make_array;
63use datafusion_common::{Column, Result as DfResult, ScalarValue};
64use datafusion_expr::{Case, Expr, LogicalPlan, ScalarUDF, cast, ident, lit, not};
65pub use relationships::{
66    CallsSource, CoDeclaredSource, DeclaredSource, RelationshipSources, build_relationships_plan,
67};
68use store_api::mito_engine_options::{APPEND_MODE_KEY, MERGE_MODE_KEY, TTL_KEY};
69
70/// Whether an existing declared-edge table still matches the canonical
71/// definition ([`build_declared_relationships_expr`]): columns, time index,
72/// primary key, engine, and merge behaviour — the union branch's revision and
73/// dedup semantics lean on all of these. A mismatch (upgrade skew) must be
74/// surfaced, not silently derived wrong; DROP resets the table.
75pub fn declared_relationships_schema_matches(table_info: &table::metadata::TableInfo) -> bool {
76    let meta = &table_info.meta;
77    if meta.engine != common_catalog::consts::MITO_ENGINE {
78        return false;
79    }
80    let schema = &meta.schema;
81    if schema
82        .timestamp_column()
83        .is_none_or(|column| column.name != OBSERVED_AT_COLUMN)
84    {
85        return false;
86    }
87    let primary_keys = meta
88        .primary_key_indices
89        .iter()
90        .filter_map(|idx| schema.column_schemas().get(*idx))
91        .map(|column| column.name.as_str());
92    if !primary_keys.eq(DECLARED_PRIMARY_KEY_COLUMNS) {
93        return false;
94    }
95    // LastRow merge is what makes a re-asserted edge a *revision*; append mode
96    // or another merge mode would change the read-side dedup semantics.
97    let options = &meta.options.extra_options;
98    if options.get(APPEND_MODE_KEY).map(String::as_str) == Some("true")
99        || options
100            .get(MERGE_MODE_KEY)
101            .is_some_and(|mode| mode != "last_row")
102    {
103        return false;
104    }
105
106    let canonical = build_declared_relationships_expr(DEFAULT_CATALOG_NAME);
107    if schema.column_schemas().len() != canonical.column_defs.len() {
108        return false;
109    }
110    canonical
111        .column_defs
112        .iter()
113        .zip(schema.column_schemas())
114        .all(|(def, column)| {
115            let Ok(wrapper) = api::helper::ColumnDataTypeWrapper::try_new(
116                def.data_type,
117                def.datatype_extension.clone(),
118            ) else {
119                return false;
120            };
121            def.name == column.name
122                && datatypes::prelude::ConcreteDataType::from(wrapper) == column.data_type
123        })
124}
125
126/// Bin width for the temporal window of derived rows, matching the
127/// service-graph convention. Shared so ingestion-synthesized observations
128/// land in the same buckets.
129const BIN_NANOS: i64 = SEMANTIC_GRAPH_WINDOW_NANOS;
130
131/// Default retention for the declared-edge table; expiry slides the topology window.
132const DEFAULT_DECLARED_RELATIONSHIPS_TTL: &str = "90d";
133/// Environment variable overriding the declared-edge table's TTL at creation
134/// time (e.g. `180d`, `forever`).
135// TODO(entity-graph): promote this to a real configuration option.
136const DECLARED_RELATIONSHIPS_TTL_ENV: &str = "GREPTIMEDB_DECLARED_RELATIONSHIPS_TTL";
137
138fn declared_relationships_ttl() -> String {
139    std::env::var(DECLARED_RELATIONSHIPS_TTL_ENV)
140        .ok()
141        .map(|ttl| ttl.trim().to_string())
142        .filter(|ttl| !ttl.is_empty())
143        .unwrap_or_else(|| DEFAULT_DECLARED_RELATIONSHIPS_TTL.to_string())
144}
145
146/// The primary-key (tag) columns, in key order. Starting with the source endpoint
147/// makes out-edge lookup (`WHERE src_type=? AND src_id=?`) a key-prefix scan;
148/// `provenance` and `generation_id` are in the key so a declared edge and a
149/// (future) derived edge for the same pair coexist without clobbering.
150pub const DECLARED_PRIMARY_KEY_COLUMNS: [&str; 8] = [
151    SRC_TYPE_COLUMN,
152    SRC_ID_COLUMN,
153    REL_TYPE_COLUMN,
154    DST_TYPE_COLUMN,
155    DST_ID_COLUMN,
156    PROVENANCE_COLUMN,
157    ENTITY_SCOPE_COLUMN,
158    GENERATION_ID_COLUMN,
159];
160
161/// The externally visible edge identity: the primary key minus `scope` and
162/// `generation_id`, which the computed table does not expose. Revision ranking
163/// uses this identity, or assertions differing only in those two columns would
164/// surface as indistinguishable duplicate rows.
165const DECLARED_EDGE_IDENTITY_COLUMNS: [&str; 6] = [
166    SRC_TYPE_COLUMN,
167    SRC_ID_COLUMN,
168    REL_TYPE_COLUMN,
169    DST_TYPE_COLUMN,
170    DST_ID_COLUMN,
171    PROVENANCE_COLUMN,
172];
173
174fn column(
175    name: &str,
176    data_type: ColumnDataType,
177    semantic_type: SemanticType,
178    nullable: bool,
179) -> ColumnDef {
180    ColumnDef {
181        name: name.to_string(),
182        data_type: data_type as i32,
183        is_nullable: nullable,
184        default_constraint: vec![],
185        semantic_type: semantic_type as i32,
186        comment: String::new(),
187        datatype_extension: None,
188        options: None,
189    }
190}
191
192fn tag(name: &str) -> ColumnDef {
193    column(name, ColumnDataType::String, SemanticType::Tag, false)
194}
195
196fn field(name: &str, data_type: ColumnDataType) -> ColumnDef {
197    column(name, data_type, SemanticType::Field, true)
198}
199
200fn json_field(name: &str) -> ColumnDef {
201    let mut def = field(name, ColumnDataType::Binary);
202    def.datatype_extension = Some(ColumnDataTypeExtension {
203        type_ext: Some(TypeExt::JsonType(JsonTypeExtension::JsonBinary.into())),
204    });
205    def
206}
207
208/// Builds the `CREATE TABLE` request for the declared-edge table. Columns mirror
209/// the computed `semantic_relationships` shape (temporal window + endpoints +
210/// provenance/confidence + RED metrics) plus the declared-only business validity
211/// window (`valid_from` / `valid_until`), which — unlike TTL (physical retention)
212/// — expresses whether a hand-declared edge is still in effect.
213///
214/// The default `LastRow` merge dedups on primary key **plus** `observed_at`
215/// (the time index is part of mito's dedup key): re-asserting an edge at a new
216/// `observed_at` stores a new revision, and the read-side union keeps only the
217/// latest revision per edge key.
218pub fn build_declared_relationships_expr(catalog: &str) -> CreateTableExpr {
219    let column_defs = vec![
220        // Temporal: when this revision of the edge was declared (time index,
221        // also the TTL clock).
222        column(
223            OBSERVED_AT_COLUMN,
224            ColumnDataType::TimestampMillisecond,
225            SemanticType::Timestamp,
226            false,
227        ),
228        field(WINDOW_START_COLUMN, ColumnDataType::TimestampMillisecond),
229        field(WINDOW_END_COLUMN, ColumnDataType::TimestampMillisecond),
230        field(FRESH_UNTIL_COLUMN, ColumnDataType::TimestampMillisecond),
231        // Declared-only business validity. NULL valid_from = valid since the
232        // declaration; NULL valid_until = valid for as long as the row exists
233        // (TTL expiry retires the edge with the row).
234        field(VALID_FROM_COLUMN, ColumnDataType::TimestampMillisecond),
235        field(VALID_UNTIL_COLUMN, ColumnDataType::TimestampMillisecond),
236        // Endpoints + edge identity (all tags, in primary-key order).
237        tag(SRC_TYPE_COLUMN),
238        tag(SRC_ID_COLUMN),
239        tag(REL_TYPE_COLUMN),
240        tag(DST_TYPE_COLUMN),
241        tag(DST_ID_COLUMN),
242        tag(PROVENANCE_COLUMN),
243        tag(ENTITY_SCOPE_COLUMN),
244        tag(GENERATION_ID_COLUMN),
245        // Confidence + RED metrics (populated for derived edges; usually NULL here).
246        field(CONFIDENCE_COLUMN, ColumnDataType::Float64),
247        field(REQUEST_COUNT_COLUMN, ColumnDataType::Int64),
248        field(ERROR_COUNT_COLUMN, ColumnDataType::Int64),
249        field(DURATION_SUM_COLUMN, ColumnDataType::Float64),
250        field(DURATION_COUNT_COLUMN, ColumnDataType::Int64),
251        // JSONB, so the union matches the computed table's json column
252        // without a per-scan parse.
253        json_field(EDGE_ATTRIBUTES_COLUMN),
254    ];
255
256    let table_options = [(TTL_KEY.to_string(), declared_relationships_ttl())]
257        .into_iter()
258        .collect();
259
260    CreateTableExpr {
261        catalog_name: catalog.to_string(),
262        schema_name: DEFAULT_PRIVATE_SCHEMA_NAME.to_string(),
263        table_name: SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME.to_string(),
264        desc: "Hand-declared edges of the entity-relationship graph".to_string(),
265        column_defs,
266        time_index: OBSERVED_AT_COLUMN.to_string(),
267        primary_keys: DECLARED_PRIMARY_KEY_COLUMNS
268            .iter()
269            .map(|c| c.to_string())
270            .collect(),
271        create_if_not_exists: true,
272        table_options,
273        table_id: None,
274        engine: common_catalog::consts::MITO_ENGINE.to_string(),
275    }
276}
277
278/// A single table's entity-identity declaration, projected from
279/// `information_schema.table_semantics` (`greptime.semantic.entity.<type>.*`).
280#[derive(Debug, Clone)]
281pub struct EntityDeclaration {
282    /// The declaring table's schema, for the qualified `source_tables` lineage.
283    pub schema: String,
284    /// The declaring table's name, recorded in `source_tables`.
285    pub table: String,
286    /// The table's time index column, used for the temporal window filter.
287    pub time_index: String,
288    pub entity_type: String,
289    /// Identifying columns (>= 1), ordered broad to narrow.
290    pub id_columns: Vec<String>,
291    /// Optional column qualifying the first identity component, so a source
292    /// carrying the parts separately (a trace table's `service.namespace`)
293    /// yields the same id as one carrying them pre-composed (`job`).
294    pub id_qualifier: Option<String>,
295    /// Identity columns of a more specific entity type that takes over on the
296    /// rows carrying all of them (a pod's container is the `k8s.container`, not
297    /// a generic `container`). Empty for explicit declarations: a user who
298    /// declares a type means it unconditionally.
299    pub superseded_by_columns: Vec<String>,
300    /// Descriptive columns snapshotted into the `descriptive` JSON (may be empty).
301    pub descriptive_columns: Vec<String>,
302    /// Scope columns (namespace/environment). One column → scope verbatim;
303    /// several → sorted `k=v,k=v`, mirroring the composite id rendering.
304    pub scope_columns: Vec<String>,
305}
306
307/// The read-time query window, resolved from the scan's `observed_at` predicate
308/// (or the product default). Two half-open `[start, end)` millisecond ranges:
309///
310/// - *observed*: the queried `observed_at` range. The scan's filters are
311///   re-applied above the computed table (`FilterPushDownType::Inexact`), so
312///   every emitted row's `observed_at` must fall inside it. The declared-edge
313///   branch also runs its validity overlap against this range.
314/// - *source scan*: `observed` widened to whole 60s buckets. Derived rows are
315///   `date_bin('60s')` aggregates keyed by the bucket start, so a bucket the
316///   observed range selects must be scanned over its full extent — filtering
317///   the source rows to the observed range instead would silently truncate the
318///   RED numbers of the boundary buckets.
319///
320/// The bounds are plain millisecond values turned into timestamp literals on
321/// demand — wall-clock snapshot semantics like `now()`, but as constants they
322/// prune the source table scans without depending on constant-folding.
323#[derive(Debug, Clone)]
324pub struct GraphQueryWindow {
325    observed_start_ms: i64,
326    observed_end_ms: i64,
327    source_start_ms: i64,
328    source_end_ms: i64,
329}
330
331impl GraphQueryWindow {
332    /// Builds the window for the queried `observed_at` range `[start_ms, end_ms)`.
333    pub fn from_observed(start_ms: i64, end_ms: i64) -> Self {
334        const BIN_MS: i64 = BIN_NANOS / 1_000_000;
335        // Ceiling division that stays correct for pre-epoch (negative) values.
336        let ceil_to_bin = |ms: i64| {
337            ms.div_euclid(BIN_MS) * BIN_MS
338                + if ms.rem_euclid(BIN_MS) == 0 {
339                    0
340                } else {
341                    BIN_MS
342                }
343        };
344        // A bucket `b` (a 60s multiple, `date_bin`'s floor of its rows) is
345        // selected when `start <= b < end`: the first is ceil(start) and the
346        // last one's rows extend to ceil(end).
347        Self {
348            observed_start_ms: start_ms,
349            observed_end_ms: end_ms,
350            source_start_ms: ceil_to_bin(start_ms),
351            source_end_ms: ceil_to_bin(end_ms),
352        }
353    }
354
355    /// Conservative default when a query carries no explicit time predicate: the
356    /// last hour, so a bare `SELECT * FROM semantic_entities` never scans every
357    /// declaring table's full history. This is a product default, not a cap.
358    pub fn default_last_hour() -> Self {
359        let end_ms = common_time::util::current_time_millis();
360        Self::from_observed(end_ms - 60 * 60 * 1000, end_ms)
361    }
362
363    /// Start of the queried `observed_at` range (inclusive).
364    pub fn observed_start(&self) -> Expr {
365        ts_ms_lit(self.observed_start_ms)
366    }
367
368    /// End of the queried `observed_at` range (exclusive).
369    pub fn observed_end(&self) -> Expr {
370        ts_ms_lit(self.observed_end_ms)
371    }
372
373    /// Start of the source-table scan range (inclusive).
374    pub fn source_start(&self) -> Expr {
375        ts_ms_lit(self.source_start_ms)
376    }
377
378    /// End of the source-table scan range (exclusive).
379    pub fn source_end(&self) -> Expr {
380        ts_ms_lit(self.source_end_ms)
381    }
382}
383
384fn ts_ms_lit(ms: i64) -> Expr {
385    lit(ScalarValue::TimestampMillisecond(Some(ms), None))
386}
387
388/// An `INTERVAL` literal of `nanos` nanoseconds.
389fn interval(nanos: i64) -> Expr {
390    lit(ScalarValue::new_interval_mdn(0, 0, nanos))
391}
392
393fn bin_interval() -> Expr {
394    interval(BIN_NANOS)
395}
396
397/// `date_bin(60s, ts)` cast to millisecond precision, so the output schema is
398/// deterministic regardless of the source column's precision (trace tables are
399/// nanosecond, metric tables millisecond).
400fn bin_ms(ts: Expr) -> Expr {
401    cast(
402        datetime_fns::date_bin().call(vec![bin_interval(), ts]),
403        DataType::Timestamp(TimeUnit::Millisecond, None),
404    )
405}
406
407/// Folds union branches without requiring a non-empty input.
408fn union_all(acc: Option<DataFrame>, branch: DataFrame) -> DfResult<Option<DataFrame>> {
409    Ok(Some(match acc {
410        Some(acc) => acc.union(branch)?,
411        None => branch,
412    }))
413}
414
415/// A column reference qualified by a join-side alias, built without string
416/// parsing (so column names containing `.` or `"` stay verbatim).
417fn qcol(relation: &str, name: &str) -> Expr {
418    Expr::Column(Column::new(Some(relation), name))
419}
420
421fn concat_expr(parts: Vec<Expr>) -> Expr {
422    string_fns::concat().call(parts)
423}
424
425/// `coalesce(CAST(column AS STRING), '')`: renders a nullable column for string
426/// concatenation without collapsing the result to NULL.
427fn cast_string_or_empty(column: &str) -> Expr {
428    core_fns::coalesce().call(vec![cast(ident(column), DataType::Utf8), lit("")])
429}
430
431/// A row identifies an entity only when every identity component is present
432/// and non-empty: kube-state-metrics descriptors emit empty-string labels (an
433/// unscheduled pod's `node`, an owner-less pod's `owner_*`), and an empty
434/// string is never a meaningful entity id. The qualifier is optional by
435/// construction and so is not guarded.
436fn identifies(column: &str) -> Expr {
437    ident(column)
438        .is_not_null()
439        .and(cast(ident(column), DataType::Utf8).not_eq(lit("")))
440}
441
442/// The row-level guard a declaration carries: every identity component present,
443/// and the superseding type's identity not complete. Every branch that turns a
444/// declaration into rows applies this, so the guard cannot drift between them.
445pub(crate) fn declaration_predicate(declaration: &EntityDeclaration) -> Expr {
446    let mut predicate = lit(true);
447    for column in &declaration.id_columns {
448        predicate = predicate.and(identifies(column));
449    }
450    if let Some(superseding) = declaration
451        .superseded_by_columns
452        .iter()
453        .map(|column| identifies(column))
454        .reduce(Expr::and)
455    {
456        predicate = predicate.and(not(superseding));
457    }
458    predicate
459}
460
461const ID_SEPARATOR: &str = ",";
462const ID_ESCAPE: &str = "\\";
463/// Left unescaped: `<namespace>/<name>` is how Prometheus renders `job`.
464const ID_QUALIFIER_SEPARATOR: &str = "/";
465
466/// Escapes so a composite id decodes back to its components. The escape
467/// character goes first, or it would double the escapes the separator pass
468/// introduces.
469fn escaped_id_value(value: Expr) -> Expr {
470    let escape_escape = string_fns::replace().call(vec![
471        value,
472        lit(ID_ESCAPE),
473        lit(format!("{ID_ESCAPE}{ID_ESCAPE}")),
474    ]);
475    string_fns::replace().call(vec![
476        escape_escape,
477        lit(ID_SEPARATOR),
478        lit(format!("{ID_ESCAPE}{ID_SEPARATOR}")),
479    ])
480}
481
482/// The identity values in declared order (broad to narrow), escaped and
483/// joined.
484///
485/// The identifying *column names* are deliberately absent: the same identity
486/// reaches us under different names per source — a trace table's
487/// `service_name` against a metric table's `job` — and encoding them would
488/// split one entity into one per signal. `entity_id_attrs` carries the names
489/// beside the id, where they document its origin without dividing it.
490///
491/// `col` constructs the column reference (unqualified for registry branches,
492/// join-side-qualified for the calls derivation).
493fn entity_id_expr(
494    id_columns: &[String],
495    qualifier: Option<&str>,
496    col: &dyn Fn(&str) -> Expr,
497) -> Expr {
498    let mut parts = Vec::with_capacity(id_columns.len() * 2);
499    for (index, column) in id_columns.iter().enumerate() {
500        if index > 0 {
501            parts.push(lit(ID_SEPARATOR));
502        }
503        let value = escaped_id_value(cast(col(column), DataType::Utf8));
504        parts.push(match qualifier {
505            Some(qualifier) if index == 0 => qualified_id_expr(qualifier, value, col),
506            _ => value,
507        });
508    }
509    if let [single] = parts.as_slice() {
510        single.clone()
511    } else {
512        concat_expr(parts)
513    }
514}
515
516/// `<qualifier>/<value>`, or bare `value` when the qualifier is empty on this
517/// row — the spec's rule composing `job` from `service.namespace` and
518/// `service.name`.
519fn qualified_id_expr(qualifier: &str, value: Expr, col: &dyn Fn(&str) -> Expr) -> Expr {
520    let qualifier = escaped_id_value(
521        core_fns::coalesce().call(vec![cast(col(qualifier), DataType::Utf8), lit("")]),
522    );
523    Expr::Case(Case::new(
524        None,
525        vec![(
526            Box::new(qualifier.clone().eq(lit(""))),
527            Box::new(value.clone()),
528        )],
529        Some(Box::new(concat_expr(vec![
530            qualifier,
531            lit(ID_QUALIFIER_SEPARATOR),
532            value,
533        ]))),
534    ))
535}
536
537/// The `parse_json` UDF, shared by all derivation plans. Resolved from the
538/// global registry once: the UDF is stateless (its `FunctionContext` is unused).
539static PARSE_JSON_UDF: LazyLock<Arc<ScalarUDF>> = LazyLock::new(|| {
540    Arc::new(
541        FUNCTION_REGISTRY
542            .get_function("parse_json")
543            .expect("parse_json must be registered")
544            .provide(FunctionContext::default()),
545    )
546});
547
548/// Parses a JSON text expression into a JSONB value, cast from the UDF's
549/// `BinaryView` output to `Binary` — the storage type the computed tables'
550/// declared `json` columns map to in Arrow.
551fn parse_json_expr(json_text: Expr) -> Expr {
552    cast(PARSE_JSON_UDF.call(vec![json_text]), DataType::Binary)
553}
554
555/// A NULL literal typed as JSONB storage (`Binary`), so branches without a JSON
556/// value union-align with branches that produce one.
557fn null_json() -> Expr {
558    lit(ScalarValue::Binary(None))
559}
560
561/// Renders a compile-time-known string as JSON text (quoted, fully escaped).
562fn json_quote(value: &str) -> String {
563    serde_json::Value::from(value).to_string()
564}
565
566/// The `json_object` UDF, resolved like [`PARSE_JSON_UDF`]. It assembles the
567/// JSONB binary directly from the value columns, so runtime values need no
568/// JSON text escaping.
569static JSON_OBJECT_UDF: LazyLock<Arc<ScalarUDF>> = LazyLock::new(|| {
570    Arc::new(
571        FUNCTION_REGISTRY
572            .get_function("json_object")
573            .expect("json_object must be registered")
574            .provide(FunctionContext::default()),
575    )
576});
577
578/// Builds a JSONB object with one entry per column: key = the column name,
579/// value = the column rendered as a string, NULL coalesced to `""` so one NULL
580/// column does not null the entry (descriptive columns are nullable). Keys come
581/// out sorted — JSONB objects are key-ordered regardless of input order.
582fn json_object_expr(columns: &[String]) -> Expr {
583    let mut args = Vec::with_capacity(columns.len() * 2);
584    for column in columns {
585        args.push(lit(column.as_str()));
586        args.push(cast_string_or_empty(column));
587    }
588    cast(JSON_OBJECT_UDF.call(args), DataType::Binary)
589}
590
591/// Renders pre-sorted columns as a `k=v,k=v` concatenation. `nullable`
592/// coalesces each value to `''` (id columns are tags and non-null; scope
593/// columns carry no such guarantee).
594fn sorted_kv_expr_with(sorted_cols: &[String], nullable: bool, col: &dyn Fn(&str) -> Expr) -> Expr {
595    let mut parts = Vec::with_capacity(sorted_cols.len() * 3);
596    for (i, column) in sorted_cols.iter().enumerate() {
597        if i > 0 {
598            parts.push(lit(","));
599        }
600        parts.push(lit(format!("{column}=")));
601        parts.push(if nullable {
602            core_fns::coalesce().call(vec![cast(col(column), DataType::Utf8), lit("")])
603        } else {
604            cast(col(column), DataType::Utf8)
605        });
606    }
607    concat_expr(parts)
608}
609
610const REGISTRY_COLUMNS: [&str; 10] = [
611    OBSERVED_AT_COLUMN,
612    WINDOW_START_COLUMN,
613    WINDOW_END_COLUMN,
614    FRESH_UNTIL_COLUMN,
615    ENTITY_TYPE_COLUMN,
616    ENTITY_ID_COLUMN,
617    ENTITY_ID_ATTRS_COLUMN,
618    ENTITY_SCOPE_COLUMN,
619    ENTITY_DESCRIPTIVE_COLUMN,
620    SOURCE_TABLES_COLUMN,
621];
622
623const REGISTRY_VALID_COLUMN: &str = "__entity_valid";
624
625/// Expands one source row into one output row per entry of `rows` with a
626/// single source scan: each output field is first built as an array whose
627/// entries correspond to the rows, then unnested. Every row is `[valid,
628/// values...]` aligned with `columns`; rows whose `valid` expression is false
629/// are dropped, and the distinct output columns are `columns`.
630fn unnest_rows(
631    df: DataFrame,
632    window_predicate: Expr,
633    valid_column: &'static str,
634    columns: &[&'static str],
635    rows: Vec<Vec<Expr>>,
636) -> DfResult<DataFrame> {
637    let mut arrays = vec![Vec::with_capacity(rows.len()); columns.len() + 1];
638    for row in rows {
639        debug_assert_eq!(row.len(), arrays.len());
640        for (array, value) in arrays.iter_mut().zip(row) {
641            array.push(value);
642        }
643    }
644    let array_names = std::iter::once(valid_column)
645        .chain(columns.iter().copied())
646        .collect::<Vec<_>>();
647    let array_projection = array_names
648        .iter()
649        .zip(arrays)
650        .map(|(name, values)| make_array(values).alias(*name))
651        .collect::<Vec<_>>();
652
653    df.filter(window_predicate)?
654        .select(array_projection)?
655        .unnest_columns(&array_names)?
656        .filter(ident(valid_column))?
657        .select(columns.iter().map(|c| ident(*c)).collect::<Vec<_>>())?
658        .distinct()
659}
660
661/// Projects all entity declarations of one source table with one source scan
662/// via [`unnest_rows`].
663fn registry_source(
664    first: &EntityDeclaration,
665    rest: &[EntityDeclaration],
666    df: DataFrame,
667    window: &GraphQueryWindow,
668) -> DfResult<DataFrame> {
669    let ts = ident(&first.time_index);
670    let bin = bin_ms(ts.clone());
671    let window_predicate = ts
672        .clone()
673        .gt_eq(window.source_start())
674        .and(ts.lt(window.source_end()));
675
676    let mut rows = Vec::with_capacity(1 + rest.len());
677    for decl in std::iter::once(first).chain(rest) {
678        // CAST even a single-column id: id columns need not be strings, and
679        // the computed table declares entity_id STRING.
680        let entity_id = entity_id_expr(&decl.id_columns, decl.id_qualifier.as_deref(), &|c| {
681            ident(c)
682        });
683        let id_parts = decl
684            .id_qualifier
685            .iter()
686            .chain(&decl.id_columns)
687            .cloned()
688            .collect::<Vec<_>>();
689        // Carried for single-column ids too. Entity equality reads entity_id
690        // alone, so this is not part of the identity.
691        let entity_id_attrs = json_object_expr(&id_parts);
692
693        let scope = match decl.scope_columns.as_slice() {
694            [] => lit(""),
695            // Scope columns are not required to be tags, so guard against NULL.
696            [single] => cast_string_or_empty(single),
697            _ => {
698                let mut cols = decl.scope_columns.clone();
699                cols.sort();
700                sorted_kv_expr_with(&cols, true, &|c| ident(c))
701            }
702        };
703
704        let descriptive = if decl.descriptive_columns.is_empty() {
705            null_json()
706        } else {
707            json_object_expr(&decl.descriptive_columns)
708        };
709
710        let source_tables = parse_json_expr(lit(format!(
711            "[{}]",
712            json_quote(&format!("{}.{}", decl.schema, decl.table))
713        )));
714
715        // Keep this predicate per declaration so an absent identity for one
716        // entity does not remove other entities on the row.
717        let valid = declaration_predicate(decl);
718
719        rows.push(vec![
720            valid,
721            bin.clone(),
722            bin.clone(),
723            bin.clone() + bin_interval(),
724            bin.clone() + bin_interval(),
725            lit(decl.entity_type.as_str()),
726            entity_id,
727            entity_id_attrs,
728            scope,
729            descriptive,
730            source_tables,
731        ]);
732    }
733
734    unnest_rows(
735        df,
736        window_predicate,
737        REGISTRY_VALID_COLUMN,
738        &REGISTRY_COLUMNS,
739        rows,
740    )
741}
742
743/// A declaring table's scan paired with the entity declarations it carries —
744/// the unit `build_registry_plan` derives registry rows from.
745pub struct RegistrySource {
746    pub declarations: Vec<EntityDeclaration>,
747    pub scan: DataFrame,
748}
749
750/// Builds the `semantic_entities` registry plan: one branch and source scan per
751/// declaring table, filtered to `window`, then `UNION ALL` across source tables.
752/// Returns `None` when nothing declared an entity, so the computed table streams
753/// empty.
754pub fn build_registry_plan(
755    sources: Vec<RegistrySource>,
756    window: &GraphQueryWindow,
757) -> DfResult<Option<LogicalPlan>> {
758    let mut union_df: Option<DataFrame> = None;
759    for source in sources {
760        let Some((first, rest)) = source.declarations.split_first() else {
761            continue;
762        };
763        union_df = union_all(union_df, registry_source(first, rest, source.scan, window)?)?;
764    }
765    Ok(union_df.map(DataFrame::into_unoptimized_plan))
766}
767
768/// RecordBatch readers shared by this module's tests and the `relationships`
769/// tests.
770#[cfg(test)]
771pub(crate) mod test_util {
772    use datafusion::arrow::array::{Array, BinaryArray, StringArray, TimestampMillisecondArray};
773    use datafusion::arrow::record_batch::RecordBatch;
774    use datafusion::prelude::SessionContext;
775    use datafusion_expr::LogicalPlan;
776
777    pub(crate) async fn collect(ctx: &SessionContext, plan: LogicalPlan) -> Vec<RecordBatch> {
778        ctx.execute_logical_plan(plan)
779            .await
780            .unwrap()
781            .collect()
782            .await
783            .unwrap()
784    }
785
786    pub(crate) fn json_texts(batch: &RecordBatch, column: usize) -> Vec<Option<String>> {
787        let array = batch
788            .column(column)
789            .as_any()
790            .downcast_ref::<BinaryArray>()
791            .unwrap();
792        (0..array.len())
793            .map(|i| {
794                array
795                    .is_valid(i)
796                    .then(|| jsonb::from_slice(array.value(i)).unwrap().to_string())
797            })
798            .collect()
799    }
800
801    pub(crate) fn strings(batch: &RecordBatch, column: usize) -> Vec<String> {
802        let array = batch
803            .column(column)
804            .as_any()
805            .downcast_ref::<StringArray>()
806            .unwrap();
807        (0..array.len())
808            .map(|i| array.value(i).to_string())
809            .collect()
810    }
811
812    pub(crate) fn ts_values(batch: &RecordBatch, column: usize) -> Vec<i64> {
813        let array = batch
814            .column(column)
815            .as_any()
816            .downcast_ref::<TimestampMillisecondArray>()
817            .unwrap();
818        (0..array.len()).map(|i| array.value(i)).collect()
819    }
820}
821
822#[cfg(test)]
823mod tests {
824    use std::sync::Arc;
825
826    use datafusion::arrow::array::{ArrayRef, Int64Array, StringArray, TimestampMillisecondArray};
827    use datafusion::arrow::datatypes::{Field, Schema};
828    use datafusion::arrow::record_batch::RecordBatch;
829    use datafusion::datasource::MemTable;
830    use datafusion::prelude::SessionContext;
831
832    use super::test_util::{collect, json_texts, strings};
833    use super::*;
834
835    fn test_window() -> GraphQueryWindow {
836        GraphQueryWindow::from_observed(0, 10 * 60 * 1000)
837    }
838
839    #[test]
840    fn window_source_scan_covers_whole_buckets() {
841        // Buckets selected by [10:30.5', 11:30') is exactly {11:00'}; its source
842        // rows span [11:00', 12:00').
843        let window = GraphQueryWindow::from_observed(630_000, 690_000);
844        assert_eq!(
845            (window.source_start_ms, window.source_end_ms),
846            (660_000, 720_000)
847        );
848
849        // Aligned bounds stay put.
850        let window = GraphQueryWindow::from_observed(600_000, 720_000);
851        assert_eq!(
852            (window.source_start_ms, window.source_end_ms),
853            (600_000, 720_000)
854        );
855
856        // A sub-bucket range that selects no bucket start scans nothing.
857        let window = GraphQueryWindow::from_observed(610_000, 650_000);
858        assert!(window.source_start_ms >= window.source_end_ms);
859
860        // Pre-epoch bounds round toward the correct buckets.
861        let window = GraphQueryWindow::from_observed(-90_000, -30_000);
862        assert_eq!((window.source_start_ms, window.source_end_ms), (-60_000, 0));
863    }
864
865    /// A metric-like table: ms timestamps, service/pid identity, nullable
866    /// descriptive column with JSON-hostile characters.
867    fn metric_table_ctx() -> SessionContext {
868        let schema = Arc::new(Schema::new(vec![
869            Field::new(
870                "ts",
871                DataType::Timestamp(TimeUnit::Millisecond, None),
872                false,
873            ),
874            Field::new("service_name", DataType::Utf8, false),
875            Field::new("pid", DataType::Int64, false),
876            Field::new("host", DataType::Utf8, true),
877        ]));
878        let batch = RecordBatch::try_new(
879            schema.clone(),
880            vec![
881                Arc::new(TimestampMillisecondArray::from(vec![1_000, 2_000, 61_000])) as ArrayRef,
882                Arc::new(StringArray::from(vec!["cart", "cart", "cart"])),
883                Arc::new(Int64Array::from(vec![42, 42, 42])),
884                Arc::new(StringArray::from(vec![
885                    Some("we\"ird\\\nhost"),
886                    None,
887                    Some("h2"),
888                ])),
889            ],
890        )
891        .unwrap();
892        let ctx = SessionContext::new();
893        ctx.register_table(
894            "app_latency",
895            Arc::new(MemTable::try_new(schema, vec![vec![batch]]).unwrap()),
896        )
897        .unwrap();
898        ctx
899    }
900
901    fn decl(entity_type: &str, id_columns: &[&str]) -> EntityDeclaration {
902        EntityDeclaration {
903            schema: "public".to_string(),
904            table: "app_latency".to_string(),
905            time_index: "ts".to_string(),
906            entity_type: entity_type.to_string(),
907            id_columns: id_columns.iter().map(|s| s.to_string()).collect(),
908            id_qualifier: None,
909            superseded_by_columns: vec![],
910            descriptive_columns: vec![],
911            scope_columns: vec![],
912        }
913    }
914
915    /// The id string must decode back to its components, and a qualifier must
916    /// reproduce the identity a source that pre-composed it already emits.
917    #[tokio::test]
918    async fn registry_id_escaping_and_qualifier() {
919        let schema = Arc::new(Schema::new(vec![
920            Field::new(
921                "ts",
922                DataType::Timestamp(TimeUnit::Millisecond, None),
923                false,
924            ),
925            Field::new("service_name", DataType::Utf8, false),
926            Field::new("instance", DataType::Utf8, false),
927            Field::new("namespace", DataType::Utf8, true),
928        ]));
929        let batch = RecordBatch::try_new(
930            schema.clone(),
931            vec![
932                Arc::new(TimestampMillisecondArray::from(vec![1_000, 2_000, 3_000])) as ArrayRef,
933                Arc::new(StringArray::from(vec!["cart", "a,b", "we\\ird"])),
934                Arc::new(StringArray::from(vec!["i-1", "c", "i-3"])),
935                Arc::new(StringArray::from(vec![Some("shop"), None, Some("")])),
936            ],
937        )
938        .unwrap();
939        let ctx = SessionContext::new();
940        ctx.register_table(
941            "svc",
942            Arc::new(MemTable::try_new(schema, vec![vec![batch]]).unwrap()),
943        )
944        .unwrap();
945
946        let mut declaration = decl("service.instance", &["service_name", "instance"]);
947        declaration.id_qualifier = Some("namespace".to_string());
948        declaration.time_index = "ts".to_string();
949        let plan = build_registry_plan(
950            vec![RegistrySource {
951                declarations: vec![declaration],
952                scan: ctx.table("svc").await.unwrap(),
953            }],
954            &test_window(),
955        )
956        .unwrap()
957        .unwrap();
958
959        let mut ids: Vec<String> = collect(&ctx, plan)
960            .await
961            .iter()
962            .flat_map(|batch| strings(batch, 5))
963            .collect();
964        ids.sort();
965        assert_eq!(
966            ids,
967            vec![
968                // an absent qualifier leaves the identity bare, and a value
969                // holding the separator stays distinguishable from two values
970                "a\\,b,c".to_string(),
971                "shop/cart,i-1".to_string(),
972                "we\\\\ird,i-3".to_string(),
973            ]
974        );
975    }
976
977    #[tokio::test]
978    async fn registry_single_column_identity() {
979        let ctx = metric_table_ctx();
980        let df = ctx.table("app_latency").await.unwrap();
981        let plan = build_registry_plan(
982            vec![RegistrySource {
983                declarations: vec![decl("service", &["service_name"])],
984                scan: df,
985            }],
986            &test_window(),
987        )
988        .unwrap()
989        .unwrap();
990
991        let names = plan
992            .schema()
993            .fields()
994            .iter()
995            .map(|f| f.name().as_str())
996            .collect::<Vec<_>>();
997        assert_eq!(
998            names,
999            [
1000                "observed_at",
1001                "window_start",
1002                "window_end",
1003                "fresh_until",
1004                "entity_type",
1005                "entity_id",
1006                "entity_id_attrs",
1007                "scope",
1008                "descriptive",
1009                "source_tables",
1010            ]
1011        );
1012
1013        let batches = collect(&ctx, plan).await;
1014        let total: usize = batches.iter().map(|b| b.num_rows()).sum();
1015        // 3 rows in 2 distinct 60s bins, all the same entity -> 2 rows.
1016        assert_eq!(total, 2);
1017        let batch = &batches[0];
1018        assert_eq!(strings(batch, 4), vec!["service"; batch.num_rows()]);
1019        assert_eq!(strings(batch, 5), vec!["cart"; batch.num_rows()]);
1020        // A single-column id still names its attribute.
1021        assert_eq!(
1022            json_texts(batch, 6),
1023            vec![Some(r#"{"service_name":"cart"}"#.to_string()); batch.num_rows()]
1024        );
1025        assert!(json_texts(batch, 8).iter().all(Option::is_none));
1026        assert_eq!(
1027            json_texts(batch, 9),
1028            vec![Some(r#"["public.app_latency"]"#.to_string()); batch.num_rows()]
1029        );
1030    }
1031
1032    #[tokio::test]
1033    async fn registry_composite_identity_and_descriptive_escaping() {
1034        let ctx = metric_table_ctx();
1035        let df = ctx.table("app_latency").await.unwrap();
1036        let mut declaration = decl("process", &["service_name", "pid"]);
1037        declaration.descriptive_columns = vec!["host".to_string()];
1038        let plan = build_registry_plan(
1039            vec![RegistrySource {
1040                declarations: vec![declaration],
1041                scan: df,
1042            }],
1043            &test_window(),
1044        )
1045        .unwrap()
1046        .unwrap();
1047
1048        let batches = collect(&ctx, plan).await;
1049        let mut rows: Vec<(String, Option<String>, Option<String>)> = batches
1050            .iter()
1051            .flat_map(|batch| {
1052                let ids = strings(batch, 5);
1053                let id_attrs = json_texts(batch, 6);
1054                let descriptives = json_texts(batch, 8);
1055                ids.into_iter()
1056                    .zip(id_attrs)
1057                    .zip(descriptives)
1058                    .map(|((id, attrs), descriptive)| (id, attrs, descriptive))
1059                    .collect::<Vec<_>>()
1060            })
1061            .collect();
1062        rows.sort();
1063
1064        // Composite id -> values in declared order plus a JSON object of the
1065        // id columns; descriptive JSON keeps `\`, `"` and control characters
1066        // intact in runtime values, NULL -> "".
1067        assert_eq!(
1068            rows,
1069            vec![
1070                (
1071                    "cart,42".to_string(),
1072                    Some(r#"{"pid":"42","service_name":"cart"}"#.to_string()),
1073                    Some(r#"{"host":""}"#.to_string()),
1074                ),
1075                (
1076                    "cart,42".to_string(),
1077                    Some(r#"{"pid":"42","service_name":"cart"}"#.to_string()),
1078                    Some(r#"{"host":"h2"}"#.to_string()),
1079                ),
1080                (
1081                    "cart,42".to_string(),
1082                    Some(r#"{"pid":"42","service_name":"cart"}"#.to_string()),
1083                    Some(r#"{"host":"we\"ird\\\nhost"}"#.to_string()),
1084                ),
1085            ]
1086        );
1087    }
1088
1089    #[tokio::test]
1090    async fn registry_scope_variants() {
1091        let ctx = metric_table_ctx();
1092
1093        // Single scope column: its (NULL-safe) value verbatim.
1094        let mut single = decl("service", &["service_name"]);
1095        single.scope_columns = vec!["host".to_string()];
1096        let df = ctx.table("app_latency").await.unwrap();
1097        let plan = build_registry_plan(
1098            vec![RegistrySource {
1099                declarations: vec![single],
1100                scan: df,
1101            }],
1102            &test_window(),
1103        )
1104        .unwrap()
1105        .unwrap();
1106        let batches = collect(&ctx, plan).await;
1107        let mut scopes: Vec<String> = batches.iter().flat_map(|b| strings(b, 7)).collect();
1108        scopes.sort();
1109        assert_eq!(scopes, vec!["", "h2", "we\"ird\\\nhost"]);
1110
1111        // Multiple scope columns: sorted `k=v,k=v`.
1112        let mut multi = decl("service", &["service_name"]);
1113        multi.scope_columns = vec!["pid".to_string(), "host".to_string()];
1114        let df = ctx.table("app_latency").await.unwrap();
1115        let plan = build_registry_plan(
1116            vec![RegistrySource {
1117                declarations: vec![multi],
1118                scan: df,
1119            }],
1120            &test_window(),
1121        )
1122        .unwrap()
1123        .unwrap();
1124        let batches = collect(&ctx, plan).await;
1125        let mut scopes: Vec<String> = batches.iter().flat_map(|b| strings(b, 7)).collect();
1126        scopes.sort();
1127        assert_eq!(
1128            scopes,
1129            vec![
1130                "host=,pid=42",
1131                "host=h2,pid=42",
1132                "host=we\"ird\\\nhost,pid=42"
1133            ]
1134        );
1135    }
1136
1137    #[tokio::test]
1138    async fn registry_skips_absent_identity_rows() {
1139        // NULL identifies nothing, and so does a kube-state-metrics-style
1140        // empty label (an unscheduled pod's `node` arrives as "").
1141        let schema = Arc::new(Schema::new(vec![
1142            Field::new(
1143                "ts",
1144                DataType::Timestamp(TimeUnit::Millisecond, None),
1145                false,
1146            ),
1147            Field::new("host", DataType::Utf8, true),
1148        ]));
1149        let batch = RecordBatch::try_new(
1150            schema.clone(),
1151            vec![
1152                Arc::new(TimestampMillisecondArray::from(vec![1_000, 2_000, 3_000])) as ArrayRef,
1153                Arc::new(StringArray::from(vec![Some("h2"), None, Some("")])),
1154            ],
1155        )
1156        .unwrap();
1157        let ctx = SessionContext::new();
1158        ctx.register_table(
1159            "app_latency",
1160            Arc::new(MemTable::try_new(schema, vec![vec![batch]]).unwrap()),
1161        )
1162        .unwrap();
1163        let df = ctx.table("app_latency").await.unwrap();
1164        let plan = build_registry_plan(
1165            vec![RegistrySource {
1166                declarations: vec![decl("host", &["host"])],
1167                scan: df,
1168            }],
1169            &test_window(),
1170        )
1171        .unwrap()
1172        .unwrap();
1173        let batches = collect(&ctx, plan).await;
1174        let ids: Vec<String> = batches.iter().flat_map(|b| strings(b, 5)).collect();
1175        assert_eq!(ids, vec!["h2"]);
1176    }
1177
1178    #[tokio::test]
1179    async fn registry_superseding_is_per_row_and_never_drops_an_entity() {
1180        // One table holds pod rows and bare-runtime rows, so the rule is per
1181        // row: each must end up with exactly one container node, the specific
1182        // type where its identity is complete and the generic one elsewhere.
1183        let schema = Arc::new(Schema::new(vec![
1184            Field::new(
1185                "ts",
1186                DataType::Timestamp(TimeUnit::Millisecond, None),
1187                false,
1188            ),
1189            Field::new("container_id", DataType::Utf8, false),
1190            Field::new("pod_uid", DataType::Utf8, true),
1191            Field::new("container_name", DataType::Utf8, true),
1192        ]));
1193        let batch = RecordBatch::try_new(
1194            schema.clone(),
1195            vec![
1196                Arc::new(TimestampMillisecondArray::from(vec![
1197                    1_000, 2_000, 3_000, 4_000,
1198                ])) as ArrayRef,
1199                Arc::new(StringArray::from(vec![
1200                    "c-pod",
1201                    "c-docker",
1202                    "c-empty",
1203                    "c-partial",
1204                ])),
1205                Arc::new(StringArray::from(vec![
1206                    Some("uid-1"),
1207                    None,
1208                    Some(""),
1209                    Some("uid-2"),
1210                ])),
1211                Arc::new(StringArray::from(vec![
1212                    Some("api"),
1213                    Some("api"),
1214                    Some("api"),
1215                    None,
1216                ])),
1217            ],
1218        )
1219        .unwrap();
1220        let ctx = SessionContext::new();
1221        ctx.register_table(
1222            "descriptors",
1223            Arc::new(MemTable::try_new(schema, vec![vec![batch]]).unwrap()),
1224        )
1225        .unwrap();
1226
1227        let mut generic = decl("container", &["container_id"]);
1228        generic.table = "descriptors".to_string();
1229        generic.superseded_by_columns = vec!["pod_uid".to_string(), "container_name".to_string()];
1230        let mut specific = decl("k8s.container", &["pod_uid", "container_name"]);
1231        specific.table = "descriptors".to_string();
1232        let plan = build_registry_plan(
1233            vec![RegistrySource {
1234                declarations: vec![generic, specific],
1235                scan: ctx.table("descriptors").await.unwrap(),
1236            }],
1237            &test_window(),
1238        )
1239        .unwrap()
1240        .unwrap();
1241
1242        let mut rows: Vec<(String, String)> = collect(&ctx, plan)
1243            .await
1244            .iter()
1245            .flat_map(|b| {
1246                strings(b, 4)
1247                    .into_iter()
1248                    .zip(strings(b, 5))
1249                    .collect::<Vec<_>>()
1250            })
1251            .collect();
1252        rows.sort();
1253        assert_eq!(
1254            rows,
1255            vec![
1256                // an empty uid is no uid: it supersedes nothing
1257                ("container".to_string(), "c-docker".to_string()),
1258                ("container".to_string(), "c-empty".to_string()),
1259                // the pod row with no container name cannot produce the
1260                // specific entity, so the generic one has to stand
1261                ("container".to_string(), "c-partial".to_string()),
1262                ("k8s.container".to_string(), "uid-1,api".to_string()),
1263            ]
1264        );
1265    }
1266
1267    #[tokio::test]
1268    async fn registry_expands_declarations_with_one_source_scan() {
1269        let ctx = metric_table_ctx();
1270        let df = ctx.table("app_latency").await.unwrap();
1271        let plan = build_registry_plan(
1272            vec![RegistrySource {
1273                declarations: vec![decl("service", &["service_name"]), decl("host", &["pid"])],
1274                scan: df,
1275            }],
1276            &test_window(),
1277        )
1278        .unwrap()
1279        .unwrap();
1280
1281        assert_eq!(
1282            plan.display_indent()
1283                .to_string()
1284                .matches("TableScan: app_latency")
1285                .count(),
1286            1
1287        );
1288        let batches = collect(&ctx, plan).await;
1289        let mut types: Vec<String> = batches.iter().flat_map(|b| strings(b, 4)).collect();
1290        types.sort();
1291        assert_eq!(types, vec!["host", "host", "service", "service"]);
1292
1293        // No declarations -> no plan.
1294        assert!(
1295            build_registry_plan(vec![], &test_window())
1296                .unwrap()
1297                .is_none()
1298        );
1299    }
1300
1301    /// A `TableInfo` derived from the canonical declared-edge definition, with
1302    /// injection points for each aspect the schema matcher must check.
1303    fn declared_table_info(
1304        mutate_columns: impl FnOnce(&mut Vec<datatypes::schema::ColumnSchema>),
1305        engine: &str,
1306        primary_key_indices: Vec<usize>,
1307        extra_options: &[(&str, &str)],
1308    ) -> table::metadata::TableInfo {
1309        let canonical = build_declared_relationships_expr("greptime");
1310        let mut columns: Vec<datatypes::schema::ColumnSchema> = canonical
1311            .column_defs
1312            .iter()
1313            .map(|def| {
1314                let wrapper = api::helper::ColumnDataTypeWrapper::try_new(
1315                    def.data_type,
1316                    def.datatype_extension.clone(),
1317                )
1318                .unwrap();
1319                datatypes::schema::ColumnSchema::new(
1320                    &def.name,
1321                    datatypes::prelude::ConcreteDataType::from(wrapper),
1322                    def.is_nullable,
1323                )
1324                .with_time_index(def.name == OBSERVED_AT_COLUMN)
1325            })
1326            .collect();
1327        mutate_columns(&mut columns);
1328        let schema = Arc::new(
1329            datatypes::schema::SchemaBuilder::try_from_columns(columns)
1330                .unwrap()
1331                .build()
1332                .unwrap(),
1333        );
1334        let options = table::requests::TableOptions {
1335            extra_options: extra_options
1336                .iter()
1337                .map(|(k, v)| (k.to_string(), v.to_string()))
1338                .collect(),
1339            ..Default::default()
1340        };
1341        let meta = table::metadata::TableMeta {
1342            schema,
1343            primary_key_indices,
1344            value_indices: vec![],
1345            engine: engine.to_string(),
1346            next_column_id: 1,
1347            options,
1348            created_on: Default::default(),
1349            updated_on: Default::default(),
1350            partition_key_indices: vec![],
1351            column_ids: vec![],
1352        };
1353        table::metadata::TableInfoBuilder::default()
1354            .table_id(1)
1355            .name(SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME)
1356            .catalog_name("greptime")
1357            .schema_name(DEFAULT_PRIVATE_SCHEMA_NAME)
1358            .table_version(0)
1359            .table_type(table::metadata::TableType::Base)
1360            .meta(meta)
1361            .build()
1362            .unwrap()
1363    }
1364
1365    #[test]
1366    fn declared_schema_match_checks_more_than_columns() {
1367        let mito = common_catalog::consts::MITO_ENGINE;
1368        let canonical_pk = || (6..14).collect::<Vec<_>>();
1369
1370        let ok = declared_table_info(|_| {}, mito, canonical_pk(), &[(TTL_KEY, "180d")]);
1371        assert!(declared_relationships_schema_matches(&ok));
1372        // The canonical expr must declare attributes as json, matching the
1373        // computed table's column.
1374        assert_eq!(
1375            ok.meta
1376                .schema
1377                .column_schema_by_name("attributes")
1378                .map(|c| c.data_type.clone()),
1379            Some(datatypes::prelude::ConcreteDataType::json_datatype())
1380        );
1381        let ok = declared_table_info(
1382            |_| {},
1383            mito,
1384            canonical_pk(),
1385            &[(MERGE_MODE_KEY, "last_row")],
1386        );
1387        assert!(declared_relationships_schema_matches(&ok));
1388
1389        let wrong_engine = declared_table_info(|_| {}, "metric", canonical_pk(), &[]);
1390        assert!(!declared_relationships_schema_matches(&wrong_engine));
1391
1392        let pk_missing_generation_id = declared_table_info(|_| {}, mito, (6..13).collect(), &[]);
1393        assert!(!declared_relationships_schema_matches(
1394            &pk_missing_generation_id
1395        ));
1396
1397        let wrong_time_index = declared_table_info(
1398            |columns| {
1399                columns[0] = columns[0].clone().with_time_index(false);
1400                columns[4] = datatypes::schema::ColumnSchema::new(
1401                    "valid_from",
1402                    datatypes::prelude::ConcreteDataType::timestamp_millisecond_datatype(),
1403                    false,
1404                )
1405                .with_time_index(true);
1406            },
1407            mito,
1408            canonical_pk(),
1409            &[],
1410        );
1411        assert!(!declared_relationships_schema_matches(&wrong_time_index));
1412
1413        let append_mode =
1414            declared_table_info(|_| {}, mito, canonical_pk(), &[(APPEND_MODE_KEY, "true")]);
1415        assert!(!declared_relationships_schema_matches(&append_mode));
1416
1417        let wrong_column_type = declared_table_info(
1418            |columns| {
1419                columns[14] = datatypes::schema::ColumnSchema::new(
1420                    "confidence",
1421                    datatypes::prelude::ConcreteDataType::int64_datatype(),
1422                    true,
1423                );
1424            },
1425            mito,
1426            canonical_pk(),
1427            &[],
1428        );
1429        assert!(!declared_relationships_schema_matches(&wrong_column_type));
1430    }
1431}