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:
Diego Imbert
2026-09-25 15:58:25 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 2e5f6d0e76
commit 4fd7d62bd0
32 changed files with 4226 additions and 899 deletions
+312 -58
View File
@@ -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 &gt;,
&gt;=, &lt;, &lt;= 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">
+13 -5
View File
@@ -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
>
+77 -37
View File
@@ -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 {}
}
}
+42 -7
View File
@@ -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">