mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-03 08:02:19 +00:00
feat: rework the db manager: native grid, tabs, sql editor, joined columns (#11340)
* feat: replace ag-grid in the db manager table viewer with a native grid Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: add user-managed data, diagram and sql editor tabs to the db manager Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: add schema autocomplete to the db manager sql editor and polish its layout Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: add joined foreign key columns and draggable tabs to the db manager Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: move db manager tabs on drop instead of mid-drag Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: open followed foreign keys in a new db manager tab Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: scroll large tables, drop stale joins and return to the last tab on close Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: tolerate the db manager tabs going away while switching data table Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: keep saved joined columns while metadata loads, test joins in every dialect Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: put the db manager grid on the input surface in dark mode Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: address db manager review nits on joins, boolean keys and sql quoting Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: keep the dark border color on the left pinned column edge Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: tone down the db manager tree menu icons Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: write json and jsonb values from the db manager through a text cast Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: dim unrelated diagram tables less on hover Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: match json columns as text when deleting a db manager row Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: compare postgres json columns as text in exact db manager filters Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: pick the columns the db manager grid shows Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: toggle every db manager column from one checkbox Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: word joined columns as a view in the db manager Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: keep data table roles and access visible when unavailable, with the reason Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: keep data table and instance roles visible in settings when unavailable Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: hide a db manager column from its header menu Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: format db manager columns with a unit, significant digits and color rules Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: drop the before/after hints from the db manager unit picker Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: write the euro after the amount and keep units off non-numeric values Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: color rule presets, bold and italic, layered and reorderable rules in the db manager Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: decimals, thousands separator, compact notation and alignment in db manager column formats Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: compact db manager format controls, a notation toggle group and a reset button Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: db manager format and filter nits Keep the value's scale when decimals are auto, read boolean color-rule conditions as booleans, let a rule's text color reach foreign-key links, close the formatter when the columns picker opens, and filter BigQuery complex columns through TO_JSON_STRING. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2e5f6d0e76
commit
4fd7d62bd0
@@ -189,6 +189,82 @@ fn duckdb_version_suffix(version: Option<i64>) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a preview reads FROM: the table itself, or with joined columns a derived table of its
|
||||
/// rows beside them. Joining inside a derived table leaves the joined columns as plain columns
|
||||
/// of the source, so every dialect's select list, quicksearch, ordering and caller filters apply
|
||||
/// to them as written, unqualified.
|
||||
fn select_source(
|
||||
table: &str,
|
||||
joins: &[JoinedColumn],
|
||||
db_type: DbType,
|
||||
version: Option<i64>,
|
||||
) -> String {
|
||||
// DuckDB takes the time-travel clause after an alias, not before it.
|
||||
let at = |t: &str, alias: Option<&str>| {
|
||||
let suffix = if db_type == DbType::Duckdb {
|
||||
duckdb_version_suffix(version)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let alias = alias.map(|a| format!(" AS {}", a)).unwrap_or_default();
|
||||
format!("{}{}{}", quote_table_name(t, db_type), alias, suffix)
|
||||
};
|
||||
if joins.is_empty() {
|
||||
return at(table, None);
|
||||
}
|
||||
// One join per foreign key, however many of its target's columns are shown.
|
||||
let mut keys: Vec<(&str, &str, &str)> = vec![];
|
||||
for j in joins {
|
||||
let key = (
|
||||
j.source_column.as_str(),
|
||||
j.target_table.as_str(),
|
||||
j.target_column.as_str(),
|
||||
);
|
||||
if !keys.contains(&key) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
let join_alias = |j: &JoinedColumn| {
|
||||
let key = (
|
||||
j.source_column.as_str(),
|
||||
j.target_table.as_str(),
|
||||
j.target_column.as_str(),
|
||||
);
|
||||
format!("wm_j{}", keys.iter().position(|k| *k == key).unwrap_or(0))
|
||||
};
|
||||
let columns = joins
|
||||
.iter()
|
||||
.map(|j| {
|
||||
format!(
|
||||
"{}.{} AS {}",
|
||||
join_alias(j),
|
||||
qi(&j.column, db_type),
|
||||
qi(&j.alias, db_type)
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let join_clauses = keys
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (source, target_table, target_column))| {
|
||||
format!(
|
||||
" LEFT JOIN {} ON wm_base.{} = wm_j{}.{}",
|
||||
at(target_table, Some(&format!("wm_j{}", i))),
|
||||
qi(source, db_type),
|
||||
i,
|
||||
qi(target_column, db_type)
|
||||
)
|
||||
})
|
||||
.collect::<String>();
|
||||
format!(
|
||||
"(SELECT wm_base.*, {} FROM {}{}) AS wm_src",
|
||||
columns,
|
||||
at(table, Some("wm_base")),
|
||||
join_clauses
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WM_INTERNAL_DB expansion: marker detection and SQL generation
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -207,6 +283,25 @@ struct SelectPayload {
|
||||
ducklake: Option<String>,
|
||||
/// DuckLake snapshot to time-travel the read to (DuckDB only).
|
||||
version: Option<i64>,
|
||||
#[serde(default)]
|
||||
joins: Vec<JoinedColumn>,
|
||||
}
|
||||
|
||||
/// A column of a table the previewed one references by a single-column foreign key, shown
|
||||
/// beside its own columns. `columnDefs` lists it too, as `alias`, with the target's datatype.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct JoinedColumn {
|
||||
/// The previewed table's foreign key column.
|
||||
#[serde(rename = "sourceColumn")]
|
||||
pub source_column: String,
|
||||
#[serde(rename = "targetTable")]
|
||||
pub target_table: String,
|
||||
/// The column the foreign key references in `target_table`.
|
||||
#[serde(rename = "targetColumn")]
|
||||
pub target_column: String,
|
||||
/// The column of `target_table` to show.
|
||||
pub column: String,
|
||||
pub alias: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -219,6 +314,8 @@ struct CountPayload {
|
||||
ducklake: Option<String>,
|
||||
/// DuckLake snapshot to time-travel the count to (DuckDB only).
|
||||
version: Option<i64>,
|
||||
#[serde(default)]
|
||||
joins: Vec<JoinedColumn>,
|
||||
}
|
||||
|
||||
/// `WM_INTERNAL_DB_DUCKLAKE_SNAPSHOTS` payload — lists the time-travel history
|
||||
@@ -368,8 +465,9 @@ fn expand_select(json_str: &str, db_type: DbType) -> Result<String, String> {
|
||||
.fix_pg_int_types
|
||||
.map(|v| BreakingFeatures { fix_pg_int_types: v });
|
||||
|
||||
let query = make_select_query(
|
||||
let query = make_select_query_with_joins(
|
||||
&payload.table,
|
||||
&payload.joins,
|
||||
&payload.column_defs,
|
||||
payload.where_clause.as_deref(),
|
||||
db_type,
|
||||
@@ -384,9 +482,10 @@ fn expand_count(json_str: &str, db_type: DbType) -> Result<String, String> {
|
||||
let payload: CountPayload =
|
||||
serde_json::from_str(json_str).map_err(|e| format!("Invalid COUNT payload: {}", e))?;
|
||||
|
||||
let query = make_count_query(
|
||||
let query = make_count_query_with_joins(
|
||||
db_type,
|
||||
&payload.table,
|
||||
&payload.joins,
|
||||
payload.where_clause.as_deref(),
|
||||
&payload.column_defs,
|
||||
payload.version,
|
||||
@@ -618,6 +717,7 @@ pub fn build_visible_field_list(column_defs: &[ColumnDef], db_type: DbType) -> V
|
||||
|
||||
fn make_snowflake_select_query(
|
||||
table: &str,
|
||||
joins: &[JoinedColumn],
|
||||
column_defs: &[ColumnDef],
|
||||
where_clause: Option<&str>,
|
||||
options: Option<&SelectOptions>,
|
||||
@@ -638,7 +738,7 @@ fn make_snowflake_select_query(
|
||||
query.push_str(&format!(
|
||||
"SELECT {} FROM {}",
|
||||
select_clause,
|
||||
quote_table_name(table, DbType::Snowflake)
|
||||
select_source(table, joins, DbType::Snowflake, None)
|
||||
));
|
||||
|
||||
// quicksearch condition
|
||||
@@ -728,6 +828,26 @@ pub fn make_select_query(
|
||||
db_type: DbType,
|
||||
options: Option<&SelectOptions>,
|
||||
breaking_features: Option<&BreakingFeatures>,
|
||||
) -> Result<String, String> {
|
||||
make_select_query_with_joins(
|
||||
table,
|
||||
&[],
|
||||
column_defs,
|
||||
where_clause,
|
||||
db_type,
|
||||
options,
|
||||
breaking_features,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn make_select_query_with_joins(
|
||||
table: &str,
|
||||
joins: &[JoinedColumn],
|
||||
column_defs: &[ColumnDef],
|
||||
where_clause: Option<&str>,
|
||||
db_type: DbType,
|
||||
options: Option<&SelectOptions>,
|
||||
breaking_features: Option<&BreakingFeatures>,
|
||||
) -> Result<String, String> {
|
||||
if table.is_empty() {
|
||||
return Err("Table name is required".to_string());
|
||||
@@ -788,6 +908,7 @@ pub fn make_select_query(
|
||||
DbType::Snowflake => {
|
||||
return Ok(make_snowflake_select_query(
|
||||
table,
|
||||
joins,
|
||||
column_defs,
|
||||
where_clause,
|
||||
options,
|
||||
@@ -818,7 +939,7 @@ pub fn make_select_query(
|
||||
query.push_str(&format!(
|
||||
"SELECT {} FROM {}",
|
||||
select_clause,
|
||||
quote_table_name(table, db_type)
|
||||
select_source(table, joins, db_type, None)
|
||||
));
|
||||
query.push_str(&format!(
|
||||
" WHERE {} {}",
|
||||
@@ -884,7 +1005,7 @@ pub fn make_select_query(
|
||||
.map(|c| format!("{}::text", c))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
quote_table_name(table, db_type)
|
||||
select_source(table, joins, db_type, None)
|
||||
));
|
||||
query.push_str(&format!(
|
||||
" WHERE {} {}\n",
|
||||
@@ -943,7 +1064,7 @@ pub fn make_select_query(
|
||||
query.push_str(&format!(
|
||||
"SELECT {} FROM {}",
|
||||
select_clause,
|
||||
quote_table_name(table, db_type)
|
||||
select_source(table, joins, db_type, None)
|
||||
));
|
||||
query.push_str(&format!(
|
||||
" WHERE {} {}",
|
||||
@@ -1012,7 +1133,7 @@ pub fn make_select_query(
|
||||
query.push_str(&format!(
|
||||
"SELECT {} FROM {}",
|
||||
select_clause,
|
||||
quote_table_name(table, db_type)
|
||||
select_source(table, joins, db_type, None)
|
||||
));
|
||||
query.push_str(&format!(
|
||||
" WHERE {} {}",
|
||||
@@ -1049,10 +1170,9 @@ pub fn make_select_query(
|
||||
);
|
||||
|
||||
query.push_str(&format!(
|
||||
"SELECT {} FROM {}{}\n",
|
||||
"SELECT {} FROM {}\n",
|
||||
filtered_columns.join(", "),
|
||||
quote_table_name(table, db_type),
|
||||
duckdb_version_suffix(options.and_then(|o| o.version))
|
||||
select_source(table, joins, db_type, options.and_then(|o| o.version))
|
||||
));
|
||||
query.push_str(&format!(
|
||||
" WHERE {} {}\n",
|
||||
@@ -1080,6 +1200,19 @@ pub fn make_count_query(
|
||||
// DuckLake time-travel snapshot (DuckDB only); `None` counts the latest.
|
||||
version: Option<i64>,
|
||||
) -> Result<String, String> {
|
||||
make_count_query_with_joins(db_type, table, &[], where_clause, column_defs, version)
|
||||
}
|
||||
|
||||
pub fn make_count_query_with_joins(
|
||||
db_type: DbType,
|
||||
table: &str,
|
||||
joins: &[JoinedColumn],
|
||||
where_clause: Option<&str>,
|
||||
column_defs: &[ColumnDef],
|
||||
// DuckLake time-travel snapshot (DuckDB only); `None` counts the latest.
|
||||
version: Option<i64>,
|
||||
) -> Result<String, String> {
|
||||
let source = select_source(table, joins, db_type, version);
|
||||
let where_prefix = " WHERE ";
|
||||
let and_condition = " AND ";
|
||||
let mut quicksearch_condition = String::new();
|
||||
@@ -1118,10 +1251,7 @@ pub fn make_count_query(
|
||||
} else {
|
||||
quicksearch_condition.push_str(" (:quicksearch = '' OR 1 = 1)");
|
||||
}
|
||||
query.push_str(&format!(
|
||||
"SELECT COUNT(*) as count FROM {}",
|
||||
quote_table_name(table, db_type)
|
||||
));
|
||||
query.push_str(&format!("SELECT COUNT(*) as count FROM {}", source));
|
||||
}
|
||||
DbType::Postgresql => {
|
||||
if !filtered_columns.is_empty() {
|
||||
@@ -1132,10 +1262,7 @@ pub fn make_count_query(
|
||||
} else {
|
||||
quicksearch_condition.push_str("($1 = '' OR 1 = 1)");
|
||||
}
|
||||
query.push_str(&format!(
|
||||
"SELECT COUNT(*) as count FROM {}",
|
||||
quote_table_name(table, db_type)
|
||||
));
|
||||
query.push_str(&format!("SELECT COUNT(*) as count FROM {}", source));
|
||||
}
|
||||
DbType::MsSqlServer => {
|
||||
if !filtered_columns.is_empty() {
|
||||
@@ -1146,10 +1273,7 @@ pub fn make_count_query(
|
||||
} else {
|
||||
quicksearch_condition.push_str("(@p1 = '' OR 1 = 1)");
|
||||
}
|
||||
query.push_str(&format!(
|
||||
"SELECT COUNT(*) as count FROM {}",
|
||||
quote_table_name(table, db_type)
|
||||
));
|
||||
query.push_str(&format!("SELECT COUNT(*) as count FROM {}", source));
|
||||
}
|
||||
DbType::Snowflake => {
|
||||
if !filtered_columns.is_empty() {
|
||||
@@ -1173,10 +1297,7 @@ pub fn make_count_query(
|
||||
query.push('\n');
|
||||
quicksearch_condition.push_str("(? = '' OR 1 = 1)");
|
||||
}
|
||||
query.push_str(&format!(
|
||||
"SELECT COUNT(*) as count FROM {}",
|
||||
quote_table_name(table, db_type)
|
||||
));
|
||||
query.push_str(&format!("SELECT COUNT(*) as count FROM {}", source));
|
||||
}
|
||||
DbType::Bigquery => {
|
||||
if !filtered_columns.is_empty() {
|
||||
@@ -1205,10 +1326,7 @@ pub fn make_count_query(
|
||||
} else {
|
||||
quicksearch_condition.push_str("(@quicksearch = '' OR 1 = 1)");
|
||||
}
|
||||
query.push_str(&format!(
|
||||
"SELECT COUNT(*) as count FROM {}",
|
||||
quote_table_name(table, db_type)
|
||||
));
|
||||
query.push_str(&format!("SELECT COUNT(*) as count FROM {}", source));
|
||||
}
|
||||
DbType::Duckdb => {
|
||||
if !filtered_columns.is_empty() {
|
||||
@@ -1219,11 +1337,7 @@ pub fn make_count_query(
|
||||
} else {
|
||||
quicksearch_condition.push_str(" ($quicksearch = '' OR 1 = 1)");
|
||||
}
|
||||
query.push_str(&format!(
|
||||
"SELECT COUNT(*) as count FROM {}{}",
|
||||
quote_table_name(table, db_type),
|
||||
duckdb_version_suffix(version)
|
||||
));
|
||||
query.push_str(&format!("SELECT COUNT(*) as count FROM {}", source));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1288,16 +1402,7 @@ pub fn make_delete_query(table: &str, columns: &[ColumnDef], db_type: DbType) ->
|
||||
let conditions: String = columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| {
|
||||
let qf = qi(&c.field, db_type);
|
||||
format!(
|
||||
"(${} IS NULL AND {} IS NULL OR {} = ${})",
|
||||
i + 1,
|
||||
qf,
|
||||
qf,
|
||||
i + 1,
|
||||
)
|
||||
})
|
||||
.map(|(i, c)| pg_row_match(&c.field, &c.datatype, i + 1))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n AND ");
|
||||
query.push_str(&format!(
|
||||
@@ -1407,13 +1512,41 @@ fn get_user_default_value(column: &ColumnDef) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether a PostgreSQL column is JSON. The preview reads and edits every value as text, and
|
||||
/// the executor binds a string parameter as `text`, which Postgres will not assign to a JSON
|
||||
/// column implicitly.
|
||||
fn pg_is_json(datatype: &str) -> bool {
|
||||
matches!(datatype.trim().to_lowercase().as_str(), "json" | "jsonb")
|
||||
}
|
||||
|
||||
/// A PostgreSQL parameter as a value for a column of `datatype`: JSON goes through text, so
|
||||
/// either a string or a JSON value can be bound.
|
||||
fn pg_value(param: &str, datatype: &str) -> String {
|
||||
if pg_is_json(datatype) {
|
||||
format!("{}::text::{}", param, datatype.trim().to_lowercase())
|
||||
} else {
|
||||
param.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// PostgreSQL predicate matching a row's `field` against parameter `$param`, NULL included.
|
||||
/// The row's JSON values were read as text, and `json` has no `=`: they compare as text.
|
||||
fn pg_row_match(field: &str, datatype: &str, param: usize) -> String {
|
||||
let qf = qi(field, DbType::Postgresql);
|
||||
if pg_is_json(datatype) {
|
||||
format!("(${param} IS NULL AND {qf} IS NULL OR {qf}::text = ${param}::text)")
|
||||
} else {
|
||||
format!("(${param} IS NULL AND {qf} IS NULL OR {qf} = ${param})")
|
||||
}
|
||||
}
|
||||
|
||||
fn format_insert_values(columns: &[ColumnDef], db_type: DbType, start_index: usize) -> String {
|
||||
columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| match db_type {
|
||||
DbType::Mysql => format!(":{}", c.field),
|
||||
DbType::Postgresql => format!("${}", start_index + i),
|
||||
DbType::Postgresql => pg_value(&format!("${}", start_index + i), &c.datatype),
|
||||
DbType::MsSqlServer => format!("@p{}", start_index + i),
|
||||
DbType::Snowflake => "?".to_string(),
|
||||
DbType::Bigquery => format!("@{}", c.field),
|
||||
@@ -1588,22 +1721,16 @@ pub fn make_update_query(
|
||||
let conditions: String = columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| {
|
||||
let qf = qi(&c.field, db_type);
|
||||
format!(
|
||||
"(${} IS NULL AND {} IS NULL OR {} = ${})",
|
||||
i + 2,
|
||||
qf,
|
||||
qf,
|
||||
i + 2,
|
||||
)
|
||||
})
|
||||
.map(|(i, c)| pg_row_match(&c.field, &c.datatype, i + 2))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n AND ");
|
||||
|
||||
query.push_str(&format!(
|
||||
"\nUPDATE {} SET {} = $1 \nWHERE {}\tRETURNING 1",
|
||||
qt, qcol, conditions
|
||||
"\nUPDATE {} SET {} = {} \nWHERE {}\tRETURNING 1",
|
||||
qt,
|
||||
qcol,
|
||||
pg_value("$1", &column.datatype),
|
||||
conditions
|
||||
));
|
||||
}
|
||||
DbType::Mysql => {
|
||||
@@ -2931,6 +3058,133 @@ mod tests {
|
||||
SimpleColumn { field: field.to_string(), datatype: datatype.to_string() }
|
||||
}
|
||||
|
||||
fn joined(source: &str, target: &str, column: &str) -> JoinedColumn {
|
||||
JoinedColumn {
|
||||
source_column: source.to_string(),
|
||||
target_table: target.to_string(),
|
||||
target_column: "id".to_string(),
|
||||
column: column.to_string(),
|
||||
alias: format!("{}.{}", source, column),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_joins_read_from_a_derived_source() {
|
||||
let cols = vec![
|
||||
col("id", "int4"),
|
||||
col("user_id", "int4"),
|
||||
col("user_id.name", "text"),
|
||||
];
|
||||
// Two columns through one foreign key share its join.
|
||||
let joins = vec![
|
||||
joined("user_id", "public.users", "name"),
|
||||
joined("user_id", "public.users", "email"),
|
||||
];
|
||||
let result = make_select_query_with_joins(
|
||||
"public.orders",
|
||||
&joins,
|
||||
&cols,
|
||||
None,
|
||||
DbType::Postgresql,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(result.contains(
|
||||
r#"FROM (SELECT wm_base.*, wm_j0."name" AS "user_id.name", wm_j0."email" AS "user_id.email" FROM "public"."orders" AS wm_base LEFT JOIN "public"."users" AS wm_j0 ON wm_base."user_id" = wm_j0."id") AS wm_src"#
|
||||
));
|
||||
// The joined column is an ordinary column of the source: selected and searched as such.
|
||||
assert!(result.contains(r#""user_id.name"::text"#));
|
||||
|
||||
let count = make_count_query_with_joins(
|
||||
DbType::Postgresql,
|
||||
"public.orders",
|
||||
&joins,
|
||||
None,
|
||||
&cols,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(count.contains("SELECT COUNT(*) as count FROM (SELECT wm_base.*"));
|
||||
assert!(count.contains(") AS wm_src WHERE ($1 = ''"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_duckdb_joins_alias_before_time_travel() {
|
||||
let cols = vec![col("id", "INTEGER"), col("user_id.name", "VARCHAR")];
|
||||
let opts = SelectOptions { limit: None, offset: None, version: Some(3) };
|
||||
let result = make_select_query_with_joins(
|
||||
"main.orders",
|
||||
&[joined("user_id", "main.users", "name")],
|
||||
&cols,
|
||||
None,
|
||||
DbType::Duckdb,
|
||||
Some(&opts),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(result.contains(r#"FROM "main"."orders" AS wm_base AT (VERSION => 3) LEFT JOIN "main"."users" AS wm_j0 AT (VERSION => 3) ON"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_joins_read_from_a_derived_source_in_every_dialect() {
|
||||
let cols = vec![col("id", "int"), col("user_id.name", "text")];
|
||||
let joins = vec![joined("user_id", "s.users", "name")];
|
||||
for (db_type, expected) in [
|
||||
(
|
||||
DbType::Mysql,
|
||||
"FROM (SELECT wm_base.*, wm_j0.`name` AS `user_id.name` FROM `s`.`orders` AS wm_base LEFT JOIN `s`.`users` AS wm_j0 ON wm_base.`user_id` = wm_j0.`id`) AS wm_src",
|
||||
),
|
||||
(
|
||||
DbType::MsSqlServer,
|
||||
"FROM (SELECT wm_base.*, wm_j0.[name] AS [user_id.name] FROM [s].[orders] AS wm_base LEFT JOIN [s].[users] AS wm_j0 ON wm_base.[user_id] = wm_j0.[id]) AS wm_src",
|
||||
),
|
||||
(
|
||||
DbType::Snowflake,
|
||||
r#"FROM (SELECT wm_base.*, wm_j0."name" AS "user_id.name" FROM "s"."orders" AS wm_base LEFT JOIN "s"."users" AS wm_j0 ON wm_base."user_id" = wm_j0."id") AS wm_src"#,
|
||||
),
|
||||
(
|
||||
DbType::Bigquery,
|
||||
"FROM (SELECT wm_base.*, wm_j0.`name` AS `user_id.name` FROM `s`.`orders` AS wm_base LEFT JOIN `s`.`users` AS wm_j0 ON wm_base.`user_id` = wm_j0.`id`) AS wm_src",
|
||||
),
|
||||
] {
|
||||
let select =
|
||||
make_select_query_with_joins("s.orders", &joins, &cols, None, db_type, None, None)
|
||||
.unwrap();
|
||||
assert!(select.contains(expected), "{:?} select: {}", db_type, select);
|
||||
let count =
|
||||
make_count_query_with_joins(db_type, "s.orders", &joins, None, &cols, None).unwrap();
|
||||
assert!(count.contains(expected), "{:?} count: {}", db_type, count);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pg_json_columns_are_written_through_text() {
|
||||
let update = make_update_query(
|
||||
"t",
|
||||
&simple_col("js", "jsonb"),
|
||||
&[simple_col("js", "jsonb")],
|
||||
DbType::Postgresql,
|
||||
);
|
||||
assert!(update.contains(r#"SET "js" = $1::text::jsonb"#), "{}", update);
|
||||
assert!(update.contains(r#""js"::text = $2::text"#), "{}", update);
|
||||
let insert =
|
||||
make_insert_query("t", &[col("id", "int4"), col("js", "jsonb")], DbType::Postgresql)
|
||||
.unwrap();
|
||||
assert!(insert.contains("VALUES ($1, $2::text::jsonb"), "{}", insert);
|
||||
let delete = make_delete_query("t", &[col("js", "json")], DbType::Postgresql);
|
||||
assert!(delete.contains(r#""js"::text = $1::text"#), "{}", delete);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_without_joins_is_unchanged() {
|
||||
let cols = vec![col("id", "int4")];
|
||||
assert_eq!(
|
||||
make_select_query_with_joins("t", &[], &cols, None, DbType::Mysql, None, None).unwrap(),
|
||||
make_select_query("t", &cols, None, DbType::Mysql, None, None).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// build_parameters
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { dbManagerTabsStorageKey, dbTableLayoutStorageKey } from './dbManagerDrawerModel.svelte'
|
||||
import { DbManagerTabs, type DbManagerTabKind } from './dbManagerTabs.svelte'
|
||||
import DbSqlTab from './DbSqlTab.svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { dbSchemas, type DBSchema } from '$lib/stores'
|
||||
import type { DataTableTables } from '$lib/gen'
|
||||
import { sortArray } from '$lib/utils'
|
||||
@@ -69,6 +73,10 @@
|
||||
/** Which view the right pane shows, set by the control the caller renders. */
|
||||
requestedViewMode?: DbManagerViewMode
|
||||
onViewMode?: (mode: DbManagerViewMode) => void
|
||||
/** Show the right pane as tabs (data, diagram, SQL editor) kept per database, in place of
|
||||
* the single view and the SQL pane below it. The caller renders the tab strip from
|
||||
* `tabsModel()`. */
|
||||
tabbed?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -91,7 +99,8 @@
|
||||
workspace = undefined,
|
||||
workerTag = $bindable(),
|
||||
requestedViewMode,
|
||||
onViewMode
|
||||
onViewMode,
|
||||
tabbed = false
|
||||
}: Props = $props()
|
||||
|
||||
let ws = $derived(workspace ?? $operatingWorkspace)
|
||||
@@ -131,8 +140,11 @@
|
||||
let colDefsRun = 0
|
||||
let schemaRun = 0
|
||||
|
||||
// Keyed on the database rather than `input`, which is replaced whenever the selected table
|
||||
// changes: re-reading on that would redo the whole database's reads per click.
|
||||
let databaseCacheKey = $derived(input ? schemaCacheKey(ws, input) : undefined)
|
||||
let colDefs = resource(
|
||||
() => [input, ws, workerTag],
|
||||
() => [databaseCacheKey, workerTag],
|
||||
async () => {
|
||||
const run = ++colDefsRun
|
||||
colDefsError = undefined
|
||||
@@ -158,7 +170,7 @@
|
||||
)
|
||||
|
||||
let dbSchemasPromise = resource(
|
||||
() => [input, ws, workerTag],
|
||||
() => [databaseCacheKey, workerTag],
|
||||
async () => {
|
||||
const run = ++schemaRun
|
||||
schemaError = undefined
|
||||
@@ -247,6 +259,24 @@
|
||||
}
|
||||
let _dbManager: DbManager | undefined = $state()
|
||||
export const dbManager = () => _dbManager
|
||||
|
||||
// Keyed on the database alone: `input` is replaced whenever the selected table changes, and
|
||||
// that must not reset the tabs.
|
||||
let tabsStorageKey = $derived(
|
||||
tabbed && input && ws ? dbManagerTabsStorageKey(ws, input) : undefined
|
||||
)
|
||||
let tabs = $derived.by(() => {
|
||||
const key = tabsStorageKey
|
||||
if (!key) return undefined
|
||||
return untrack(() => {
|
||||
const kinds: DbManagerTabKind[] =
|
||||
input && getDbType(input) === 'postgresql' ? ['data', 'diagram', 'sql'] : ['data', 'sql']
|
||||
const model = new DbManagerTabs(key, kinds)
|
||||
if (input?.specificTable) model.openTable(input.specificSchema, input.specificTable)
|
||||
return model
|
||||
})
|
||||
})
|
||||
export const tabsModel = () => tabs
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
@@ -295,29 +325,56 @@
|
||||
{@const _input = input}
|
||||
{@const dbType = getDbType(_input)}
|
||||
{@const shownSchema = loadError || !dbSchema ? emptySchemaFor(_input) : dbSchema}
|
||||
{@const placeholderTableName = sortArray(
|
||||
Object.keys(
|
||||
shownSchema.schema[
|
||||
'public' in shownSchema.schema
|
||||
? 'public'
|
||||
: 'dbo' in shownSchema.schema
|
||||
? 'dbo'
|
||||
: Object.keys(shownSchema.schema ?? {})?.[0]
|
||||
] ?? {}
|
||||
)
|
||||
)?.[0]}
|
||||
{#snippet sqlTab(tab: { id: string; code?: string })}
|
||||
<DbSqlTab
|
||||
input={_input}
|
||||
workspace={ws!}
|
||||
tag={workerTag}
|
||||
{placeholderTableName}
|
||||
initialCode={tab.code}
|
||||
onCodeChange={(code) => tabs?.update(tab.id, { code })}
|
||||
onSchemaChange={() => refresh()}
|
||||
schema={shownSchema}
|
||||
/>
|
||||
{/snippet}
|
||||
<Splitpanes horizontal>
|
||||
<Pane class="relative">
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class={'absolute inset-0 z-10 p-8 ' +
|
||||
(replResultData
|
||||
? 'bg-surface/90'
|
||||
: 'transition-colors bg-transparent pointer-events-none select-none')}
|
||||
onclick={(e) => {
|
||||
// Only proceed if the click is directly on this div and not on the child elements
|
||||
if (e.target === e.currentTarget) {
|
||||
replResultData = undefined
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if replResultData}
|
||||
{#key replResultData}
|
||||
<SimpleAgTable data={replResultData} class="animate-zoom-in" />
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
{#if !tabbed}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class={'absolute inset-0 z-10 p-8 ' +
|
||||
(replResultData
|
||||
? 'bg-surface/90'
|
||||
: 'transition-colors bg-transparent pointer-events-none select-none')}
|
||||
onclick={(e) => {
|
||||
// Only proceed if the click is directly on this div and not on the child elements
|
||||
if (e.target === e.currentTarget) {
|
||||
replResultData = undefined
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if replResultData}
|
||||
{#key replResultData}
|
||||
<SimpleAgTable data={replResultData} class="animate-zoom-in" />
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<DbManager
|
||||
{tabs}
|
||||
{sqlTab}
|
||||
{requestedViewMode}
|
||||
{onViewMode}
|
||||
databaseKey={schemaCacheKey(ws, _input)}
|
||||
@@ -327,11 +384,10 @@
|
||||
dbSchema={shownSchema}
|
||||
mainPane={loadError ? errorPane : undefined}
|
||||
colDefs={loadError ? undefined : colDefsOfInput}
|
||||
dbTableOpsFactory={({ colDefs, tableKey, whereClause }) =>
|
||||
dbTableOpsFactory={({ colDefs, tableKey }) =>
|
||||
dbTableOpsWithPreviewScripts({
|
||||
colDefs,
|
||||
tableKey,
|
||||
whereClause,
|
||||
input: _input,
|
||||
workspace: ws,
|
||||
tag: workerTag
|
||||
@@ -373,9 +429,10 @@
|
||||
bind:this={_dbManager}
|
||||
{disabledTables}
|
||||
features={getDbFeatures(input)}
|
||||
tableLayoutKey={(schema, table) => dbTableLayoutStorageKey(ws, _input, schema, table)}
|
||||
/>
|
||||
</Pane>
|
||||
{#if showRepl}
|
||||
{#if showRepl && !tabbed}
|
||||
<Pane bind:size={replPanelSize} minSize={REPL_MIN_SIZE} class="relative">
|
||||
<SqlRepl
|
||||
{input}
|
||||
@@ -385,17 +442,8 @@
|
||||
replResultData = data
|
||||
}}
|
||||
onSchemaChange={() => refresh()}
|
||||
placeholderTableName={sortArray(
|
||||
Object.keys(
|
||||
shownSchema.schema[
|
||||
'public' in shownSchema.schema
|
||||
? 'public'
|
||||
: 'dbo' in shownSchema.schema
|
||||
? 'dbo'
|
||||
: Object.keys(shownSchema.schema ?? {})?.[0]
|
||||
] ?? {}
|
||||
)
|
||||
)?.[0]}
|
||||
{placeholderTableName}
|
||||
schema={shownSchema}
|
||||
/>
|
||||
</Pane>
|
||||
{/if}
|
||||
|
||||
@@ -8,21 +8,25 @@
|
||||
import Select from './select/Select.svelte'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Code,
|
||||
Copy,
|
||||
Download,
|
||||
Expand,
|
||||
Minimize,
|
||||
Network,
|
||||
Plus,
|
||||
RefreshCcw,
|
||||
Table2,
|
||||
Upload
|
||||
Tag,
|
||||
Upload,
|
||||
X
|
||||
} from 'lucide-svelte'
|
||||
import DBManagerContent from './DBManagerContent.svelte'
|
||||
import type { DbManagerViewMode, PendingRowAction } from './DBManager.svelte'
|
||||
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
|
||||
import DropdownV2 from './DropdownV2.svelte'
|
||||
import { flip } from 'svelte/animate'
|
||||
import type { PendingRowAction } from './DBManager.svelte'
|
||||
import type { DbManagerTab, DbManagerTabKind } from './dbManagerTabs.svelte'
|
||||
import { logFeatureUsage } from '$lib/utils/featureUsage'
|
||||
import { getDbType } from './dbOps'
|
||||
import { renderDbQuotedIdentifier } from './apps/components/display/dbtable/utils'
|
||||
import DataTableMigrationsButton from './workspaceSettings/DataTableMigrationsButton.svelte'
|
||||
import DataTablePermissionsButton from './workspaceSettings/DataTablePermissionsButton.svelte'
|
||||
import { resource } from 'runed'
|
||||
@@ -176,12 +180,8 @@
|
||||
dbManagerContent?.clearReplResult()
|
||||
}
|
||||
|
||||
let windowWidth = $state(window.innerWidth)
|
||||
let expand = $state(false)
|
||||
|
||||
$effect(() => {
|
||||
if (!open) {
|
||||
expand = false
|
||||
uriState.closeDrawer()
|
||||
// An action asked for on one data table must not be waiting when the
|
||||
// drawer is next opened on another database — or on no data table at
|
||||
@@ -200,20 +200,55 @@
|
||||
|
||||
let hasReplResult = $state(false)
|
||||
|
||||
// Which view the manager shows. Held here, beside the control that switches it
|
||||
// and outside the key that remounts the manager, so picking another data table
|
||||
// or role stays on the view the user was reading.
|
||||
let requestedViewMode = $state<DbManagerViewMode>('data')
|
||||
// Only PostgreSQL has a diagram; the manager clamps the mode itself, and this
|
||||
// keeps the control off the header for a database that cannot show one.
|
||||
// The tabs start where the right pane does, so a narrow tree pane would run them into the
|
||||
// title: below the room the title needs, in pixels, it goes.
|
||||
const TITLE_ROOM_PX = 200
|
||||
let showTitle = $derived.by(() => {
|
||||
const left = dbManagerContent?.dbManager()?.mainPaneLeft()
|
||||
return !dbManagerContent?.tabsModel() || !left || left >= TITLE_ROOM_PX
|
||||
})
|
||||
|
||||
// Only PostgreSQL has a diagram.
|
||||
let diagramSupported = $derived(
|
||||
!!uriState.effectiveInput && getDbType(uriState.effectiveInput) === 'postgresql'
|
||||
)
|
||||
|
||||
const TAB_KINDS: { kind: DbManagerTabKind; label: string; icon: typeof Table2 }[] = [
|
||||
{ kind: 'data', label: 'Data', icon: Table2 },
|
||||
{ kind: 'diagram', label: 'Diagram', icon: Network },
|
||||
{ kind: 'sql', label: 'SQL Editor', icon: Code }
|
||||
]
|
||||
/** A plain mouse wheel scrolls vertically, which the tab strip cannot: turn it sideways.
|
||||
* Registered by hand because Svelte's `onwheel` is passive and cannot prevent the default. */
|
||||
function wheelScrollsSideways(el: HTMLElement) {
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (e.deltaX || !e.deltaY || el.scrollWidth <= el.clientWidth) return
|
||||
el.scrollLeft += e.deltaY
|
||||
e.preventDefault()
|
||||
}
|
||||
el.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => el.removeEventListener('wheel', onWheel)
|
||||
}
|
||||
|
||||
function quotedTableName(schema: string | undefined, table: string): string {
|
||||
const input = uriState.effectiveInput
|
||||
if (!input) return table
|
||||
const q = (name: string) => renderDbQuotedIdentifier(name, getDbType(input))
|
||||
return schema ? `${q(schema)}.${q(table)}` : q(table)
|
||||
}
|
||||
|
||||
let draggedTabId: string | undefined = $state()
|
||||
let dropTarget: { id: string; side: 'before' | 'after' } | undefined = $state()
|
||||
function tabLabel(tab: DbManagerTab): string {
|
||||
if (tab.kind === 'data') return tab.table ?? 'Data'
|
||||
return TAB_KINDS.find((k) => k.kind === tab.kind)!.label
|
||||
}
|
||||
|
||||
// Export/Import state
|
||||
let exportDrawerOpen = $state(false)
|
||||
let exportResult = $state('')
|
||||
let importDrawerOpen = $state(false)
|
||||
let workerTagOpen = $state(false)
|
||||
let importLoading = $state(false)
|
||||
let importSource = $state<string | undefined>(undefined)
|
||||
/** Which database an import writes into; set when driven from a tree row. */
|
||||
@@ -318,17 +353,9 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerWidth={windowWidth} />
|
||||
|
||||
<Drawer
|
||||
bind:open
|
||||
size={expand ? `${windowWidth}px` : '1200px'}
|
||||
preventEscape
|
||||
{offset}
|
||||
on:close={handleClose}
|
||||
>
|
||||
<Drawer bind:open placement="center" preventEscape {offset} on:close={handleClose}>
|
||||
<DrawerContent
|
||||
title={hasReplResult ? 'Query Result' : 'Database Manager'}
|
||||
title={showTitle ? (hasReplResult ? 'Query Result' : 'Database Manager') : undefined}
|
||||
on:close={() => {
|
||||
if (hasReplResult) {
|
||||
dbManagerContent?.clearReplResult()
|
||||
@@ -338,14 +365,16 @@
|
||||
}}
|
||||
CloseIcon={hasReplResult ? ArrowLeft : undefined}
|
||||
noPadding
|
||||
overflow_y={false}
|
||||
fullScreen={false}
|
||||
titleClass="text-sm text-primary"
|
||||
id="db-manager-drawer"
|
||||
>
|
||||
{#if contentInput && ws && roleSettled}
|
||||
{#key `${selectedDatatable}~${selectedRole ?? ''}`}
|
||||
<DBManagerContent
|
||||
bind:this={dbManagerContent}
|
||||
{requestedViewMode}
|
||||
onViewMode={(mode) => (requestedViewMode = mode)}
|
||||
tabbed
|
||||
input={contentInput}
|
||||
workspace={uriState.workspace}
|
||||
datatableTree={uriState.isDatatableInput ? datatables.current : undefined}
|
||||
@@ -357,9 +386,7 @@
|
||||
uriState.selectedRole = role
|
||||
}}
|
||||
bind:pendingAction
|
||||
canManageDatatable={!!($superadmin || $userStore?.is_admin) &&
|
||||
!!$enterpriseLicense &&
|
||||
!isCloudHosted()}
|
||||
canManageDatatable={!!($superadmin || $userStore?.is_admin)}
|
||||
onDatatableAction={runDatatableAction}
|
||||
bind:workerTag={() => workerTag.tag, (v) => (workerTag.tag = v)}
|
||||
bind:hasReplResult
|
||||
@@ -375,38 +402,167 @@
|
||||
></DBManagerContent>
|
||||
{/key}
|
||||
{/if}
|
||||
{#snippet titleExtra()}
|
||||
{@const mainPaneLeft = dbManagerContent?.dbManager()?.mainPaneLeft()}
|
||||
{@const tabs = dbManagerContent?.tabsModel()}
|
||||
<!-- `tabs` goes undefined while the content remounts on another data table, and this
|
||||
block can still re-read it on its way out: every read below tolerates that. -->
|
||||
{#if tabs && mainPaneLeft}
|
||||
<!-- Floating tabs over the right pane, starting where it starts, and scrolling
|
||||
sideways before they would run into the actions. -->
|
||||
<div
|
||||
class="absolute inset-y-0 right-28 flex items-center gap-1 overflow-x-auto scrollbar-subtle"
|
||||
style:left="{mainPaneLeft}px"
|
||||
role="tablist"
|
||||
{@attach wheelScrollsSideways}
|
||||
>
|
||||
{#each tabs?.tabs ?? [] as tab (tab.id)}
|
||||
{@const active = tab.id === tabs?.activeId}
|
||||
{@const Icon = TAB_KINDS.find((k) => k.kind === tab.kind)!.icon}
|
||||
{@const dropSide = dropTarget?.id === tab.id ? dropTarget.side : undefined}
|
||||
<!-- Moved only on drop: moving the dragged element mid-drag makes the browser
|
||||
abandon the drag and snap it back. A bar shows where it will land. -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class={(active
|
||||
? 'group relative flex h-7 shrink-0 items-center rounded-md bg-surface-sunken text-xs font-medium text-emphasis'
|
||||
: 'group relative flex h-7 shrink-0 items-center rounded-md text-xs text-secondary hover:bg-surface-hover hover:text-primary') +
|
||||
(draggedTabId === tab.id ? ' opacity-50' : '')}
|
||||
draggable="true"
|
||||
animate:flip={{ duration: 150 }}
|
||||
ondragstart={(e) => {
|
||||
draggedTabId = tab.id
|
||||
e.dataTransfer?.setData('text/plain', tab.id)
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'
|
||||
}}
|
||||
ondragover={(e) => {
|
||||
if (!draggedTabId) return
|
||||
e.preventDefault()
|
||||
if (draggedTabId === tab.id) {
|
||||
dropTarget = undefined
|
||||
return
|
||||
}
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const side = e.clientX < rect.left + rect.width / 2 ? 'before' : 'after'
|
||||
if (dropTarget?.id !== tab.id || dropTarget.side !== side)
|
||||
dropTarget = { id: tab.id, side }
|
||||
}}
|
||||
ondrop={(e) => {
|
||||
e.preventDefault()
|
||||
if (draggedTabId && dropTarget)
|
||||
tabs?.move(draggedTabId, dropTarget.id, dropTarget.side)
|
||||
draggedTabId = undefined
|
||||
dropTarget = undefined
|
||||
}}
|
||||
ondragend={() => {
|
||||
draggedTabId = undefined
|
||||
dropTarget = undefined
|
||||
}}
|
||||
{@attach (el) => {
|
||||
if (active) el.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||
}}
|
||||
>
|
||||
{#if dropSide}
|
||||
<div
|
||||
class={'pointer-events-none absolute inset-y-1 w-0.5 rounded bg-surface-accent-primary ' +
|
||||
(dropSide === 'before' ? '-left-1' : '-right-1')}
|
||||
></div>
|
||||
{/if}
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
class="flex h-full items-center gap-1.5 pl-2.5 pr-1 max-w-48"
|
||||
title={tabLabel(tab)}
|
||||
onclick={() => tabs?.activate(tab.id)}
|
||||
>
|
||||
<Icon size={14} class="shrink-0" />
|
||||
<span class="truncate">{tabLabel(tab)}</span>
|
||||
</button>
|
||||
<button
|
||||
class={'mr-1 rounded p-0.5 text-hint hover:bg-surface-hover hover:text-primary ' +
|
||||
(active ? '' : 'opacity-0 group-hover:opacity-100')}
|
||||
title="Close tab"
|
||||
aria-label="Close {tabLabel(tab)} tab"
|
||||
onclick={() => tabs?.close(tab.id)}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
<DropdownV2
|
||||
enableFlyTransition
|
||||
items={TAB_KINDS.filter((k) => k.kind !== 'diagram' || diagramSupported).map((k) => ({
|
||||
displayName: k.label,
|
||||
icon: k.icon,
|
||||
action: () => {
|
||||
// A query opened from a data tab starts on that tab's table.
|
||||
const from = tabs?.active.kind === 'data' ? tabs.active : undefined
|
||||
tabs?.add(
|
||||
k.kind,
|
||||
k.kind === 'sql' && from?.table
|
||||
? {
|
||||
code: `SELECT * FROM ${quotedTableName(from.schema, from.table)}`
|
||||
}
|
||||
: {}
|
||||
)
|
||||
logFeatureUsage('db_manager', 'view_mode', { key: k.kind })
|
||||
}
|
||||
}))}
|
||||
btnId="db-manager-new-tab"
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<div
|
||||
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-secondary hover:bg-surface-hover hover:text-primary"
|
||||
title="New tab"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</div>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
<!-- A data table exports and imports from its row menu in the tree; a plain
|
||||
database has no tree row to hold them. -->
|
||||
{#if enableImportExport && !uriState.isDatatableInput}
|
||||
<Button startIcon={{ icon: Download }} onClick={() => handleExportSchema()}>Export</Button>
|
||||
<Button
|
||||
startIcon={{ icon: Upload }}
|
||||
onClick={() => ((importTarget = undefined), (importDrawerOpen = true))}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
{/if}
|
||||
{#if diagramSupported}
|
||||
<ToggleButtonGroup
|
||||
bind:selected={requestedViewMode}
|
||||
noWFull
|
||||
onSelected={(v) => logFeatureUsage('db_manager', 'view_mode', { key: v })}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="data" label="Data" icon={Table2} {item} />
|
||||
<ToggleButton value="diagram" label="Diagram" icon={Network} {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
{/if}
|
||||
{#if uriState.effectiveInput && ws}
|
||||
<DbWorkerTagButton
|
||||
bind:tag={() => workerTag.tag, (v) => (workerTag.tag = v)}
|
||||
input={uriState.effectiveInput}
|
||||
workspace={ws}
|
||||
/>
|
||||
{@const input = uriState.effectiveInput}
|
||||
<!-- The hidden tag button anchors its picker under this menu's trigger. -->
|
||||
<div class="flex items-center">
|
||||
<DropdownV2
|
||||
enableFlyTransition
|
||||
items={() => [
|
||||
{
|
||||
displayName: workerTag.tag ? `Worker tag: ${workerTag.tag}` : 'Worker tag',
|
||||
icon: Tag,
|
||||
action: () => (workerTagOpen = true)
|
||||
},
|
||||
// A data table exports and imports from its row menu in the tree; a
|
||||
// plain database has no tree row to hold them.
|
||||
...(enableImportExport && !uriState.isDatatableInput
|
||||
? [
|
||||
{
|
||||
displayName: 'Export database',
|
||||
icon: Download,
|
||||
action: () => handleExportSchema()
|
||||
},
|
||||
{
|
||||
displayName: 'Import database',
|
||||
icon: Upload,
|
||||
action: () => ((importTarget = undefined), (importDrawerOpen = true))
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]}
|
||||
btnId="db-manager-database-actions"
|
||||
/>
|
||||
<DbWorkerTagButton
|
||||
bind:tag={() => workerTag.tag, (v) => (workerTag.tag = v)}
|
||||
bind:open={workerTagOpen}
|
||||
hideTrigger
|
||||
{input}
|
||||
workspace={ws}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
loading={dbManagerContent?.isLoading() ?? false}
|
||||
on:click={refreshManager}
|
||||
@@ -416,13 +572,6 @@
|
||||
size="xs"
|
||||
color="light"
|
||||
/>
|
||||
|
||||
<Button
|
||||
on:click={() => (expand = !expand)}
|
||||
startIcon={{ icon: expand ? Minimize : Expand }}
|
||||
size="xs"
|
||||
color="light"
|
||||
/>
|
||||
{/snippet}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,362 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
AlignLeft,
|
||||
AlignRight,
|
||||
Bold,
|
||||
GripVertical,
|
||||
Italic,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
X
|
||||
} from 'lucide-svelte'
|
||||
import { Button } from './common'
|
||||
import Select from './select/Select.svelte'
|
||||
import TextInput from './text_input/TextInput.svelte'
|
||||
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
|
||||
import Popover from './meltComponents/Popover.svelte'
|
||||
import {
|
||||
formatValue,
|
||||
RULE_PRESETS,
|
||||
UNIT_PRESETS,
|
||||
type ColorRule,
|
||||
type ColumnFormat
|
||||
} from './dbTableFormat'
|
||||
|
||||
type Props = {
|
||||
column: string
|
||||
format: ColumnFormat | undefined
|
||||
onChange: (format: ColumnFormat) => void
|
||||
}
|
||||
let { column, format, onChange }: Props = $props()
|
||||
|
||||
const CUSTOM = 'custom'
|
||||
const AUTO_DECIMALS = -1
|
||||
|
||||
let unit = $derived(format?.unit)
|
||||
let rules = $derived(format?.rules ?? [])
|
||||
let unitIsPreset = $derived(
|
||||
!!unit && UNIT_PRESETS.some((p) => p.symbol === unit.symbol && p.position === unit.position)
|
||||
)
|
||||
// Kept apart from the format so that picking "Custom" shows the field before anything is typed.
|
||||
let customUnit = $state(false)
|
||||
let unitChoice = $derived(
|
||||
customUnit || (unit?.symbol && !unitIsPreset) ? CUSTOM : (unit?.symbol ?? '')
|
||||
)
|
||||
|
||||
function update(patch: Partial<ColumnFormat>) {
|
||||
onChange({ ...format, ...patch })
|
||||
}
|
||||
function setRule(i: number, patch: Partial<ColorRule>) {
|
||||
update({ rules: rules.map((r, j) => (j === i ? { ...r, ...patch } : r)) })
|
||||
}
|
||||
let addRuleOpen = $state(false)
|
||||
// Which rule's preview has its presets open.
|
||||
let restyling: number | undefined = $state()
|
||||
|
||||
// Order matters, a later rule overrides an earlier one: rules are dragged by their handle.
|
||||
let dragFrom: number | undefined = $state()
|
||||
let dropAt: { index: number; side: 'above' | 'below' } | undefined = $state()
|
||||
function dropRule() {
|
||||
if (dragFrom === undefined || !dropAt) return
|
||||
const moved = rules[dragFrom]
|
||||
const rest = rules.filter((_, j) => j !== dragFrom)
|
||||
let to = dropAt.index + (dropAt.side === 'below' ? 1 : 0)
|
||||
if (dragFrom < to) to--
|
||||
rest.splice(to, 0, moved)
|
||||
update({ rules: rest })
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet sample(style: { bg?: string; text?: string; bold?: boolean; italic?: boolean })}
|
||||
<span
|
||||
class="flex h-7 w-8 shrink-0 items-center justify-center rounded border"
|
||||
style:background-color={style.bg}
|
||||
style:color={style.text}
|
||||
style:font-weight={style.bold ? 600 : undefined}
|
||||
style:font-style={style.italic ? 'italic' : undefined}
|
||||
>
|
||||
Aa
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
{#snippet presets(onPick: (preset: (typeof RULE_PRESETS)[number]) => void)}
|
||||
<div class="grid grid-cols-9 gap-1 p-2">
|
||||
{#each RULE_PRESETS as preset, i (i)}
|
||||
<button
|
||||
class="rounded hover:ring-2 hover:ring-border-selected"
|
||||
title="Use these colors"
|
||||
onclick={() => onPick(preset)}
|
||||
>
|
||||
{@render sample(preset)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex w-96 flex-col gap-3 p-3 text-xs" data-testid="db-format-editor">
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="flex min-w-0 grow flex-col gap-0.5">
|
||||
<span class="truncate font-semibold text-emphasis">Format {column}</span>
|
||||
<span class="text-2xs text-secondary">Only changes how values show in this view.</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
startIcon={{ icon: RotateCcw }}
|
||||
disabled={!format}
|
||||
onClick={() => {
|
||||
customUnit = false
|
||||
onChange({})
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="font-medium text-secondary">Unit</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<Select
|
||||
class="w-24 shrink-0"
|
||||
size="sm"
|
||||
items={[
|
||||
{ label: 'None', value: '' },
|
||||
...UNIT_PRESETS.map((p) => ({
|
||||
label: p.symbol.trim(),
|
||||
value: p.symbol
|
||||
})),
|
||||
{ label: 'Custom', value: CUSTOM }
|
||||
]}
|
||||
bind:value={
|
||||
() => unitChoice,
|
||||
(v) => {
|
||||
customUnit = v === CUSTOM
|
||||
if (v === CUSTOM) update({ unit: { symbol: '', position: 'after' } })
|
||||
else update({ unit: UNIT_PRESETS.find((p) => p.symbol === v) })
|
||||
}
|
||||
}
|
||||
/>
|
||||
{#if unitChoice === CUSTOM}
|
||||
<div class="w-16 shrink-0">
|
||||
<TextInput
|
||||
size="sm"
|
||||
inputProps={{ placeholder: 'kg' }}
|
||||
bind:value={
|
||||
() => unit?.symbol ?? '',
|
||||
(v) => update({ unit: { symbol: v, position: unit?.position ?? 'after' } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<ToggleButtonGroup
|
||||
noWFull
|
||||
bind:selected={
|
||||
() => unit?.position ?? 'after',
|
||||
(v) => update({ unit: { symbol: unit?.symbol ?? '', position: v } })
|
||||
}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="before" label="Before" {item} small />
|
||||
<ToggleButton value="after" label="After" {item} small />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="font-medium text-secondary">Decimals</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<Select
|
||||
class="w-24 shrink-0"
|
||||
size="sm"
|
||||
items={[
|
||||
{ label: 'Auto', value: AUTO_DECIMALS },
|
||||
...Array.from({ length: 11 }, (_, i) => ({ label: String(i), value: i }))
|
||||
]}
|
||||
bind:value={
|
||||
() => format?.decimals ?? AUTO_DECIMALS,
|
||||
(v) => update({ decimals: v === AUTO_DECIMALS ? undefined : v })
|
||||
}
|
||||
/>
|
||||
<ToggleButtonGroup
|
||||
noWFull
|
||||
bind:selected={
|
||||
() => format?.notation ?? 'plain',
|
||||
(v) => update({ notation: v === 'plain' ? undefined : (v as 'thousands' | 'compact') })
|
||||
}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="plain" label="1000" tooltip="No separator" {item} small />
|
||||
<ToggleButton
|
||||
value="thousands"
|
||||
label="1,000"
|
||||
tooltip="Thousands separator"
|
||||
{item}
|
||||
small
|
||||
/>
|
||||
<ToggleButton
|
||||
value="compact"
|
||||
label="Compact"
|
||||
tooltip="Shorten large numbers: 35.4M"
|
||||
{item}
|
||||
small
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
<span class="text-2xs text-hint">
|
||||
1234567.891 shows as {formatValue(1234567.891, format) ?? '1234567.891'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="font-medium text-secondary">Alignment</span>
|
||||
<ToggleButtonGroup
|
||||
bind:selected={
|
||||
() => format?.align ?? 'auto',
|
||||
(v) => update({ align: v === 'auto' ? undefined : (v as 'left' | 'right') })
|
||||
}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="auto" label="Auto" {item} small />
|
||||
<ToggleButton value="left" label="Left" icon={AlignLeft} {item} small />
|
||||
<ToggleButton value="right" label="Right" icon={AlignRight} {item} small />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="font-medium text-secondary">Color rules</span>
|
||||
{#each rules as rule, i (i)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="relative flex items-center gap-1.5"
|
||||
data-testid="db-format-rule"
|
||||
ondragover={(e) => {
|
||||
if (dragFrom === undefined) return
|
||||
e.preventDefault()
|
||||
const r = e.currentTarget.getBoundingClientRect()
|
||||
dropAt = { index: i, side: e.clientY < r.top + r.height / 2 ? 'above' : 'below' }
|
||||
}}
|
||||
ondrop={(e) => {
|
||||
e.preventDefault()
|
||||
dropRule()
|
||||
}}
|
||||
>
|
||||
{#if dropAt?.index === i && dragFrom !== undefined}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 h-0.5 rounded bg-border-selected"
|
||||
class:-top-1={dropAt.side === 'above'}
|
||||
class:-bottom-1={dropAt.side === 'below'}
|
||||
></div>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="-mx-1 flex h-7 shrink-0 cursor-grab items-center text-hint hover:text-primary"
|
||||
title="Drag to reorder"
|
||||
draggable="true"
|
||||
ondragstart={(e) => {
|
||||
dragFrom = i
|
||||
const row = e.currentTarget.parentElement
|
||||
if (row) e.dataTransfer?.setDragImage(row, 8, row.offsetHeight / 2)
|
||||
}}
|
||||
ondragend={() => {
|
||||
dragFrom = undefined
|
||||
dropAt = undefined
|
||||
}}
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</div>
|
||||
<div class="min-w-0 grow">
|
||||
<TextInput
|
||||
size="sm"
|
||||
inputProps={{ placeholder: 'All cells, or >= 4, =paid' }}
|
||||
bind:value={() => rule.condition, (v) => setRule(i, { condition: v })}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="color"
|
||||
class="!h-7 !w-7 shrink-0 cursor-pointer rounded border bg-transparent !p-0.5"
|
||||
title="Background color"
|
||||
value={rule.bg ?? '#ffffff'}
|
||||
oninput={(e) => setRule(i, { bg: e.currentTarget.value })}
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
class="!h-7 !w-7 shrink-0 cursor-pointer rounded border bg-transparent !p-0.5"
|
||||
title="Text color"
|
||||
value={rule.text ?? '#000000'}
|
||||
oninput={(e) => setRule(i, { text: e.currentTarget.value })}
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
iconOnly
|
||||
startIcon={{ icon: Bold }}
|
||||
selected={!!rule.bold}
|
||||
title="Bold"
|
||||
onClick={() => setRule(i, { bold: !rule.bold || undefined })}
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
iconOnly
|
||||
startIcon={{ icon: Italic }}
|
||||
selected={!!rule.italic}
|
||||
title="Italic"
|
||||
onClick={() => setRule(i, { italic: !rule.italic || undefined })}
|
||||
/>
|
||||
<Popover
|
||||
contentClasses="z-[10001]"
|
||||
extraProps={{ 'data-db-format-popover': true }}
|
||||
floatingConfig={{ strategy: 'fixed', placement: 'bottom-end' }}
|
||||
bind:isOpen={() => restyling === i, (open) => (restyling = open ? i : undefined)}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<span title="Pick preset colors">{@render sample(rule)}</span>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
{@render presets((preset) => {
|
||||
setRule(i, { bg: preset.bg, text: preset.text })
|
||||
restyling = undefined
|
||||
})}
|
||||
{/snippet}
|
||||
</Popover>
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
iconOnly
|
||||
startIcon={{ icon: X }}
|
||||
title="Remove rule"
|
||||
onClick={() => update({ rules: rules.filter((_, j) => j !== i) })}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Popover
|
||||
contentClasses="z-[10001]"
|
||||
extraProps={{ 'data-db-format-popover': true }}
|
||||
floatingConfig={{ strategy: 'fixed', placement: 'bottom-start' }}
|
||||
bind:isOpen={addRuleOpen}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<Button variant="subtle" unifiedSize="sm" startIcon={{ icon: Plus }} nonCaptureEvent>
|
||||
Add rule
|
||||
</Button>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
{@render presets((preset) => {
|
||||
update({ rules: [...rules, { condition: '', ...preset }] })
|
||||
addRuleOpen = false
|
||||
})}
|
||||
{/snippet}
|
||||
</Popover>
|
||||
</div>
|
||||
<span class="text-2xs text-hint">
|
||||
Conditions use the search bar's syntax: text contains, =value is exact, numbers take >,
|
||||
>=, <, <= and !=. An empty condition matches every cell. Every matching rule applies,
|
||||
a later one overriding an earlier one.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,23 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { ArrowRight } from 'lucide-svelte'
|
||||
import Button from './common/button/Button.svelte'
|
||||
|
||||
type Props = {
|
||||
targetTable: string
|
||||
targetColumn: string
|
||||
onGoToRow: () => void
|
||||
}
|
||||
let { targetTable, targetColumn, onGoToRow }: Props = $props()
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-1.5 rounded-md border bg-surface-tertiary p-2 text-xs shadow-md"
|
||||
data-testid="db-fk-tooltip"
|
||||
>
|
||||
<div class="text-secondary">
|
||||
References <span class="font-mono text-primary">{targetTable}.{targetColumn}</span>
|
||||
</div>
|
||||
<Button unifiedSize="xs" variant="default" startIcon={{ icon: ArrowRight }} onClick={onGoToRow}>
|
||||
Go to row
|
||||
</Button>
|
||||
</div>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import SqlRepl from './SqlRepl.svelte'
|
||||
import DBTable from './DBTable.svelte'
|
||||
import type { DbInput } from './dbTypes'
|
||||
import type { DBSchema } from '$lib/stores'
|
||||
import { staticDbTableOps } from './staticDbTableOps'
|
||||
import { getDbType } from './dbOps'
|
||||
|
||||
type Props = {
|
||||
input: DbInput
|
||||
workspace: string
|
||||
tag?: string
|
||||
placeholderTableName?: string
|
||||
initialCode?: string
|
||||
onCodeChange?: (code: string) => void
|
||||
onSchemaChange?: () => void
|
||||
schema?: DBSchema
|
||||
}
|
||||
let {
|
||||
input,
|
||||
workspace,
|
||||
tag,
|
||||
placeholderTableName,
|
||||
initialCode,
|
||||
onCodeChange,
|
||||
onSchemaChange,
|
||||
schema
|
||||
}: Props = $props()
|
||||
|
||||
let result: Record<string, unknown>[] | undefined = $state.raw()
|
||||
let resultOps = $derived(
|
||||
result ? staticDbTableOps(result, getDbType(input), 'query_result') : undefined
|
||||
)
|
||||
</script>
|
||||
|
||||
<Splitpanes horizontal>
|
||||
<Pane class="relative" minSize={15}>
|
||||
<SqlRepl
|
||||
{input}
|
||||
{workspace}
|
||||
{tag}
|
||||
{placeholderTableName}
|
||||
{initialCode}
|
||||
{onCodeChange}
|
||||
{onSchemaChange}
|
||||
{schema}
|
||||
onData={(data) => (result = data)}
|
||||
/>
|
||||
</Pane>
|
||||
<Pane size={55} minSize={10} class="relative">
|
||||
{#if resultOps}
|
||||
<!-- Each result has its own columns: start its grid afresh. -->
|
||||
{#key resultOps}
|
||||
<DBTable dbTableOps={resultOps} />
|
||||
{/key}
|
||||
{:else}
|
||||
<div class="h-full w-full center-center bg-surface-tertiary text-xs text-hint">
|
||||
Run a query to see its result here
|
||||
</div>
|
||||
{/if}
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
@@ -12,24 +12,40 @@
|
||||
input: DbInput
|
||||
/** Workspace the custom tags are read from; defaults to the navigation one. */
|
||||
workspace?: string
|
||||
/** Render no button: the picker is opened through `open`, and pops up where this is placed. */
|
||||
hideTrigger?: boolean
|
||||
open?: boolean
|
||||
}
|
||||
|
||||
let { tag = $bindable(), input, workspace = undefined }: Props = $props()
|
||||
let {
|
||||
tag = $bindable(),
|
||||
input,
|
||||
workspace = undefined,
|
||||
hideTrigger = false,
|
||||
open = $bindable()
|
||||
}: Props = $props()
|
||||
|
||||
let defaultTag = $derived(getDefaultDbTag(input))
|
||||
</script>
|
||||
|
||||
<Popover floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}>
|
||||
<Popover
|
||||
floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}
|
||||
bind:isOpen={() => open ?? false, (v) => (open = v)}
|
||||
class={hideTrigger ? 'w-0 h-0 overflow-hidden' : undefined}
|
||||
triggerAttrs={hideTrigger ? { tabindex: -1, 'aria-hidden': true } : undefined}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<Button
|
||||
size="xs"
|
||||
color="light"
|
||||
startIcon={{ icon: Tag }}
|
||||
nonCaptureEvent
|
||||
title="Worker tag the database jobs run on"
|
||||
>
|
||||
{tag ?? 'Worker tag'}
|
||||
</Button>
|
||||
{#if !hideTrigger}
|
||||
<Button
|
||||
size="xs"
|
||||
color="light"
|
||||
startIcon={{ icon: Tag }}
|
||||
nonCaptureEvent
|
||||
title="Worker tag the database jobs run on"
|
||||
>
|
||||
{tag ?? 'Worker tag'}
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<div class="p-4 w-96">
|
||||
|
||||
@@ -141,6 +141,9 @@
|
||||
awareness?: any | undefined
|
||||
folding?: boolean
|
||||
args?: Record<string, any> | undefined
|
||||
/** Schema to complete SQL against when the caller already holds it; otherwise it is
|
||||
* looked up from `args.database`. */
|
||||
sqlSchema?: DBSchema | undefined
|
||||
useWebsockets?: boolean
|
||||
small?: boolean
|
||||
scriptLang: Preview['language'] | 'bunnative' | 'tsx' | 'jsx' | 'json' | undefined
|
||||
@@ -191,6 +194,7 @@
|
||||
awareness = undefined,
|
||||
folding = false,
|
||||
args = undefined,
|
||||
sqlSchema = undefined,
|
||||
useWebsockets = true,
|
||||
small = false,
|
||||
scriptLang,
|
||||
@@ -274,7 +278,8 @@
|
||||
const sessionScopedChatManager = getContext<AIChatManager | undefined>('aiChatManager')
|
||||
// let graphqlService: MonacoGraphQLAPI | undefined = undefined
|
||||
|
||||
let dbSchema: DBSchema | undefined = $state(undefined)
|
||||
let fetchedDbSchema: DBSchema | undefined = $state(undefined)
|
||||
let dbSchema: DBSchema | undefined = $derived(sqlSchema ?? fetchedDbSchema)
|
||||
|
||||
let destroyed = false
|
||||
const uri = computeUri(
|
||||
@@ -777,8 +782,8 @@
|
||||
async function updateSchema(newSchemaRes: string | undefined) {
|
||||
if (typeof newSchemaRes === 'string') {
|
||||
const resourcePath = newSchemaRes.replace('$res:', '')
|
||||
dbSchema = $dbSchemas[resourcePath]
|
||||
if (dbSchema === undefined) {
|
||||
fetchedDbSchema = $dbSchemas[resourcePath]
|
||||
if (fetchedDbSchema === undefined) {
|
||||
$dbSchemas[resourcePath] = await getDbSchemas(
|
||||
lang === 'graphql' ? 'graphql' : (scriptLang ?? ''),
|
||||
resourcePath,
|
||||
@@ -787,9 +792,9 @@
|
||||
{ customTag }
|
||||
)
|
||||
}
|
||||
dbSchema = $dbSchemas[resourcePath]
|
||||
fetchedDbSchema = $dbSchemas[resourcePath]
|
||||
} else {
|
||||
dbSchema = undefined
|
||||
fetchedDbSchema = undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -824,6 +829,9 @@
|
||||
sqlSchemaCompletor = languages.registerCompletionItemProvider('sql', {
|
||||
triggerCharacters: ['.', ' ', '('],
|
||||
provideCompletionItems: function (model, position) {
|
||||
// Registered for the whole `sql` language: every other mounted SQL editor
|
||||
// would otherwise add its own schema's suggestions to this one's.
|
||||
if (model !== editor?.getModel()) return { suggestions: [] }
|
||||
const textUntilPosition = model.getValueInRange({
|
||||
startLineNumber: 1,
|
||||
startColumn: 1,
|
||||
|
||||
@@ -339,6 +339,14 @@
|
||||
hideDropdownOnFreeText?: boolean
|
||||
// Notified whenever the dropdown's effective visibility changes
|
||||
onDropdownVisibleChange?: (visible: boolean) => void
|
||||
size?: 'md' | 'lg'
|
||||
// On the right the icon gives way to the clear button once there is text; on the left
|
||||
// it stays put.
|
||||
searchIconPosition?: 'left' | 'right'
|
||||
// Caps the suggestions dropdown, which otherwise matches the searchbar's width.
|
||||
dropdownMaxWidth?: number
|
||||
// Gap in px between the searchbar and the dropdown below it.
|
||||
dropdownOffsetY?: number
|
||||
}
|
||||
|
||||
type SchemaT = FilterSchemaRec // TODO: Generic
|
||||
@@ -351,7 +359,11 @@
|
||||
autofocus,
|
||||
hideDropdownOnFreeText = false,
|
||||
onDropdownVisibleChange,
|
||||
inputId
|
||||
inputId,
|
||||
size = 'md',
|
||||
searchIconPosition = 'right',
|
||||
dropdownMaxWidth,
|
||||
dropdownOffsetY = 0
|
||||
}: Props<SchemaT> = $props()
|
||||
|
||||
let _value = new DebouncedTempValue(
|
||||
@@ -702,7 +714,7 @@
|
||||
class={twMerge(
|
||||
'flex items-center rounded-md bg-surface-input overflow-clip',
|
||||
inputBorderClass({ error: errors.length > 0, forceFocus: open }),
|
||||
ButtonType.UnifiedHeightClasses.md,
|
||||
ButtonType.UnifiedHeightClasses[size],
|
||||
className
|
||||
)}
|
||||
onmousedown={(e) => {
|
||||
@@ -716,6 +728,9 @@
|
||||
}}
|
||||
bind:this={inputElement}
|
||||
>
|
||||
{#if searchIconPosition === 'left'}
|
||||
<SearchIcon size={16} class="ml-2.5 shrink-0 text-hint" />
|
||||
{/if}
|
||||
<TaggedTextInput
|
||||
bind:this={taggedTextInput}
|
||||
id={inputId}
|
||||
@@ -732,7 +747,10 @@
|
||||
class={twMerge(
|
||||
'overflow-x-auto !pr-24 bg-surface-input outline-none scrollbar-hidden text-nowrap flex-1 mr-2 mt-0.5',
|
||||
inputBaseClass,
|
||||
inputSizeClasses.md
|
||||
inputSizeClasses[size],
|
||||
// The editable's fixed top padding centres the text at md height.
|
||||
size === 'lg' && '!pt-[0.7rem]',
|
||||
searchIconPosition === 'left' && '!pl-1.5'
|
||||
)}
|
||||
{placeholder}
|
||||
onKeyDown={(e) => {
|
||||
@@ -752,7 +770,7 @@
|
||||
/>
|
||||
{#if asText.val}
|
||||
<CloseButton small class="mr-1.5" onClick={() => (_value.current = {})} />
|
||||
{:else}
|
||||
{:else if searchIconPosition === 'right'}
|
||||
<div class="mr-3">
|
||||
<SearchIcon size={16} class="text-hint" />
|
||||
</div>
|
||||
@@ -762,7 +780,11 @@
|
||||
<GenericDropdown
|
||||
open={dropdownVisible}
|
||||
instantClose={hideDropdownOnFreeText}
|
||||
getInputRect={() => inputElement?.getBoundingClientRect() ?? new DOMRect()}
|
||||
getInputRect={() => {
|
||||
const r = inputElement?.getBoundingClientRect() ?? new DOMRect()
|
||||
const width = dropdownMaxWidth ? Math.min(r.width, dropdownMaxWidth) : r.width
|
||||
return new DOMRect(r.x, r.y, width, r.height + dropdownOffsetY)
|
||||
}}
|
||||
innerClass="!max-h-[25rem]"
|
||||
strictWidth
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { CornerDownLeft, Loader2 } from 'lucide-svelte'
|
||||
import { CornerDownLeft, History, Loader2, X } from 'lucide-svelte'
|
||||
import Button from './common/button/Button.svelte'
|
||||
import { runScriptAndPollResult } from './jobs/utils'
|
||||
import { writingJobOptions } from './jobs/writingJob'
|
||||
@@ -7,9 +7,9 @@
|
||||
import { untrack } from 'svelte'
|
||||
import { getLanguageByResourceType } from './apps/components/display/dbtable/utils'
|
||||
import StepHistory, { type StepHistoryData } from './flows/propPicker/StepHistory.svelte'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { getDatabaseArg, getDbType } from './dbOps'
|
||||
import type { DbInput } from './dbTypes'
|
||||
import type { DBSchema } from '$lib/stores'
|
||||
import { wrapDucklakeQuery } from './ducklake'
|
||||
import { splitSqlStatements, pruneComments } from './sqlDdl'
|
||||
import DdlMigrationGuard from './DdlMigrationGuard.svelte'
|
||||
@@ -32,6 +32,11 @@
|
||||
workspace?: string | undefined
|
||||
/** Worker tag the queries run on instead of the language's native one. */
|
||||
tag?: string | undefined
|
||||
/** Editor content to start from instead of the placeholder query. */
|
||||
initialCode?: string
|
||||
onCodeChange?: (code: string) => void
|
||||
/** The database's schema, for completing table and column names. */
|
||||
schema?: DBSchema
|
||||
}
|
||||
let {
|
||||
input,
|
||||
@@ -39,7 +44,10 @@
|
||||
placeholderTableName,
|
||||
onSchemaChange,
|
||||
workspace = undefined,
|
||||
tag = undefined
|
||||
tag = undefined,
|
||||
initialCode,
|
||||
onCodeChange,
|
||||
schema
|
||||
}: Props = $props()
|
||||
let ws = $derived(workspace ?? $operatingWorkspace)
|
||||
let dbType = $derived(getDbType(input))
|
||||
@@ -54,7 +62,7 @@
|
||||
let ddlGuard = $state<DdlMigrationGuard | undefined>(undefined)
|
||||
|
||||
const DEFAULT_SQL = 'SELECT * FROM _'
|
||||
let code = $state(DEFAULT_SQL)
|
||||
let code = $state(untrack(() => initialCode) ?? DEFAULT_SQL)
|
||||
|
||||
/** Seed the editor from outside, e.g. a query composed in a drawer. */
|
||||
export function setCode(newCode: string) {
|
||||
@@ -187,42 +195,74 @@
|
||||
}
|
||||
}
|
||||
let editor = $state<any | null>(null)
|
||||
let historyOpen = $state(true)
|
||||
</script>
|
||||
|
||||
<Splitpanes>
|
||||
<Pane class="relative">
|
||||
{#await import('$lib/components/Editor.svelte')}
|
||||
<Loader2 class="animate-spin" />
|
||||
{:then Module}
|
||||
<Module.default
|
||||
bind:this={editor}
|
||||
bind:code
|
||||
scriptLang="mysql"
|
||||
class="w-full h-full"
|
||||
cmdEnterAction={run}
|
||||
/>
|
||||
{/await}
|
||||
<Button
|
||||
wrapperClasses="absolute z-10 bottom-2 right-6"
|
||||
variant="accent"
|
||||
destructive={isRunning}
|
||||
shortCut={{ Icon: CornerDownLeft }}
|
||||
on:click={() => run()}
|
||||
>
|
||||
{isRunning ? 'Running...' : 'Run'}
|
||||
</Button>
|
||||
</Pane>
|
||||
<Pane size={24} minSize={16}>
|
||||
<StepHistory
|
||||
staticInputs={runHistory}
|
||||
on:select={(e) => {
|
||||
const data = e.detail as (typeof runHistory)[number]
|
||||
editor?.setCode(data.code)
|
||||
onData(data.result, data.code)
|
||||
}}
|
||||
<div class="relative h-full w-full">
|
||||
{#await import('$lib/components/Editor.svelte')}
|
||||
<Loader2 class="animate-spin" />
|
||||
{:then Module}
|
||||
<Module.default
|
||||
bind:this={editor}
|
||||
bind:code={() => code, (v) => ((code = v), onCodeChange?.(v))}
|
||||
scriptLang="mysql"
|
||||
sqlSchema={schema}
|
||||
class="w-full h-full"
|
||||
cmdEnterAction={run}
|
||||
/>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
{/await}
|
||||
<Button
|
||||
wrapperClasses="absolute z-10 bottom-2 right-6"
|
||||
variant="accent"
|
||||
destructive={isRunning}
|
||||
shortCut={{ Icon: CornerDownLeft }}
|
||||
on:click={() => run()}
|
||||
>
|
||||
{isRunning ? 'Running...' : 'Run'}
|
||||
</Button>
|
||||
<!-- Floats over the editor rather than taking a pane of its own, so the query keeps the
|
||||
full width. -->
|
||||
{#if historyOpen}
|
||||
<div
|
||||
class="absolute right-6 top-3 z-10 flex h-96 w-72 flex-col overflow-hidden rounded-lg border bg-surface-tertiary shadow-md"
|
||||
style="max-height: calc(100% - 4.5rem)"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b py-1 pl-3 pr-1">
|
||||
<span class="text-xs font-semibold text-emphasis">Run history</span>
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
iconOnly
|
||||
startIcon={{ icon: X }}
|
||||
title="Hide run history"
|
||||
onClick={() => (historyOpen = false)}
|
||||
/>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto">
|
||||
<StepHistory
|
||||
staticInputs={runHistory}
|
||||
on:select={(e) => {
|
||||
const data = e.detail as (typeof runHistory)[number]
|
||||
editor?.setCode(data.code)
|
||||
onData(data.result, data.code)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="absolute right-6 top-3 z-10">
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
iconOnly
|
||||
btnClasses="bg-surface-tertiary"
|
||||
startIcon={{ icon: History }}
|
||||
title="Show run history"
|
||||
onClick={() => (historyOpen = true)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if datatableName && ws}
|
||||
<DdlMigrationGuard
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Drawer, DrawerContent, Button } from '$lib/components/common'
|
||||
import { Drawer, DrawerContent, Button, ButtonType } from '$lib/components/common'
|
||||
import { CornerDownLeft, Plus } from 'lucide-svelte'
|
||||
import InsertRow from './dbtable/InsertRow.svelte'
|
||||
import type { ColumnDef } from './dbtable/utils'
|
||||
@@ -9,9 +9,11 @@
|
||||
columnDefs: ColumnDef[]
|
||||
dbType: DbType
|
||||
onInsert: (args: Record<string, any>) => void | Promise<void>
|
||||
/** Sizes the trigger on the unified scale; without it the trigger keeps its legacy size. */
|
||||
unifiedSize?: ButtonType.UnifiedSize
|
||||
}
|
||||
|
||||
let { columnDefs, dbType, onInsert }: Props = $props()
|
||||
let { columnDefs, dbType, onInsert, unifiedSize }: Props = $props()
|
||||
|
||||
let args: Record<string, any> = $state({})
|
||||
let insertDrawer: Drawer | undefined = $state()
|
||||
@@ -35,7 +37,8 @@
|
||||
<Button
|
||||
startIcon={{ icon: Plus }}
|
||||
variant="default"
|
||||
size="xs2"
|
||||
size={unifiedSize ? undefined : 'xs2'}
|
||||
{unifiedSize}
|
||||
on:click={() => {
|
||||
args = {}
|
||||
insertDrawer?.openDrawer()
|
||||
|
||||
@@ -386,6 +386,31 @@ export function renderDbEqualityFilter(
|
||||
return `${renderDbQuotedIdentifier(column, dbType)} = ${literal}`
|
||||
}
|
||||
|
||||
/** Case-insensitive "column contains `term`" predicate. The column is cast to
|
||||
* text first so it applies to any type (uuid, dates, json…). The term is
|
||||
* matched with a position function rather than LIKE so `%` and `_` in it stay
|
||||
* literal. */
|
||||
export function renderDbContainsFilter(column: string, term: string, dbType: DbType): string {
|
||||
const quoted = renderDbQuotedIdentifier(column, dbType)
|
||||
const literal = renderDbLiteral(term.toLowerCase(), dbType)!
|
||||
switch (dbType) {
|
||||
case 'postgresql':
|
||||
return `strpos(lower(CAST(${quoted} AS TEXT)), ${literal}) > 0`
|
||||
case 'mysql':
|
||||
return `LOCATE(${literal}, LOWER(CAST(${quoted} AS CHAR))) > 0`
|
||||
case 'ms_sql_server':
|
||||
return `CHARINDEX(${literal}, LOWER(CAST(${quoted} AS NVARCHAR(MAX)))) > 0`
|
||||
case 'snowflake':
|
||||
return `CONTAINS(LOWER(TO_VARCHAR(${quoted})), ${literal})`
|
||||
case 'duckdb':
|
||||
return `contains(lower(CAST(${quoted} AS VARCHAR)), ${literal})`
|
||||
case 'bigquery':
|
||||
return `STRPOS(LOWER(CAST(${quoted} AS STRING)), ${literal}) > 0`
|
||||
default:
|
||||
throw new Error('Unsupported database type: ' + dbType)
|
||||
}
|
||||
}
|
||||
|
||||
export function getLanguageByResourceType(name: string): ScriptLang {
|
||||
const language = {
|
||||
postgresql: 'postgresql',
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
class?: string
|
||||
onItemClick?: (item: ContextMenuItem) => void
|
||||
closeOnOutsideClick?: boolean
|
||||
/** Tailwind z-index class of the menu; raise it above a drawer the trigger sits in. */
|
||||
zIndex?: string
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -41,7 +43,8 @@
|
||||
menu,
|
||||
class: className = '',
|
||||
onItemClick,
|
||||
closeOnOutsideClick = true
|
||||
closeOnOutsideClick = true,
|
||||
zIndex
|
||||
}: Props = $props()
|
||||
|
||||
// Overlays belong to the enclosing pane when there is one — see overlayHost.
|
||||
@@ -110,7 +113,7 @@
|
||||
|
||||
{#if $open}
|
||||
<div
|
||||
class="{getContextMenuContainerClass()} {CONTEXT_MENU_ANIMATION_CLASSES}"
|
||||
class="{getContextMenuContainerClass(zIndex)} {CONTEXT_MENU_ANIMATION_CLASSES}"
|
||||
use:melt={$menuElement}
|
||||
transition:fly={{ duration: 150, y: -10 }}
|
||||
data-context-menu
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
class:open
|
||||
class:close={!open && timeout}
|
||||
class:global-chat-open={aiChatOpen}
|
||||
style={`${style}; --zIndex: ${zIndex}; --adjusted-offset: calc(${aiChatOpen && placement === 'right' && !disableChatOffset ? chatState.size : 0}% + 4px)`}
|
||||
style={`${style}; --zIndex: ${zIndex}; --adjusted-offset: calc(${aiChatOpen && (placement === 'right' || placement === 'center') && !disableChatOffset ? chatState.size : 0}% + 4px)`}
|
||||
>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
@@ -272,4 +272,28 @@
|
||||
.drawer.open > .panel {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
/* A modal inset from every edge, so what is behind it shows around it. `size` does not
|
||||
apply: the inset alone sizes it. */
|
||||
.panel.center {
|
||||
inset: 2rem;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transform: scale(0.98);
|
||||
transition:
|
||||
opacity var(--duration) ease,
|
||||
transform var(--duration) ease;
|
||||
}
|
||||
|
||||
.drawer.respect-global-chat.global-chat-open > .panel.center {
|
||||
right: calc(var(--adjusted-offset) + 2rem);
|
||||
}
|
||||
|
||||
.drawer.open > .panel.center {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import { classNames } from '$lib/utils'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import CloseButton from '../CloseButton.svelte'
|
||||
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
|
||||
import { createEventDispatcher, getContext } from 'svelte'
|
||||
@@ -32,6 +33,8 @@
|
||||
bannerReserved?: boolean
|
||||
/** For content rendered in place of a drawer, which has nothing to close. */
|
||||
hideClose?: boolean
|
||||
/** Merged over the title's classes, e.g. to shrink it. */
|
||||
titleClass?: string
|
||||
children?: import('svelte').Snippet
|
||||
}
|
||||
|
||||
@@ -53,6 +56,7 @@
|
||||
banner,
|
||||
bannerReserved = false,
|
||||
hideClose = false,
|
||||
titleClass = undefined,
|
||||
children
|
||||
}: Props = $props()
|
||||
|
||||
@@ -74,7 +78,7 @@
|
||||
>
|
||||
<div
|
||||
class={classNames(
|
||||
'flex justify-between w-full items-center pr-4 py-2 gap-2',
|
||||
'relative flex justify-between w-full items-center pr-4 py-2 gap-2',
|
||||
hideClose ? 'pl-4' : 'pl-2'
|
||||
)}
|
||||
>
|
||||
@@ -92,7 +96,7 @@
|
||||
<CloseButton on:close Icon={CloseIcon} id="{id}-close-btn" />
|
||||
</div>
|
||||
{/if}
|
||||
<span class="font-semibold text-emphasis truncate text-lg max-w-sm"
|
||||
<span class={twMerge('font-semibold text-emphasis truncate text-lg max-w-sm', titleClass)}
|
||||
>{title ?? ''}
|
||||
{#if tooltip != '' || documentationLink}
|
||||
<Tooltip {documentationLink}>{tooltip}</Tooltip>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from 'zod'
|
||||
import { useSearchParams } from '$lib/svelte5UtilsKit.svelte'
|
||||
import type { DbInput, DbType } from './dbTypes'
|
||||
import { isDbType } from './dbTypes'
|
||||
import { DB_TABLE_LAYOUT_PREFIX } from './dbTableLayoutStorage'
|
||||
|
||||
/**
|
||||
* Single URL param `dbm` encodes the full DB manager state:
|
||||
@@ -123,6 +124,50 @@ export function buildDbm(p: ParsedDbm): string {
|
||||
return segments.join('~')
|
||||
}
|
||||
|
||||
/** The `dbm` fields an input designates, before any schema or table is picked from it. */
|
||||
export function dbmOfInput(input: DbInput): ParsedDbm {
|
||||
if (input.type === 'ducklake') {
|
||||
return {
|
||||
type: 'ducklake',
|
||||
path: input.ducklake,
|
||||
schema: input.specificSchema,
|
||||
table: input.specificTable
|
||||
}
|
||||
}
|
||||
const isDatatable = input.resourcePath.startsWith('datatable://')
|
||||
return {
|
||||
type: isDatatable ? 'datatable' : 'database',
|
||||
path: isDatatable ? input.resourcePath.slice('datatable://'.length) : input.resourcePath,
|
||||
resType: isDatatable ? undefined : input.resourceType,
|
||||
role: isDatatable ? input.role : undefined,
|
||||
schema: input.specificSchema,
|
||||
table: input.specificTable
|
||||
}
|
||||
}
|
||||
|
||||
/** localStorage key of a table's grid layout: the table as the `dbm` URL param names it, in
|
||||
* its workspace. The role is left out, since connecting as another role is the same table. */
|
||||
export function dbTableLayoutStorageKey(
|
||||
workspace: string,
|
||||
input: DbInput,
|
||||
schema: string | undefined,
|
||||
table: string
|
||||
): string {
|
||||
return `${DB_TABLE_LAYOUT_PREFIX}${workspace}:${buildDbm({ ...dbmOfInput(input), role: undefined, schema, table })}`
|
||||
}
|
||||
|
||||
/** localStorage key of the DB manager's tabs over a database, named as the `dbm` URL param
|
||||
* names that database: no role, schema or table. */
|
||||
export function dbManagerTabsStorageKey(workspace: string, input: DbInput): string {
|
||||
const dbm = buildDbm({
|
||||
...dbmOfInput(input),
|
||||
role: undefined,
|
||||
schema: undefined,
|
||||
table: undefined
|
||||
})
|
||||
return `dbManagerTabs:${workspace}:${dbm}`
|
||||
}
|
||||
|
||||
export interface DbManagerUriState {
|
||||
readonly input: DbInput | undefined
|
||||
readonly effectiveInput: DbInput | undefined
|
||||
@@ -183,24 +228,7 @@ export function useDbManagerUriState(): DbManagerUriState {
|
||||
|
||||
function openDrawer(nInput: DbInput, ws?: string) {
|
||||
workspace = ws
|
||||
if (nInput.type === 'database') {
|
||||
const isDatatable = nInput.resourcePath.startsWith('datatable://')
|
||||
params.dbm = buildDbm({
|
||||
type: isDatatable ? 'datatable' : 'database',
|
||||
path: isDatatable ? nInput.resourcePath.slice('datatable://'.length) : nInput.resourcePath,
|
||||
resType: isDatatable ? undefined : nInput.resourceType,
|
||||
role: isDatatable ? nInput.role : undefined,
|
||||
schema: nInput.specificSchema,
|
||||
table: nInput.specificTable
|
||||
})
|
||||
} else {
|
||||
params.dbm = buildDbm({
|
||||
type: 'ducklake',
|
||||
path: nInput.ducklake,
|
||||
schema: nInput.specificSchema,
|
||||
table: nInput.specificTable
|
||||
})
|
||||
}
|
||||
params.dbm = buildDbm(dbmOfInput(nInput))
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { randomUUID } from '$lib/utils/uuid'
|
||||
import { getLocalSetting, storeLocalSetting } from '$lib/utils'
|
||||
|
||||
export type DbManagerTabKind = 'data' | 'diagram' | 'sql'
|
||||
|
||||
export type DbManagerDiagramTable = { datatable?: string; schema: string; table: string }
|
||||
|
||||
export type DbManagerTab =
|
||||
| { id: string; kind: 'data'; schema?: string; table?: string }
|
||||
/** `tables` stays undefined until the diagram's first draw, automatic or by hand. */
|
||||
| { id: string; kind: 'diagram'; tables?: DbManagerDiagramTable[] }
|
||||
| { id: string; kind: 'sql'; code?: string }
|
||||
|
||||
export type DbManagerDataTab = Extract<DbManagerTab, { kind: 'data' }>
|
||||
|
||||
type StoredTabs = { tabs: DbManagerTab[]; activeId: string }
|
||||
|
||||
/**
|
||||
* The DB manager's tabs over one database, kept in localStorage under `storageKey`. What a tab
|
||||
* shows is stored (its table, the diagram's tables, the query); how it was left — scroll,
|
||||
* filters, results — lives only in its mounted view.
|
||||
*/
|
||||
export class DbManagerTabs {
|
||||
tabs = $state<DbManagerTab[]>([])
|
||||
activeId = $state('')
|
||||
/** Tabs by last use, most recent first: where closing the active tab goes back to, and which
|
||||
* data tab the tree opens a table in when the active tab is not one. */
|
||||
#recency = $state<string[]>([])
|
||||
private storageKey: string | undefined
|
||||
private kinds: DbManagerTabKind[]
|
||||
|
||||
constructor(storageKey: string | undefined, kinds: DbManagerTabKind[]) {
|
||||
this.storageKey = storageKey
|
||||
this.kinds = kinds
|
||||
const stored = this.#load()
|
||||
this.tabs = stored?.tabs.length
|
||||
? stored.tabs
|
||||
: kinds.map((kind) => ({ id: randomUUID(), kind }) as DbManagerTab)
|
||||
this.activeId = this.tabs.some((t) => t.id === stored?.activeId)
|
||||
? stored!.activeId
|
||||
: this.tabs[0].id
|
||||
this.#recency = this.tabs.map((t) => t.id)
|
||||
this.#touch(this.activeId)
|
||||
}
|
||||
|
||||
get active(): DbManagerTab {
|
||||
return this.tabs.find((t) => t.id === this.activeId) ?? this.tabs[0]
|
||||
}
|
||||
|
||||
/** The data tab the tree's selection belongs to: the active one, else the last used. */
|
||||
get currentData(): DbManagerDataTab | undefined {
|
||||
const id = this.#recency.find((r) => this.tabs.find((t) => t.id === r)?.kind === 'data')
|
||||
return this.tabs.find((t): t is DbManagerDataTab => t.id === id && t.kind === 'data')
|
||||
}
|
||||
|
||||
activate(id: string) {
|
||||
if (!this.tabs.some((t) => t.id === id)) return
|
||||
this.activeId = id
|
||||
this.#touch(id)
|
||||
this.#save()
|
||||
}
|
||||
|
||||
add(kind: DbManagerTabKind, init: Partial<DbManagerTab> = {}): DbManagerTab {
|
||||
const tab = { ...init, id: randomUUID(), kind } as DbManagerTab
|
||||
this.tabs.push(tab)
|
||||
this.activate(tab.id)
|
||||
return tab
|
||||
}
|
||||
|
||||
/** Closing the last tab leaves a fresh data tab: the manager always has somewhere to show rows. */
|
||||
close(id: string) {
|
||||
const index = this.tabs.findIndex((t) => t.id === id)
|
||||
if (index === -1) return
|
||||
this.tabs.splice(index, 1)
|
||||
this.#recency = this.#recency.filter((r) => r !== id)
|
||||
if (!this.tabs.length) {
|
||||
this.add('data')
|
||||
return
|
||||
}
|
||||
if (this.activeId === id) this.activate(this.#recency[0] ?? this.tabs[0].id)
|
||||
else this.#save()
|
||||
}
|
||||
|
||||
/** Moves a tab next to another, on the given side of it. */
|
||||
move(id: string, targetId: string, side: 'before' | 'after') {
|
||||
if (id === targetId) return
|
||||
const from = this.tabs.findIndex((t) => t.id === id)
|
||||
if (from === -1) return
|
||||
const [tab] = this.tabs.splice(from, 1)
|
||||
const target = this.tabs.findIndex((t) => t.id === targetId)
|
||||
if (target === -1) {
|
||||
this.tabs.splice(from, 0, tab)
|
||||
return
|
||||
}
|
||||
this.tabs.splice(side === 'before' ? target : target + 1, 0, tab)
|
||||
this.#save()
|
||||
}
|
||||
|
||||
update<T extends DbManagerTab>(id: string, patch: Partial<Omit<T, 'id' | 'kind'>>) {
|
||||
const tab = this.tabs.find((t) => t.id === id)
|
||||
if (!tab) return
|
||||
if (Object.entries(patch).every(([k, v]) => (tab as any)[k] === v)) return
|
||||
Object.assign(tab, patch)
|
||||
this.#save()
|
||||
}
|
||||
|
||||
/** The current data tab, activated; one is opened when none is left. */
|
||||
focusData(): DbManagerDataTab {
|
||||
const tab = this.currentData ?? (this.add('data') as DbManagerDataTab)
|
||||
if (this.activeId !== tab.id) this.activate(tab.id)
|
||||
return tab
|
||||
}
|
||||
|
||||
/** A table named from outside (the URL): shown in the data tab already on it, else in the
|
||||
* current data tab. */
|
||||
openTable(schema: string | undefined, table: string) {
|
||||
const open = this.tabs.find(
|
||||
(t): t is DbManagerDataTab => t.kind === 'data' && t.table === table && t.schema === schema
|
||||
)
|
||||
if (open) {
|
||||
this.activate(open.id)
|
||||
return
|
||||
}
|
||||
this.update<DbManagerDataTab>(this.focusData().id, { schema, table })
|
||||
}
|
||||
|
||||
#touch(id: string) {
|
||||
this.#recency = [id, ...this.#recency.filter((r) => r !== id)]
|
||||
}
|
||||
|
||||
#load(): StoredTabs | undefined {
|
||||
if (!this.storageKey) return undefined
|
||||
try {
|
||||
const parsed = JSON.parse(getLocalSetting(this.storageKey) ?? 'null')
|
||||
if (!parsed || !Array.isArray(parsed.tabs)) return undefined
|
||||
// A kind this database cannot show, e.g. a diagram saved before it became unsupported.
|
||||
const tabs = (parsed.tabs as DbManagerTab[]).filter(
|
||||
(t) => t && typeof t.id === 'string' && this.kinds.includes(t.kind)
|
||||
)
|
||||
return { tabs, activeId: parsed.activeId }
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
#save() {
|
||||
if (!this.storageKey) return
|
||||
try {
|
||||
const stored: StoredTabs = { tabs: $state.snapshot(this.tabs), activeId: this.activeId }
|
||||
storeLocalSetting(this.storageKey, JSON.stringify(stored))
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type RawForeignKey
|
||||
} from './apps/components/display/dbtable/queries/relationalKeys'
|
||||
import { groupForeignKeyRows, type DbRelation, type RawAllForeignKeyRow } from './dbRelations'
|
||||
import { joinedColumnDef, joinsPayload, type DbTableJoin } from './dbTableJoins'
|
||||
|
||||
export type IDbTableOps = {
|
||||
dbType: DbType
|
||||
@@ -37,8 +38,22 @@ export type IDbTableOps = {
|
||||
quicksearch: string
|
||||
order_by: string
|
||||
is_desc: boolean
|
||||
/** Raw SQL predicate AND-ed into this read (already escaped). */
|
||||
whereClause?: string
|
||||
/** The same filters as `whereClause`, column → value, for a source that filters rows
|
||||
* itself. */
|
||||
columnFilters?: Record<string, unknown>
|
||||
/** Whether `order_by` was picked by the user rather than defaulted to the first column. */
|
||||
explicitSort?: boolean
|
||||
/** Columns of referenced tables to read beside the table's own. */
|
||||
joins?: DbTableJoin[]
|
||||
}) => Promise<unknown[]>
|
||||
getCount: (params: { quicksearch: string }) => Promise<number>
|
||||
getCount: (params: {
|
||||
quicksearch: string
|
||||
whereClause?: string
|
||||
columnFilters?: Record<string, unknown>
|
||||
joins?: DbTableJoin[]
|
||||
}) => Promise<number>
|
||||
onUpdate?: (
|
||||
row: { values: object },
|
||||
colDef: { field: string; datatype: string },
|
||||
@@ -81,16 +96,29 @@ export function dbTableOpsWithPreviewScripts({
|
||||
if (ducklake) payload.ducklake = ducklake
|
||||
return `-- WM_INTERNAL_DB_${op} ${JSON.stringify(payload)}`
|
||||
}
|
||||
// A joined column is read as one more column of the table, named by its alias.
|
||||
function readColumns(joins: DbTableJoin[] | undefined) {
|
||||
if (!joins?.length) return { columnDefs: colDefs }
|
||||
return {
|
||||
columnDefs: [...colDefs, ...joins.map(joinedColumnDef)],
|
||||
joins: joinsPayload(joins)
|
||||
}
|
||||
}
|
||||
function combinedWhere(extra: string | undefined): string | undefined {
|
||||
if (!whereClause || !extra) return whereClause || extra || undefined
|
||||
return `(${whereClause}) AND (${extra})`
|
||||
}
|
||||
|
||||
return {
|
||||
dbType,
|
||||
tableKey,
|
||||
colDefs,
|
||||
getCount: async ({ quicksearch }) => {
|
||||
getCount: async ({ quicksearch, whereClause: extraWhere, joins }) => {
|
||||
const where = combinedWhere(extraWhere)
|
||||
const content = makeMarker('COUNT', {
|
||||
table: tableKey,
|
||||
columnDefs: colDefs,
|
||||
...(whereClause ? { whereClause } : {}),
|
||||
...readColumns(joins),
|
||||
...(where ? { whereClause: where } : {}),
|
||||
...(version != undefined ? { version } : {})
|
||||
})
|
||||
const result = await runScriptAndPollResult({
|
||||
@@ -100,12 +128,19 @@ export function dbTableOpsWithPreviewScripts({
|
||||
const count = result?.[0].count as number
|
||||
return count
|
||||
},
|
||||
getRows: async (params) => {
|
||||
getRows: async ({
|
||||
whereClause: extraWhere,
|
||||
columnFilters: _,
|
||||
explicitSort: __,
|
||||
joins,
|
||||
...params
|
||||
}) => {
|
||||
const where = combinedWhere(extraWhere)
|
||||
const content = makeMarker('SELECT', {
|
||||
table: tableKey,
|
||||
columnDefs: colDefs,
|
||||
...readColumns(joins),
|
||||
fixPgIntTypes: true,
|
||||
...(whereClause ? { whereClause } : {}),
|
||||
...(where ? { whereClause: where } : {}),
|
||||
...(version != undefined ? { version } : {})
|
||||
})
|
||||
let items = (await runScriptAndPollResult({
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildDbTableFilterSchema,
|
||||
dbColumnKind,
|
||||
renderColumnFilter,
|
||||
renderDbTableFilters,
|
||||
unescapeFreeText
|
||||
} from './dbTableFilters'
|
||||
import { ColumnIdentity, type ColumnDef } from './apps/components/display/dbtable/utils'
|
||||
|
||||
const col = (field: string, datatype: string): ColumnDef => ({
|
||||
field,
|
||||
datatype,
|
||||
defaultvalue: '',
|
||||
isprimarykey: false,
|
||||
isidentity: ColumnIdentity.No,
|
||||
isnullable: 'YES',
|
||||
isenum: false
|
||||
})
|
||||
|
||||
describe('dbColumnKind', () => {
|
||||
it('does not mistake interval for an integer', () => {
|
||||
expect(dbColumnKind('interval')).toBe('text')
|
||||
expect(dbColumnKind('int4')).toBe('number')
|
||||
expect(dbColumnKind('bigint unsigned')).toBe('number')
|
||||
expect(dbColumnKind('bit')).toBe('boolean')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderColumnFilter', () => {
|
||||
it('matches text case-insensitively without LIKE wildcards', () => {
|
||||
expect(renderColumnFilter('name', 'text', "50%_O'k", 'postgresql')).toBe(
|
||||
`strpos(lower(CAST("name" AS TEXT)), '50%_o''k') > 0`
|
||||
)
|
||||
})
|
||||
|
||||
it('treats a leading = as an exact match', () => {
|
||||
expect(renderColumnFilter('name', 'varchar', '=Bob', 'mysql')).toBe("`name` = 'Bob'")
|
||||
})
|
||||
|
||||
it('compares PostgreSQL json as text for an exact match', () => {
|
||||
expect(renderColumnFilter('js', 'json', '={"a": 1}', 'postgresql')).toBe(
|
||||
`CAST("js" AS TEXT) = '{"a": 1}'`
|
||||
)
|
||||
})
|
||||
|
||||
it('serializes BigQuery complex columns, which have no cast to STRING', () => {
|
||||
expect(renderColumnFilter('tags', 'ARRAY<STRING>', 'Ab', 'bigquery')).toBe(
|
||||
"STRPOS(LOWER(TO_JSON_STRING(`tags`)), 'ab') > 0"
|
||||
)
|
||||
expect(renderColumnFilter('doc', 'JSON', '={"a":1}', 'bigquery')).toBe(
|
||||
`TO_JSON_STRING(\`doc\`) = '{"a":1}'`
|
||||
)
|
||||
})
|
||||
|
||||
it('compares numbers and never passes a malformed one through', () => {
|
||||
expect(renderColumnFilter('id', 'int4', '>= 10', 'postgresql')).toBe('"id" >= 10')
|
||||
expect(renderColumnFilter('id', 'int4', '!=3', 'postgresql')).toBe('"id" <> 3')
|
||||
expect(renderColumnFilter('id', 'int4', '1; DROP TABLE x', 'postgresql')).toBe('1 = 0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('unescapeFreeText', () => {
|
||||
it('drops the escapes the search bar puts on typed spaces', () => {
|
||||
expect(unescapeFreeText('number\\ 77\\')).toBe('number 77')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderDbTableFilters', () => {
|
||||
it('maps sanitized keys back to their column and ignores the free text', () => {
|
||||
const columns = [col('first name', 'text'), col('id', 'int4')]
|
||||
const schema = buildDbTableFilterSchema(columns)
|
||||
expect(schema.keyOfColumn['first name']).toBe('first_name')
|
||||
expect(
|
||||
renderDbTableFilters(
|
||||
{ first_name: 'al\u00A0b', id: '2', _default_: 'free' },
|
||||
schema,
|
||||
columns,
|
||||
'duckdb'
|
||||
)
|
||||
).toBe(`(contains(lower(CAST("first name" AS VARCHAR)), 'al b')) AND ("id" = 2)`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,228 @@
|
||||
import { Hash, ToggleLeft, Type } from 'lucide-svelte'
|
||||
import type { FilterSchema, FilterSchemaRec } from './FilterSearchbar.svelte'
|
||||
import {
|
||||
renderDbContainsFilter,
|
||||
renderDbEqualityFilter,
|
||||
renderDbLiteral,
|
||||
renderDbQuotedIdentifier,
|
||||
type ColumnDef
|
||||
} from './apps/components/display/dbtable/utils'
|
||||
import type { DbType } from './dbTypes'
|
||||
|
||||
export type DbColumnKind = 'number' | 'boolean' | 'text'
|
||||
|
||||
/** Search bar key holding the free text, which stays the table's quicksearch. */
|
||||
export const FREE_TEXT_KEY = '_default_'
|
||||
|
||||
/** The search bar is a contenteditable, so a typed space can reach its values
|
||||
* as a non-breaking space, which a database compares as a different character. */
|
||||
function normalizeSpaces(text: string): string {
|
||||
return text.replace(/\u00A0/g, ' ')
|
||||
}
|
||||
|
||||
/** The search bar escapes a typed space as `\ ` (a double space separates
|
||||
* terms) and hands the free text over still escaped, unlike column values. */
|
||||
export function unescapeFreeText(text: string): string {
|
||||
return normalizeSpaces(text.replace(/\\(.)/g, '$1').replace(/\\$/, '')).trim()
|
||||
}
|
||||
|
||||
const NUMERIC_TYPE =
|
||||
/^(u?(tiny|small|medium|big|huge)?int(eger)?\d*|(small|big)?serial\d*|numeric|decimal|dec|float\d*|double|real|number|money|smallmoney|bignumeric)\b/
|
||||
const BOOLEAN_TYPE = /^(bool|boolean|bit)\b/
|
||||
|
||||
export function dbColumnKind(datatype: string | undefined): DbColumnKind {
|
||||
const t = (datatype ?? '').trim().toLowerCase()
|
||||
if (NUMERIC_TYPE.test(t)) return 'number'
|
||||
if (BOOLEAN_TYPE.test(t)) return 'boolean'
|
||||
return 'text'
|
||||
}
|
||||
|
||||
const NUMERIC_FILTER = /^(>=|<=|!=|<>|>|<|=)?\s*(-?\d+(\.\d+)?([eE][+-]?\d+)?)$/
|
||||
|
||||
export type DbTableFilterSchema = {
|
||||
schema: FilterSchemaRec
|
||||
/** Search bar key → column name. The bar only recognises `\w+` keys, so
|
||||
* other column names get a sanitized key. */
|
||||
columnOfKey: Record<string, string>
|
||||
keyOfColumn: Record<string, string>
|
||||
}
|
||||
|
||||
export function buildDbTableFilterSchema(columns: ColumnDef[]): DbTableFilterSchema {
|
||||
const schema: FilterSchemaRec = { [FREE_TEXT_KEY]: { type: 'string', hidden: true } }
|
||||
const columnOfKey: Record<string, string> = {}
|
||||
const keyOfColumn: Record<string, string> = {}
|
||||
for (const col of columns) {
|
||||
if (!col.field) continue
|
||||
let base = col.field.replace(/\W+/g, '_') || 'column'
|
||||
if (base === FREE_TEXT_KEY) base = `${base}col`
|
||||
let key = base
|
||||
for (let i = 2; key in schema; i++) key = `${base}_${i}`
|
||||
columnOfKey[key] = col.field
|
||||
keyOfColumn[col.field] = key
|
||||
schema[key] = columnFilterSchema(col)
|
||||
}
|
||||
return { schema, columnOfKey, keyOfColumn }
|
||||
}
|
||||
|
||||
function columnFilterSchema(col: ColumnDef): FilterSchema {
|
||||
const kind = dbColumnKind(col.datatype)
|
||||
const common = { label: col.field }
|
||||
switch (kind) {
|
||||
case 'boolean':
|
||||
return {
|
||||
...common,
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
icon: ToggleLeft,
|
||||
description: col.datatype
|
||||
}
|
||||
case 'number':
|
||||
return {
|
||||
...common,
|
||||
type: 'string',
|
||||
icon: Hash,
|
||||
description: `${col.datatype} · equals, or compare with >, >=, <, <=, !=`
|
||||
}
|
||||
case 'text':
|
||||
return {
|
||||
...common,
|
||||
type: 'string',
|
||||
icon: Type,
|
||||
description: `${col.datatype} · contains (case-insensitive), prefix with = for an exact match`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** SQL predicate for one column filter. A malformed number matches nothing
|
||||
* rather than being dropped, so the table never shows rows the filter excludes. */
|
||||
export function renderColumnFilter(
|
||||
column: string,
|
||||
datatype: string | undefined,
|
||||
value: unknown,
|
||||
dbType: DbType
|
||||
): string | undefined {
|
||||
if (value === undefined || value === null || value === '') return undefined
|
||||
switch (dbColumnKind(datatype)) {
|
||||
case 'boolean':
|
||||
return typeof value === 'boolean' ? renderDbEqualityFilter(column, value, dbType) : undefined
|
||||
case 'number': {
|
||||
const m = String(value).trim().match(NUMERIC_FILTER)
|
||||
if (!m) return '1 = 0'
|
||||
const op = m[1] === '!=' ? '<>' : (m[1] ?? '=')
|
||||
return `${renderDbQuotedIdentifier(column, dbType)} ${op} ${m[2]}`
|
||||
}
|
||||
case 'text': {
|
||||
const text = String(value)
|
||||
// BigQuery can neither cast these to STRING nor compare them to a string literal.
|
||||
if (dbType === 'bigquery' && /^(json|array|struct|geography)\b/i.test(datatype ?? '')) {
|
||||
const serialized = `TO_JSON_STRING(${renderDbQuotedIdentifier(column, dbType)})`
|
||||
return text.startsWith('=')
|
||||
? `${serialized} = ${renderDbLiteral(text.slice(1), dbType)}`
|
||||
: `STRPOS(LOWER(${serialized}), ${renderDbLiteral(text.toLowerCase(), dbType)}) > 0`
|
||||
}
|
||||
if (!text.startsWith('=')) return renderDbContainsFilter(column, text, dbType)
|
||||
// PostgreSQL `json` has no `=`: compare as the text the grid shows.
|
||||
if (dbType === 'postgresql' && /^jsonb?$/i.test((datatype ?? '').trim())) {
|
||||
const literal = renderDbLiteral(text.slice(1), dbType)!
|
||||
return `CAST(${renderDbQuotedIdentifier(column, dbType)} AS TEXT) = ${literal}`
|
||||
}
|
||||
return renderDbEqualityFilter(column, text.slice(1), dbType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** AND of every column filter, or undefined when none applies. The free text
|
||||
* is not part of it: it goes to the reads as their quicksearch. */
|
||||
export function renderDbTableFilters(
|
||||
filters: Record<string, unknown>,
|
||||
{ columnOfKey }: DbTableFilterSchema,
|
||||
columns: ColumnDef[],
|
||||
dbType: DbType
|
||||
): string | undefined {
|
||||
const predicates: string[] = []
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
const column = columnOfKey[key]
|
||||
if (!column) continue
|
||||
const datatype = columns.find((c) => c.field === column)?.datatype
|
||||
const predicate = renderColumnFilter(
|
||||
column,
|
||||
datatype,
|
||||
typeof value === 'string' ? normalizeSpaces(value) : value,
|
||||
dbType
|
||||
)
|
||||
if (predicate) predicates.push(`(${predicate})`)
|
||||
}
|
||||
return predicates.length ? predicates.join(' AND ') : undefined
|
||||
}
|
||||
|
||||
/** Search bar value that selects exactly the rows whose `column` equals `value`. */
|
||||
export function exactColumnFilterValue(
|
||||
datatype: string | undefined,
|
||||
value: unknown
|
||||
): string | boolean {
|
||||
switch (dbColumnKind(datatype)) {
|
||||
case 'boolean':
|
||||
// Previews read values as text, so a false key arrives as "false" or "f".
|
||||
return typeof value === 'string'
|
||||
? ['true', 't', '1', 'yes', 'y'].includes(value.trim().toLowerCase())
|
||||
: Boolean(value)
|
||||
case 'number':
|
||||
return String(value)
|
||||
case 'text':
|
||||
return `=${String(value)}`
|
||||
}
|
||||
}
|
||||
|
||||
/** Column → filter value, for a source that filters its rows itself rather than in SQL. */
|
||||
export function columnFiltersOf(
|
||||
filters: Record<string, unknown>,
|
||||
{ columnOfKey }: DbTableFilterSchema
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
const column = columnOfKey[key]
|
||||
if (!column || value === undefined || value === null || value === '') continue
|
||||
out[column] = typeof value === 'string' ? normalizeSpaces(value) : value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** In-memory counterpart of `renderColumnFilter`, with the same semantics: a null never
|
||||
* matches, and a malformed number matches nothing. */
|
||||
export function matchesColumnFilter(
|
||||
value: unknown,
|
||||
datatype: string | undefined,
|
||||
filter: unknown
|
||||
): boolean {
|
||||
if (value === null || value === undefined) return false
|
||||
switch (dbColumnKind(datatype)) {
|
||||
case 'boolean':
|
||||
return typeof filter !== 'boolean' || value === filter
|
||||
case 'number': {
|
||||
const m = String(filter).trim().match(NUMERIC_FILTER)
|
||||
if (!m) return false
|
||||
const a = Number(value)
|
||||
const b = Number(m[2])
|
||||
switch (m[1]) {
|
||||
case '>':
|
||||
return a > b
|
||||
case '>=':
|
||||
return a >= b
|
||||
case '<':
|
||||
return a < b
|
||||
case '<=':
|
||||
return a <= b
|
||||
case '!=':
|
||||
case '<>':
|
||||
return a !== b
|
||||
default:
|
||||
return a === b
|
||||
}
|
||||
}
|
||||
case 'text': {
|
||||
const text = typeof value === 'object' ? JSON.stringify(value) : String(value)
|
||||
const f = String(filter)
|
||||
return f.startsWith('=') ? text === f.slice(1) : text.toLowerCase().includes(f.toLowerCase())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { cellStyle, formatValue, UNIT_PRESETS } from './dbTableFormat'
|
||||
|
||||
describe('formatValue', () => {
|
||||
it('sets the decimals, groups thousands and compacts', () => {
|
||||
expect(formatValue('1234567.891', { decimals: 2 })).toBe('1234567.89')
|
||||
expect(formatValue('1234567.891', { decimals: 2, notation: 'thousands' })).toBe('1,234,567.89')
|
||||
expect(formatValue(3, { decimals: 2 })).toBe('3.00')
|
||||
expect(formatValue('1234.50', { notation: 'thousands' })).toBe('1,234.50')
|
||||
expect(formatValue('35412345', { notation: 'compact', decimals: 1 })).toBe('35.4M')
|
||||
// Past what a float holds: numeric columns arrive as text and keep every digit.
|
||||
expect(formatValue('12345678901234567890.5', { notation: 'thousands' })).toBe(
|
||||
'12,345,678,901,234,567,890.5'
|
||||
)
|
||||
})
|
||||
|
||||
it('places the unit on its side, the sign before a leading one', () => {
|
||||
expect(formatValue('12.5', { unit: { symbol: '%', position: 'after' } })).toBe('12.5%')
|
||||
expect(formatValue(-5000, { unit: { symbol: '$', position: 'before' } })).toBe('-$5000')
|
||||
expect(
|
||||
formatValue('35412345', { notation: 'compact', decimals: 1, unit: UNIT_PRESETS[2] })
|
||||
).toBe('35.4M\u00A0€')
|
||||
})
|
||||
|
||||
it('leaves a value untouched when nothing applies to it', () => {
|
||||
expect(formatValue('1234', { align: 'left' })).toBeUndefined()
|
||||
expect(formatValue('N/A', { unit: { symbol: '$', position: 'before' } })).toBeUndefined()
|
||||
expect(formatValue(null, { unit: { symbol: '$', position: 'before' } })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cellStyle', () => {
|
||||
it('layers every matching rule, a later one overriding what it sets', () => {
|
||||
const format = {
|
||||
rules: [
|
||||
{ condition: '', bold: true, bg: '#f3f4f6' },
|
||||
{ condition: '>= 10', bg: '#fef9c3' },
|
||||
{ condition: '>= 100', bg: '#fee2e2', text: '#991b1b' }
|
||||
]
|
||||
}
|
||||
expect(cellStyle('150', 'int4', format)).toEqual({
|
||||
bold: true,
|
||||
bg: '#fee2e2',
|
||||
text: '#991b1b'
|
||||
})
|
||||
expect(cellStyle('50', 'int4', format)).toEqual({ bold: true, bg: '#fef9c3' })
|
||||
expect(cellStyle('5', 'int4', format)).toEqual({ bold: true, bg: '#f3f4f6' })
|
||||
const bools = {
|
||||
rules: [
|
||||
{ condition: '=true', bg: 'green' },
|
||||
{ condition: 'f', bg: 'red' }
|
||||
]
|
||||
}
|
||||
expect(cellStyle('t', 'bool', bools)?.bg).toBe('green')
|
||||
expect(cellStyle(false, 'bool', bools)?.bg).toBe('red')
|
||||
expect(cellStyle(null, 'bool', bools)).toBeUndefined()
|
||||
expect(cellStyle('refunded', 'text', { rules: [{ condition: '=paid', bold: true }] })).toBe(
|
||||
undefined
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { dbColumnKind, matchesColumnFilter } from './dbTableFilters'
|
||||
|
||||
/** How a column's values are displayed. Display only: filters, sorting, edits and copies keep
|
||||
* the raw value. */
|
||||
export type ColumnFormat = {
|
||||
unit?: { symbol: string; position: 'before' | 'after' }
|
||||
/** Digits after the decimal point; undefined keeps those of the value. */
|
||||
decimals?: number
|
||||
/** Grouped by thousands (1,234,567) or shortened (1.2M); undefined writes 1234567. */
|
||||
notation?: 'thousands' | 'compact'
|
||||
/** Undefined aligns numbers right and anything else left. */
|
||||
align?: 'left' | 'right'
|
||||
/** Every rule whose condition matches styles the cell, a later one overriding an earlier one. */
|
||||
rules?: ColorRule[]
|
||||
}
|
||||
|
||||
export type ColorRule = {
|
||||
/** In the search bar's filter syntax: `=paid`, `>= 4`, `pend`. Empty matches every cell. */
|
||||
condition: string
|
||||
bg?: string
|
||||
text?: string
|
||||
bold?: boolean
|
||||
italic?: boolean
|
||||
}
|
||||
|
||||
/** Picked when adding a rule, so that one click sets a background and a text color that read
|
||||
* well together. */
|
||||
export const RULE_PRESETS: { bg?: string; text: string }[] = [
|
||||
{ bg: '#fee2e2', text: '#991b1b' },
|
||||
{ bg: '#ffedd5', text: '#9a3412' },
|
||||
{ bg: '#fef3c7', text: '#92400e' },
|
||||
{ bg: '#dcfce7', text: '#166534' },
|
||||
{ bg: '#ccfbf1', text: '#115e59' },
|
||||
{ bg: '#dbeafe', text: '#1e40af' },
|
||||
{ bg: '#ede9fe', text: '#5b21b6' },
|
||||
{ bg: '#fce7f3', text: '#9d174d' },
|
||||
{ bg: '#f3f4f6', text: '#374151' },
|
||||
{ text: '#dc2626' },
|
||||
{ text: '#ea580c' },
|
||||
{ text: '#d97706' },
|
||||
{ text: '#16a34a' },
|
||||
{ text: '#0d9488' },
|
||||
{ text: '#2563eb' },
|
||||
{ text: '#7c3aed' },
|
||||
{ text: '#db2777' },
|
||||
{ text: '#9ca3af' }
|
||||
]
|
||||
|
||||
// Each where it is usually written: 12.5%, $5, 5 €, £5, ¥5.
|
||||
export const UNIT_PRESETS: { symbol: string; position: 'before' | 'after' }[] = [
|
||||
{ symbol: '%', position: 'after' },
|
||||
{ symbol: '$', position: 'before' },
|
||||
{ symbol: '\u00A0€', position: 'after' },
|
||||
{ symbol: '£', position: 'before' },
|
||||
{ symbol: '¥', position: 'before' }
|
||||
]
|
||||
|
||||
const NUMERIC = /^-?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i
|
||||
/** Previews read most values as text, so a number may arrive as a string. */
|
||||
export function asNumber(value: unknown): number | undefined {
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? value : undefined
|
||||
if (typeof value === 'string' && NUMERIC.test(value.trim())) return Number(value.trim())
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Whether the numbers of the column are formatted at all. */
|
||||
export function formatsNumbers(format: ColumnFormat | undefined): boolean {
|
||||
return !!format && (format.decimals !== undefined || !!format.notation || !!format.unit?.symbol)
|
||||
}
|
||||
|
||||
/** The text a cell shows for `value`, or undefined when the format leaves it as is. */
|
||||
export function formatValue(value: unknown, format: ColumnFormat | undefined): string | undefined {
|
||||
if (!format || value === null || value === undefined) return undefined
|
||||
const n = asNumber(value)
|
||||
// A unit on text would read as a quantity: `N/A` must not become `$N/A`.
|
||||
if (n === undefined) return undefined
|
||||
if (!formatsNumbers(format)) return undefined
|
||||
// A string keeps the digits a float would lose: numeric columns are read as text.
|
||||
const source = typeof value === 'string' ? value.trim() : String(n)
|
||||
const compact = format.notation === 'compact'
|
||||
// Auto keeps the value's own scale, trailing zeros included: numeric(10,2) shows 1234.50.
|
||||
const scale = /e/i.test(source) ? 0 : Math.min(source.split('.')[1]?.length ?? 0, 100)
|
||||
const text = new Intl.NumberFormat('en-US', {
|
||||
useGrouping: format.notation === 'thousands',
|
||||
notation: compact ? 'compact' : 'standard',
|
||||
minimumFractionDigits: format.decimals ?? (compact ? undefined : scale),
|
||||
// Left to the value, or to the compact notation's own rounding.
|
||||
maximumFractionDigits: format.decimals ?? (compact ? undefined : 100)
|
||||
}).format(typeof value === 'string' ? (source as Intl.StringNumericLiteral) : n)
|
||||
if (!format.unit?.symbol) return text
|
||||
const { symbol, position } = format.unit
|
||||
if (position === 'after') return `${text}${symbol}`
|
||||
// A sign reads before a leading unit: -$5, not $-5.
|
||||
return text.startsWith('-') ? `-${symbol}${text.slice(1)}` : `${symbol}${text}`
|
||||
}
|
||||
|
||||
const TRUE_TEXT = ['true', 't', '1', 'yes', 'y']
|
||||
const FALSE_TEXT = ['false', 'f', '0', 'no', 'n']
|
||||
function asBoolean(value: unknown): boolean | undefined {
|
||||
if (typeof value === 'boolean') return value
|
||||
const text = String(value).trim().toLowerCase()
|
||||
return TRUE_TEXT.includes(text) ? true : FALSE_TEXT.includes(text) ? false : undefined
|
||||
}
|
||||
|
||||
/** The search bar filters a boolean column with a toggle, but a rule's condition is typed:
|
||||
* `true`, `=false`, `t`… are read as the boolean they name. */
|
||||
function matchesCondition(value: unknown, datatype: string | undefined, condition: string) {
|
||||
if (dbColumnKind(datatype) !== 'boolean') return matchesColumnFilter(value, datatype, condition)
|
||||
const expected = asBoolean(condition.replace(/^=/, ''))
|
||||
return expected !== undefined && value !== null && asBoolean(value) === expected
|
||||
}
|
||||
|
||||
export type CellStyle = Pick<ColorRule, 'bg' | 'text' | 'bold' | 'italic'>
|
||||
|
||||
/** Every rule `value` matches, layered in order: a later rule overrides only what it sets. */
|
||||
export function cellStyle(
|
||||
value: unknown,
|
||||
datatype: string | undefined,
|
||||
format: ColumnFormat | undefined
|
||||
): CellStyle | undefined {
|
||||
let style: CellStyle | undefined
|
||||
for (const rule of format?.rules ?? []) {
|
||||
const condition = rule.condition.trim()
|
||||
// No condition styles every cell.
|
||||
if (condition && !matchesCondition(value, datatype, condition)) continue
|
||||
style = { ...style }
|
||||
if (rule.bg) style.bg = rule.bg
|
||||
if (rule.text) style.text = rule.text
|
||||
if (rule.bold) style.bold = true
|
||||
if (rule.italic) style.italic = true
|
||||
}
|
||||
return style
|
||||
}
|
||||
|
||||
export function isEmptyFormat(format: ColumnFormat): boolean {
|
||||
return (
|
||||
!format.unit?.symbol &&
|
||||
format.decimals === undefined &&
|
||||
!format.notation &&
|
||||
!format.align &&
|
||||
!format.rules?.length
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ColumnIdentity, type ColumnDef } from './apps/components/display/dbtable/utils'
|
||||
|
||||
/** A column of a table the previewed one references by foreign key, shown beside its own
|
||||
* columns. The read LEFT JOINs the target on the key (see `select_source` in the backend's
|
||||
* query builders). */
|
||||
export type DbTableJoin = {
|
||||
/** The previewed table's foreign key column. */
|
||||
sourceColumn: string
|
||||
/** The referenced table, as the query names it. */
|
||||
targetTable: string
|
||||
/** The column the key references. */
|
||||
targetColumn: string
|
||||
/** The target's column to show. */
|
||||
column: string
|
||||
datatype: string
|
||||
}
|
||||
|
||||
/** A table the previewed one can join: one of its single-column foreign keys, and the columns
|
||||
* of the table that key references. */
|
||||
export type DbJoinTarget = {
|
||||
sourceColumn: string
|
||||
targetTable: string
|
||||
targetColumn: string
|
||||
columns: { field: string; datatype: string }[]
|
||||
}
|
||||
|
||||
/** The joined column's name in the grid and the query: its path from the previewed table. */
|
||||
export function joinAlias(join: Pick<DbTableJoin, 'sourceColumn' | 'column'>): string {
|
||||
return `${join.sourceColumn}.${join.column}`
|
||||
}
|
||||
|
||||
export function joinedColumnDef(join: DbTableJoin): ColumnDef {
|
||||
return {
|
||||
field: joinAlias(join),
|
||||
datatype: join.datatype,
|
||||
defaultvalue: '',
|
||||
isprimarykey: false,
|
||||
isidentity: ColumnIdentity.No,
|
||||
isnullable: 'YES',
|
||||
isenum: false
|
||||
}
|
||||
}
|
||||
|
||||
/** The joins as the SELECT and COUNT markers take them. */
|
||||
export function joinsPayload(joins: DbTableJoin[]) {
|
||||
return joins.map(({ sourceColumn, targetTable, targetColumn, column }) => ({
|
||||
sourceColumn,
|
||||
targetTable,
|
||||
targetColumn,
|
||||
column,
|
||||
alias: joinAlias({ sourceColumn, column })
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { getLocalSetting, storeLocalSetting } from '$lib/utils'
|
||||
import type { DbTableJoin } from './dbTableJoins'
|
||||
import type { ColumnFormat } from './dbTableFormat'
|
||||
|
||||
export const DB_TABLE_LAYOUT_PREFIX = 'dbTableLayout:'
|
||||
// Every table opened gets a layout, so without a cap they pile up forever.
|
||||
const MAX_LAYOUTS = 60
|
||||
const PRUNED_PER_OVERFLOW = 10
|
||||
|
||||
export type DbTableLayout = {
|
||||
widths?: Record<string, number>
|
||||
pinned?: Record<string, 'left' | 'right'>
|
||||
joins?: DbTableJoin[]
|
||||
/** Columns left out of the grid. */
|
||||
hidden?: string[]
|
||||
formats?: Record<string, ColumnFormat>
|
||||
lastUsed?: number
|
||||
}
|
||||
|
||||
export function readDbTableLayout(key: string): DbTableLayout {
|
||||
try {
|
||||
const parsed = JSON.parse(getLocalSetting(key) ?? '{}')
|
||||
return parsed && typeof parsed === 'object' ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function saveDbTableLayout(key: string, layout: DbTableLayout) {
|
||||
try {
|
||||
if (!layout.widths && !layout.pinned && !layout.joins && !layout.hidden && !layout.formats) {
|
||||
storeLocalSetting(key, undefined)
|
||||
return
|
||||
}
|
||||
const isNew = getLocalSetting(key) == null
|
||||
storeLocalSetting(key, JSON.stringify({ ...layout, lastUsed: Date.now() }))
|
||||
if (isNew) pruneDbTableLayouts(key)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** Drops the least recently used layouts once there are more than the cap, never `keep`. */
|
||||
function pruneDbTableLayouts(keep: string) {
|
||||
const keys = Object.keys(localStorage).filter((k) => k.startsWith(DB_TABLE_LAYOUT_PREFIX))
|
||||
if (keys.length <= MAX_LAYOUTS) return
|
||||
keys
|
||||
.filter((k) => k !== keep)
|
||||
.map((k) => ({ k, lastUsed: readDbTableLayout(k).lastUsed ?? 0 }))
|
||||
.sort((a, b) => a.lastUsed - b.lastUsed)
|
||||
.slice(0, PRUNED_PER_OVERFLOW)
|
||||
.forEach(({ k }) => localStorage.removeItem(k))
|
||||
}
|
||||
@@ -41,7 +41,7 @@
|
||||
<div
|
||||
class={twMerge(
|
||||
'rounded-md border bg-surface-tertiary shadow-sm transition-opacity duration-150 overflow-hidden',
|
||||
dimmed ? 'opacity-20' : 'opacity-100',
|
||||
dimmed ? 'opacity-50' : 'opacity-100',
|
||||
isTarget
|
||||
? 'border-border-accent'
|
||||
: lit && highlight.tables
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
maxHeight = 256,
|
||||
instantClose = false,
|
||||
getInputRect,
|
||||
portalName = 'dropdown-portal',
|
||||
children
|
||||
}: {
|
||||
listAutoWidth?: boolean
|
||||
@@ -31,6 +32,9 @@
|
||||
// dropdown is toggled off as the user types, so the panel vanishes at once.
|
||||
instantClose?: boolean
|
||||
getInputRect?: () => DOMRect
|
||||
/** Outside-click handlers ignore clicks in `.dropdown-portal`: a panel that holds Selects of
|
||||
* its own needs another name, or their lists would not close on clicks elsewhere in it. */
|
||||
portalName?: string
|
||||
children?: Snippet
|
||||
} = $props()
|
||||
|
||||
@@ -125,7 +129,7 @@
|
||||
)
|
||||
</script>
|
||||
|
||||
<ConditionalPortal condition={!disablePortal} name="dropdown-portal">
|
||||
<ConditionalPortal condition={!disablePortal} name={portalName}>
|
||||
{#if uiState.domExists}
|
||||
<div
|
||||
class={twMerge(
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { staticDbTableOps } from './staticDbTableOps'
|
||||
|
||||
const rows = [
|
||||
{ id: 3, name: 'carol', active: true },
|
||||
{ id: 1, name: null, active: false },
|
||||
{ id: 2, name: 'alice', active: true }
|
||||
]
|
||||
const ops = staticDbTableOps(rows, 'postgresql', 'query_result')
|
||||
const read = (params: Partial<Parameters<typeof ops.getRows>[0]>) =>
|
||||
ops.getRows({ offset: 0, limit: 10, quicksearch: '', order_by: 'id', is_desc: false, ...params })
|
||||
|
||||
describe('staticDbTableOps', () => {
|
||||
it('types a column from its values', () => {
|
||||
expect(ops.colDefs.map((c) => c.datatype)).toEqual(['numeric', 'text', 'boolean'])
|
||||
})
|
||||
|
||||
it('keeps the query order until a sort is picked, then puts nulls last', async () => {
|
||||
expect((await read({})).map((r: any) => r.id)).toEqual([3, 1, 2])
|
||||
expect((await read({ order_by: 'name', explicitSort: true })).map((r: any) => r.id)).toEqual([
|
||||
2, 3, 1
|
||||
])
|
||||
})
|
||||
|
||||
it('filters and counts like the SQL reads do', async () => {
|
||||
expect(await read({ columnFilters: { id: '>=2', active: true } })).toHaveLength(2)
|
||||
expect(await ops.getCount({ quicksearch: 'ALI' })).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { ColumnIdentity, type ColumnDef } from './apps/components/display/dbtable/utils'
|
||||
import type { IDbTableOps } from './dbOps'
|
||||
import { matchesColumnFilter } from './dbTableFilters'
|
||||
import type { DbType } from './dbTypes'
|
||||
|
||||
/** A query result has no column types, so they are read off the values: a column is a
|
||||
* number or a boolean only if every non-null value is. */
|
||||
function inferDatatype(rows: Record<string, unknown>[], field: string): string {
|
||||
let kind: 'number' | 'boolean' | undefined
|
||||
for (const row of rows) {
|
||||
const v = row[field]
|
||||
if (v === null || v === undefined) continue
|
||||
const k = typeof v === 'number' ? 'number' : typeof v === 'boolean' ? 'boolean' : undefined
|
||||
if (!k || (kind && kind !== k)) return 'text'
|
||||
kind = k
|
||||
}
|
||||
return kind === 'number' ? 'numeric' : kind === 'boolean' ? 'boolean' : 'text'
|
||||
}
|
||||
|
||||
function searchable(value: unknown): string {
|
||||
if (value === null || value === undefined) return ''
|
||||
return (typeof value === 'object' ? JSON.stringify(value) : String(value)).toLowerCase()
|
||||
}
|
||||
|
||||
function compare(a: unknown, b: unknown): number {
|
||||
if (a === b) return 0
|
||||
if (a === null || a === undefined) return 1
|
||||
if (b === null || b === undefined) return -1
|
||||
if (typeof a === 'number' && typeof b === 'number') return a - b
|
||||
return searchable(a).localeCompare(searchable(b), undefined, { numeric: true })
|
||||
}
|
||||
|
||||
/** Read-only table ops over rows already in memory, e.g. a SQL editor's result: searched,
|
||||
* filtered and sorted here rather than by a query. */
|
||||
export function staticDbTableOps(
|
||||
rows: Record<string, unknown>[],
|
||||
dbType: DbType,
|
||||
tableKey: string
|
||||
): IDbTableOps {
|
||||
const fields = [...new Set(rows.flatMap((r) => Object.keys(r)))]
|
||||
const colDefs: ColumnDef[] = fields.map((field) => ({
|
||||
field,
|
||||
datatype: inferDatatype(rows, field),
|
||||
defaultvalue: '',
|
||||
isprimarykey: false,
|
||||
isidentity: ColumnIdentity.No,
|
||||
isnullable: 'YES',
|
||||
isenum: false
|
||||
}))
|
||||
const datatypeOf = Object.fromEntries(colDefs.map((c) => [c.field, c.datatype]))
|
||||
|
||||
function filtered(quicksearch: string, columnFilters: Record<string, unknown> = {}) {
|
||||
const q = quicksearch.toLowerCase()
|
||||
return rows.filter(
|
||||
(row) =>
|
||||
(!q || fields.some((f) => searchable(row[f]).includes(q))) &&
|
||||
Object.entries(columnFilters).every(([column, filter]) =>
|
||||
matchesColumnFilter(row[column], datatypeOf[column], filter)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
dbType,
|
||||
tableKey,
|
||||
colDefs,
|
||||
getCount: async ({ quicksearch, columnFilters }) => filtered(quicksearch, columnFilters).length,
|
||||
getRows: async ({
|
||||
offset,
|
||||
limit,
|
||||
quicksearch,
|
||||
columnFilters,
|
||||
order_by,
|
||||
is_desc,
|
||||
explicitSort
|
||||
}) => {
|
||||
let result = filtered(quicksearch, columnFilters)
|
||||
// Unsorted, a result keeps the order its query gave it.
|
||||
if (explicitSort) {
|
||||
result = [...result].sort((a, b) => compare(a[order_by], b[order_by]))
|
||||
if (is_desc) result.reverse()
|
||||
}
|
||||
return result.slice(offset, offset + limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,9 +319,15 @@
|
||||
link="https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables"
|
||||
>
|
||||
{#snippet actions()}
|
||||
{#if $superadmin && $enterpriseLicense && !isCloudHosted()}
|
||||
<InstanceRolesButton />
|
||||
{/if}
|
||||
<InstanceRolesButton
|
||||
unavailable={!$enterpriseLicense
|
||||
? { reason: 'Instance roles are an Enterprise Edition feature.', ee: true }
|
||||
: isCloudHosted()
|
||||
? { reason: 'Instance roles are only available on self-hosted instances.', ee: false }
|
||||
: !$superadmin
|
||||
? { reason: 'Only instance superadmins can manage instance roles.', ee: false }
|
||||
: undefined}
|
||||
/>
|
||||
{/snippet}
|
||||
</SettingsPageHeader>
|
||||
|
||||
@@ -529,17 +535,21 @@
|
||||
tooltip: dirtyMap[dataTable.name] ? 'Save the settings first' : undefined,
|
||||
action: () => migrationsButtons[dataTable.name]?.open()
|
||||
},
|
||||
...($enterpriseLicense && !isCloudHosted()
|
||||
? [
|
||||
{
|
||||
displayName: 'Roles',
|
||||
icon: KeyRound,
|
||||
disabled: !!dirtyMap[dataTable.name],
|
||||
tooltip: dirtyMap[dataTable.name] ? 'Save the settings first' : undefined,
|
||||
action: () => permissionsButtons[dataTable.name]?.open()
|
||||
}
|
||||
]
|
||||
: []),
|
||||
// Listed even where it cannot be used, disabled with the reason, so the feature
|
||||
// can be found.
|
||||
{
|
||||
displayName: $enterpriseLicense ? 'Roles' : 'Roles (EE)',
|
||||
icon: KeyRound,
|
||||
disabled: !$enterpriseLicense || isCloudHosted() || !!dirtyMap[dataTable.name],
|
||||
tooltip: !$enterpriseLicense
|
||||
? 'Data table roles are an Enterprise Edition feature.'
|
||||
: isCloudHosted()
|
||||
? 'Data table roles are only available on self-hosted instances.'
|
||||
: dirtyMap[dataTable.name]
|
||||
? 'Save the settings first'
|
||||
: undefined,
|
||||
action: () => permissionsButtons[dataTable.name]?.open()
|
||||
},
|
||||
// A fork's pointer entry is written by forking and kept by the server, not this form.
|
||||
...(dataTable.reference
|
||||
? []
|
||||
|
||||
@@ -5,12 +5,16 @@
|
||||
|
||||
let {
|
||||
hideTrigger = false,
|
||||
onChanged
|
||||
onChanged,
|
||||
unavailable
|
||||
}: {
|
||||
/** Mount the drawer without its button, for a caller that opens it with `open()`. */
|
||||
hideTrigger?: boolean
|
||||
/** Called after every change to the instance roles. */
|
||||
onChanged?: () => void
|
||||
/** Why the roles cannot be managed here: the button stays, disabled with this reason, so the
|
||||
* feature can be found. `ee` marks the reason that is the edition. */
|
||||
unavailable?: { reason: string; ee: boolean }
|
||||
} = $props()
|
||||
|
||||
let drawer: Drawer | undefined = $state(undefined)
|
||||
@@ -27,9 +31,18 @@
|
||||
</script>
|
||||
|
||||
{#if !hideTrigger}
|
||||
<Button unifiedSize="sm" variant="default" startIcon={{ icon: Users }} on:click={() => open()}>
|
||||
Instance roles
|
||||
</Button>
|
||||
{#if unavailable}
|
||||
<!-- A disabled button shows no title of its own: the wrapper carries the reason. -->
|
||||
<span title={unavailable.reason} class="inline-flex cursor-not-allowed">
|
||||
<Button unifiedSize="sm" variant="default" startIcon={{ icon: Users }} disabled>
|
||||
Instance roles{unavailable.ee ? ' (EE)' : ''}
|
||||
</Button>
|
||||
</span>
|
||||
{:else}
|
||||
<Button unifiedSize="sm" variant="default" startIcon={{ icon: Users }} on:click={() => open()}>
|
||||
Instance roles
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<Drawer bind:this={drawer} size="700px">
|
||||
|
||||
Reference in New Issue
Block a user