fix(grid): bound large table previews by byte budget

This commit is contained in:
zipg authored and GitHub committed 2026-08-13 13:25:04 +08:00
1 parent 020654c33c
commit e85d3dcc59
19 files changed
+823 -23

No files matched your search

@@ -12,6 +12,131 @@ use super::types::{
DBX_TDENGINE_TBNAME_COLUMN,
};
pub const DBX_LARGE_VALUE_BYTES_COLUMN_PREFIX: &str = "__DBX_LARGE_VALUE_BYTES_";
#[derive(Clone, Copy, PartialEq, Eq)]
enum LargeValuePreviewKind {
Text,
Binary,
TextCast,
Vector,
}
fn large_value_marker_alias_kind(kind: LargeValuePreviewKind, data_type: &str) -> &'static str {
match kind {
LargeValuePreviewKind::Binary => "B",
LargeValuePreviewKind::Vector => "V",
LargeValuePreviewKind::TextCast => match normalized_data_type_base(data_type).as_str() {
"json" => "J",
"jsonb" => "K",
"tsvector" => "S",
_ => "T",
},
LargeValuePreviewKind::Text => "T",
}
}
fn normalized_data_type_base(data_type: &str) -> String {
data_type.trim().split(['(', '[']).next().unwrap_or_default().trim().to_ascii_lowercase()
}
fn large_value_preview_kind(database_type: Option<DatabaseType>, data_type: &str) -> Option<LargeValuePreviewKind> {
let normalized = data_type.trim().to_ascii_lowercase();
let base = normalized_data_type_base(data_type);
match database_type {
Some(DatabaseType::Mysql) => {
if matches!(base.as_str(), "binary" | "varbinary" | "tinyblob" | "blob" | "mediumblob" | "longblob") {
Some(LargeValuePreviewKind::Binary)
} else if base == "json" {
Some(LargeValuePreviewKind::TextCast)
} else if matches!(base.as_str(), "char" | "varchar" | "tinytext" | "text" | "mediumtext" | "longtext") {
Some(LargeValuePreviewKind::Text)
} else {
None
}
}
Some(DatabaseType::Postgres) => {
if base == "bytea" {
Some(LargeValuePreviewKind::Binary)
} else if matches!(base.as_str(), "char" | "character" | "varchar" | "text" | "citext" | "name" | "xml")
|| normalized.starts_with("character varying")
{
Some(LargeValuePreviewKind::Text)
} else if base == "vector" {
Some(LargeValuePreviewKind::Vector)
} else if matches!(base.as_str(), "json" | "jsonb" | "tsvector") {
Some(LargeValuePreviewKind::TextCast)
} else {
None
}
}
_ => None,
}
}
fn build_large_value_preview_columns(options: &TableDataSelectSqlOptions) -> Option<String> {
let database_type = options.database_type;
let preview_size = options.large_value_preview_size?.max(1);
if options.columns.is_empty()
|| options.columns.len() != options.column_types.len()
|| options.primary_keys.is_empty()
|| options
.columns
.iter()
.any(|column| column.to_ascii_uppercase().starts_with(DBX_LARGE_VALUE_BYTES_COLUMN_PREFIX))
{
return None;
}
let protected: std::collections::HashSet<String> =
options.primary_keys.iter().map(|column| column.to_ascii_lowercase()).collect();
let mut projections = Vec::with_capacity(options.columns.len() * 2);
let mut marker_count = 0;
for (column_index, (column, data_type)) in options.columns.iter().zip(&options.column_types).enumerate() {
let quoted = if uses_connection_identifier_quote(database_type, options.identifier_quote.as_deref()) {
quote_table_data_identifier(database_type, column, options.identifier_quote.as_deref())
} else {
quote_table_identifier(database_type, column)
};
let kind = (!protected.contains(&column.to_ascii_lowercase()))
.then(|| large_value_preview_kind(database_type, data_type))
.flatten();
let Some(kind) = kind else {
projections.push(quoted);
continue;
};
let alias_kind = large_value_marker_alias_kind(kind, data_type);
let marker_alias = quote_table_identifier(
database_type,
&format!("{DBX_LARGE_VALUE_BYTES_COLUMN_PREFIX}{alias_kind}_{column_index}"),
);
let prefix_size = preview_size.saturating_add(1);
let (preview, marker_kind) = match database_type {
Some(DatabaseType::Mysql) if kind == LargeValuePreviewKind::Binary => {
(format!("LEFT({quoted}, {prefix_size}) AS {quoted}"), "B")
}
Some(DatabaseType::Mysql) => (format!("LEFT({quoted}, {prefix_size}) AS {quoted}"), "T"),
Some(DatabaseType::Postgres) if kind == LargeValuePreviewKind::Binary => {
(format!("substring({quoted} from 1 for {prefix_size}) AS {quoted}"), "B")
}
Some(DatabaseType::Postgres) if kind == LargeValuePreviewKind::TextCast => {
(format!("left({quoted}::text, {prefix_size}) AS {quoted}"), "T")
}
Some(DatabaseType::Postgres) if kind == LargeValuePreviewKind::Vector => {
(format!("left({quoted}::text, {prefix_size}) AS {quoted}"), "V")
}
Some(DatabaseType::Postgres) => (format!("left({quoted}, {prefix_size}) AS {quoted}"), "T"),
_ => return None,
};
let marker = format!("'{marker_kind}:{preview_size}' AS {marker_alias}");
projections.push(preview);
projections.push(marker);
marker_count += 1;
}
(marker_count > 0).then(|| projections.join(", "))
}
pub fn build_count_table_sql(database_type: Option<DatabaseType>, schema: Option<&str>, table_name: &str) -> String {
if database_type == Some(DatabaseType::VictoriaMetrics) {
return format!("count({})", victoriametrics_metric_selector(table_name));
@@ -77,6 +202,8 @@ pub fn build_table_data_select_sql(options: TableDataSelectSqlOptions) -> String
let select_columns = if include_oracle_row_id {
format!("ROWIDTOCHAR(t.ROWID) AS \"{DBX_ROWID_COLUMN}\", t.*")
} else if let Some(preview_columns) = build_large_value_preview_columns(&options) {
preview_columns
} else {
build_select_columns(
database_type,
@@ -521,6 +648,8 @@ mod tests {
table_type: None,
primary_keys: Vec::new(),
columns: Vec::new(),
column_types: Vec::new(),
large_value_preview_size: None,
fallback_order_columns: Vec::new(),
order_by: None,
limit: Some(10),
+91
View File
@@ -641,6 +641,97 @@ fn builds_table_data_where_and_schema_queries() {
);
}
#[test]
fn builds_mysql_table_data_large_value_previews_without_truncating_keys() {
let sql = build_table_data_select_sql(TableDataSelectSqlOptions {
database_type: Some(DatabaseType::Mysql),
table_name: "large_rows".to_string(),
primary_keys: vec!["id".to_string()],
columns: vec!["id".to_string(), "payload".to_string(), "raw_value".to_string(), "metadata".to_string()],
column_types: vec!["bigint".to_string(), "longtext".to_string(), "longblob".to_string(), "json".to_string()],
large_value_preview_size: Some(4096),
limit: Some(100),
..Default::default()
});
assert!(sql.starts_with("SELECT `id`, LEFT(`payload`, 4097) AS `payload`"));
assert!(sql.contains("'T:4096' AS `__DBX_LARGE_VALUE_BYTES_T_1`"));
assert!(sql.contains("LEFT(`raw_value`, 4097) AS `raw_value`"));
assert!(sql.contains("'B:4096' AS `__DBX_LARGE_VALUE_BYTES_B_2`"));
assert!(sql.contains("LEFT(`metadata`, 4097) AS `metadata`"));
assert!(sql.contains("'T:4096' AS `__DBX_LARGE_VALUE_BYTES_J_3`"));
assert!(!sql.contains("__DBX_LARGE_VALUE_BYTES_0"));
}
#[test]
fn builds_postgres_table_data_large_value_previews() {
let sql = build_table_data_select_sql(TableDataSelectSqlOptions {
database_type: Some(DatabaseType::Postgres),
schema: Some("public".to_string()),
table_name: "large_rows".to_string(),
primary_keys: vec!["id".to_string()],
columns: vec!["id".to_string(), "payload".to_string(), "metadata".to_string()],
column_types: vec!["integer".to_string(), "text".to_string(), "jsonb".to_string()],
large_value_preview_size: Some(8192),
limit: Some(100),
..Default::default()
});
assert!(sql.starts_with("SELECT \"id\", left(\"payload\", 8193) AS \"payload\""));
assert!(sql.contains("'T:8192' AS \"__DBX_LARGE_VALUE_BYTES_T_1\""));
assert!(sql.contains("left(\"metadata\"::text, 8193) AS \"metadata\""));
assert!(sql.contains("'T:8192' AS \"__DBX_LARGE_VALUE_BYTES_K_2\""));
}
#[test]
fn builds_pgvector_table_data_preview_with_compatible_text_cast() {
let sql = build_table_data_select_sql(TableDataSelectSqlOptions {
database_type: Some(DatabaseType::Postgres),
schema: Some("public".to_string()),
table_name: "embeddings".to_string(),
primary_keys: vec!["id".to_string()],
columns: vec!["id".to_string(), "embedding".to_string()],
column_types: vec!["integer".to_string(), "vector(16000)".to_string()],
large_value_preview_size: Some(8192),
limit: Some(100),
..Default::default()
});
assert!(sql.contains("left(\"embedding\"::text, 8193) AS \"embedding\""));
assert!(sql.contains("'V:8192' AS \"__DBX_LARGE_VALUE_BYTES_V_1\""));
}
#[test]
fn table_data_preview_requires_stable_keys_and_parallel_types() {
let base = TableDataSelectSqlOptions {
database_type: Some(DatabaseType::Postgres),
table_name: "large_rows".to_string(),
columns: vec!["payload".to_string()],
column_types: vec!["text".to_string()],
large_value_preview_size: Some(8192),
limit: Some(100),
..Default::default()
};
assert_eq!(build_table_data_select_sql(base.clone()), "SELECT * FROM \"large_rows\" LIMIT 100;");
assert_eq!(
build_table_data_select_sql(TableDataSelectSqlOptions {
primary_keys: vec!["id".to_string()],
column_types: Vec::new(),
..base.clone()
}),
"SELECT * FROM \"large_rows\" LIMIT 100;"
);
assert_eq!(
build_table_data_select_sql(TableDataSelectSqlOptions {
primary_keys: vec!["id".to_string()],
columns: vec!["id".to_string(), "__dbx_large_value_bytes_t_0".to_string()],
column_types: vec!["integer".to_string(), "text".to_string()],
..base
}),
"SELECT * FROM \"large_rows\" LIMIT 100;"
);
}
#[test]
fn builds_informix_table_data_with_skip_first_pagination() {
assert_eq!(
+9
View File
@@ -44,6 +44,15 @@ pub struct TableDataSelectSqlOptions {
pub primary_keys: Vec<String>,
#[serde(default)]
pub columns: Vec<String>,
/// Database type names parallel to `columns`. Table-data callers can use
/// these to request bounded server-side projections for variable-length
/// values without changing arbitrary query execution.
#[serde(default)]
pub column_types: Vec<String>,
/// Maximum characters (text-like values) or bytes (binary values) kept in
/// a table-data preview. Omitted for exports and ordinary SQL queries.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub large_value_preview_size: Option<usize>,
#[serde(default)]
pub fallback_order_columns: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]