fix(grid): bound large table previews by byte budget
This commit is contained in:
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),
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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")]
|
||||
|
||||
Reference in new issue
Block a user