diff --git a/backend/windmill-common/src/query_builders.rs b/backend/windmill-common/src/query_builders.rs index 546d2d454b..be608ebf34 100644 --- a/backend/windmill-common/src/query_builders.rs +++ b/backend/windmill-common/src/query_builders.rs @@ -189,6 +189,82 @@ fn duckdb_version_suffix(version: Option) -> 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, +) -> 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::>() + .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::(); + 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, /// DuckLake snapshot to time-travel the read to (DuckDB only). version: Option, + #[serde(default)] + joins: Vec, +} + +/// 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, /// DuckLake snapshot to time-travel the count to (DuckDB only). version: Option, + #[serde(default)] + joins: Vec, } /// `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 { .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 { 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 { + 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 { 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::>() .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, ) -> Result { + 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, +) -> Result { + 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::>() .join("\n AND "); query.push_str(&format!( @@ -1407,13 +1512,41 @@ fn get_user_default_value(column: &ColumnDef) -> Option { 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::>() .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 // ----------------------------------------------------------------------- diff --git a/frontend/src/lib/components/DBManager.svelte b/frontend/src/lib/components/DBManager.svelte index 16e64dc5a0..5204a876b9 100644 --- a/frontend/src/lib/components/DBManager.svelte +++ b/frontend/src/lib/components/DBManager.svelte @@ -1,7 +1,7 @@ - - -
- -
-
- - {#if datatableTreeLoading && (datatableTree?.length ?? 0) === 0} -
- - Loading... +{#snippet diagramView(tables: SelectedTable[])} + { + if (!tabs) onViewMode?.('data') + selectTable(currentDatatable, schema, table) + }} + /> +{/snippet} + +{#snippet dataView( + refKey: string, + schema: string | undefined, + table: string | undefined, + isCurrent: boolean +)} + {@const key = tableKeyOf(schema, table)} + {@const schemaDefs = key && !colDefs?.[key]?.length ? provisionalColDefs(schema, table) : []} + {#if key && (colDefs?.[key]?.length || schemaDefs.length)} + {@const dbTableOps = colDefs?.[key]?.length + ? dbTableOpsFactory({ colDefs: colDefs[key], tableKey: key }) + : waitingDbTableOps(schemaDefs, key)} + + {#key key} + (rowFilter = undefined)} + layoutStorageKey={table ? tableLayoutKey?.(schema, table) : undefined} + joinTargets={joinTargetsOf(key)} + onNewColumn={schema && table + ? () => startAlterTable(currentDatatable, schema, table) + : undefined} + bind:this={dbTableRefs[refKey]} + /> + {/key} + {:else if databaseIsEmpty} +
+ Database is empty + {#if onImport} +
+ + {#if !!actingUser?.is_admin || !!$superadmin} + + {/if}
{/if} - {#each treeRoots as root (root.datatable ?? '')} - {@const dtOpen = isExpanded(root.datatable)} - {#if root.datatable !== undefined} - {@const hasMenu = !multiSelectMode && onDatatableAction !== undefined} - {@const roleInfo = roleOf(root.datatable)} - - {/if} - {#if dtOpen} - {#if root.error} -

{root.error}

{/if} - {#each root.schemas as sc (sc.schemaKey)} - {@const schemaOpen = isExpanded(root.datatable, sc.schemaKey)} - {@const indent = root.datatable !== undefined ? 'pl-7' : 'pl-3'} - {#if dbSupportsSchemas} + {#each treeRoots as root (root.datatable ?? '')} + {@const dtOpen = isExpanded(root.datatable)} + {#if root.datatable !== undefined} + {@const hasMenu = !multiSelectMode && onDatatableAction !== undefined} + {@const roleInfo = roleOf(root.datatable)} {/if} - - - {#if schemaOpen || !dbSupportsSchemas} - {@const tableIndent = dbSupportsSchemas - ? root.datatable !== undefined - ? 'pl-11' - : 'pl-7' - : root.datatable !== undefined - ? 'pl-7' - : 'pl-3'} - {#each sc.tables as tableKey (tableKey)} - {@const entry = { - datatable: root.datatable, - schema: sc.schemaKey, - table: tableKey - }} - {@const hasMenu = !multiSelectMode} - {@const isSelected = - root.datatable === currentDatatable && - selected.schemaKey === sc.schemaKey && - selected.tableKey === tableKey} + {#if dtOpen} + {#if root.error} +

{root.error}

+ {/if} + {#each root.schemas as sc (sc.schemaKey)} + {@const schemaOpen = isExpanded(root.datatable, sc.schemaKey)} + {@const indent = root.datatable !== undefined ? 'pl-7' : 'pl-3'} + {#if dbSupportsSchemas} - {/each} - {#if canCreateTableIn(root.datatable, sc.schemaKey)} - {/if} + + + {#if schemaOpen || !dbSupportsSchemas} + {@const tableIndent = dbSupportsSchemas + ? root.datatable !== undefined + ? 'pl-11' + : 'pl-7' + : root.datatable !== undefined + ? 'pl-7' + : 'pl-3'} + {#each sc.tables as tableKey (tableKey)} + {@const entry = { + datatable: root.datatable, + schema: sc.schemaKey, + table: tableKey + }} + {@const hasMenu = !multiSelectMode} + {@const isSelected = + root.datatable === currentDatatable && + selected.schemaKey === sc.schemaKey && + selected.tableKey === tableKey} + + {/each} + {#if canCreateTableIn(root.datatable, sc.schemaKey)} + + {/if} + {/if} + + {/each} + {#if dbSupportsSchemas && search.trim() === '' && canCreateSchemaIn(root.datatable)} + {/if} -
- {/each} - {#if dbSupportsSchemas && search.trim() === '' && canCreateSchemaIn(root.datatable)} - - {/if} - {/if} - {/each} -
- - - {#if mainPane} - {@render mainPane()} - {:else if viewMode === 'diagram'} - { - onViewMode?.('data') - selectTable(currentDatatable, schema, table) - }} - /> - {:else if tableKey && colDefs?.[tableKey]?.length} - {@const dbTableOps = dbTableOpsFactory({ colDefs: colDefs[tableKey], tableKey, whereClause })} - (rowFilter = undefined)} - bind:this={_dbTable} - /> - {:else if databaseIsEmpty} -
- Database is empty - {#if onImport} -
- - {#if !!actingUser?.is_admin || !!$superadmin} - {/if} -
+ {/each} +
+
+ + + {#if mainPane} + {@render mainPane()} + {:else if tabs} + + {#each tabs.tabs as tab (tab.id)} + {#if mountedTabs.has(tab.id)} +
+ {#if tab.kind === 'data'} + {@render dataView( + tab.id, + tab.schema ?? ownSelected.schemaKey, + tab.table, + tab.id === tabs.currentData?.id + )} + {:else if tab.kind === 'diagram'} + {@render diagramView(tab.tables ?? [])} + {:else} + {@render sqlTab?.(tab)} + {/if} +
+ {/if} + {/each} + {:else if viewMode === 'diagram'} + {@render diagramView(diagramTables)} + {:else} + {@render dataView('', selected.schemaKey, selected.tableKey, true)} {/if} -
- {/if} - - + + + {/if} +
(aclDrawer = undefined)}> diff --git a/frontend/src/lib/components/DBManagerContent.svelte b/frontend/src/lib/components/DBManagerContent.svelte index b88b1d1510..8894043688 100644 --- a/frontend/src/lib/components/DBManagerContent.svelte +++ b/frontend/src/lib/components/DBManagerContent.svelte @@ -1,4 +1,8 @@ tabs?.update(tab.id, { code })} + onSchemaChange={() => refresh()} + schema={shownSchema} + /> + {/snippet} - - -
{ - // 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} - - {/key} - {/if} -
+ {#if !tabbed} + + +
{ + // 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} + + {/key} + {/if} +
+ {/if} + 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)} />
- {#if showRepl} + {#if showRepl && !tabbed} 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} /> {/if} diff --git a/frontend/src/lib/components/DBManagerDrawer.svelte b/frontend/src/lib/components/DBManagerDrawer.svelte index 2d4060a59c..bd016544b0 100644 --- a/frontend/src/lib/components/DBManagerDrawer.svelte +++ b/frontend/src/lib/components/DBManagerDrawer.svelte @@ -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('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(undefined) /** Which database an import writes into; set when driven from a tree row. */ @@ -318,17 +353,9 @@ } - - - + { 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 ?? ''}`} (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 @@ > {/key} {/if} + {#snippet titleExtra()} + {@const mainPaneLeft = dbManagerContent?.dbManager()?.mainPaneLeft()} + {@const tabs = dbManagerContent?.tabsModel()} + + {#if tabs && mainPaneLeft} + +
+ {#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} + + +
{ + 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} +
+ {/if} + + +
+ {/each} + 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()} +
+ +
+ {/snippet} +
+
+ {/if} + {/snippet} {#snippet actions()} - - {#if enableImportExport && !uriState.isDatatableInput} - - - {/if} - {#if diagramSupported} - logFeatureUsage('db_manager', 'view_mode', { key: v })} - > - {#snippet children({ item })} - - - {/snippet} - - {/if} {#if uriState.effectiveInput && ws} - workerTag.tag, (v) => (workerTag.tag = v)} - input={uriState.effectiveInput} - workspace={ws} - /> + {@const input = uriState.effectiveInput} + +
+ [ + { + 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" + /> + workerTag.tag, (v) => (workerTag.tag = v)} + bind:open={workerTagOpen} + hideTrigger + {input} + workspace={ws} + /> +
{/if} - + + {/snippet} + + + {/if} + {@render hiddenColumnsNote()} +{/snippet} + +{#snippet addColumnCell()} + {#if canAddColumn} +
+ {/if} + {#if hidden.length} +
+ {/if} +{/snippet} + + { + if (editing) cancelEdit() + closeFormatOnOutsideClick(e) + }} + onkeydown={(e) => { + if (formatting && e.key === 'Escape') formatting = undefined + }} +/>
-
-
- + +
+ {#if totalRows > 0 && endRow !== 0} + + {Math.min(firstVisible + 1, totalRows)}–{Math.min(lastVisible, totalRows)} + {#if rowCount !== undefined || endRow !== undefined} + of {endRow ?? rowCount} rows + {/if} + + {/if} + + {/snippet} + {#snippet content()} +
+ +
+ {#each columns as col (col.field)} +
+ + {#if formats[col.field]} + + {/if} + openFormat(col.field) + } + ]} + size="2xs" + class="opacity-0 group-hover:opacity-100 focus-within:opacity-100" + /> +
+ {/each} +
+ {#if onNewColumn} +
+ +
+ {/if} +
+ {/snippet} + +
+ {#if dbTableOps.onInsert} + { + if (!$operatingWorkspace) return + dbTableOps.onInsert?.({ values }).then(() => { + refresh() + sendUserToast('Row inserted') + }) + }} + /> {/if}
- {#if dbTableOps.onInsert} - { - if (!$operatingWorkspace) return - dbTableOps.onInsert?.({ values }).then((result) => { - refresh?.() - sendUserToast('Row inserted') - }) - }} - /> - {/if}
-
- + +
{ - if ((e.ctrlKey || e.metaKey) && e.key === 'c') { - const selectedCell = api?.getFocusedCell() - if (selectedCell) { - const rowIndex = selectedCell.rowIndex - const colId = selectedCell.column?.getId() - const rowNode = api?.getDisplayedRowAtIndex(rowIndex) - const selectedValue = rowNode?.data?.[colId] - navigator.clipboard.writeText(selectedValue) - sendUserToast('Copied cell value to clipboard', false) + bind:this={scrollEl} + bind:clientHeight={viewportHeight} + bind:clientWidth={viewportWidth} + bind:offsetHeight={gridOuterHeight} + bind:offsetWidth={gridOuterWidth} + class={twMerge( + 'h-full w-full overflow-x-auto overflow-y-scroll relative outline-none text-xs text-primary', + // Wider than the app's default so it is easy to grab. + '[&::-webkit-scrollbar]:w-2.5 [&::-webkit-scrollbar]:h-2.5 [&::-webkit-scrollbar-thumb]:rounded-full' + )} + tabindex="0" + role="grid" + aria-rowcount={totalRows} + data-testid="db-table-grid" + onscroll={(e) => (scrollTop = e.currentTarget.scrollTop)} + onkeydown={onGridKeyDown} + onclick={(e) => (selected = cellFromEvent(e))} + ondblclick={(e) => { + const cell = cellFromEvent(e) + if (cell) startEdit(cell.row, cell.column) + }} + > +
+ +
+ {#each displayColumns as col (col.field)} + {@const sorted = sort?.column === col.field ? sort : undefined} + {@const pin = pinned[col.field]} + {#if col.field === firstRightPinned} + {@render addColumnHeader()} +
+ {/if} +
+ + +
startResize(e, col.field)} + ondblclick={(e) => { + e.stopPropagation() + autoSize([col.field]) + }} + >
+
+ {/each} + {#if !firstRightPinned} + {@render addColumnHeader()} +
+ {/if} +
+ + {#if endRow === 0} +
+ {hasFilters ? 'No rows match the filters' : 'No rows'} +
+ {/if} + +
+ {#each renderedIndices as i (i)} + {@const data = rowAt(i)} +
+ {#each displayColumns as col (col.field)} + {@const isSelected = selected?.row === i && selected.column === col.field} + {@const pin = pinned[col.field]} + {@const style = + data && data !== 'loading' + ? cellStyle(data[col.field], col.datatype, formats[col.field]) + : undefined} + {#if col.field === firstRightPinned} + {@render addColumnCell()} +
+ {/if} +
+ {#if pin} + +
+ {/if} + {#if data === 'loading'} +
+ {:else if data} + {@const value = data[col.field]} + {@const format = formats[col.field]} + {@const shown = formatValue(value, format) ?? formatCell(value)} + {#if value === null || value === undefined} + NULL + {:else if fkByColumn[col.field] && hasForeignKeyValue(value)} + {@const fk = fkByColumn[col.field]} + + {:else} + {shown} + {/if} + {/if} +
+ {/each} + {#if !firstRightPinned} + {@render addColumnCell()} +
+ {/if} +
+ {/each} +
+
+
+
+ + {#if gridOuterWidth - viewportWidth > 0} +
+ {/if} + {#if gridOuterHeight - viewportHeight > 0} +
+ {/if} +
+
+ + { + if (editing) { + const el = cellElement(editing.row, editing.column) + if (el) editorRect = el.getBoundingClientRect() + } + return editorRect + }} +> + +
e.stopPropagation()}> + {#if editing} + { + if (e.key === 'Enter') { + e.preventDefault() + commitEdit() + } else if (e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + cancelEdit() } } }} - >
-
- -
-
- - {#snippet text()} - Download - {/snippet} -
- {#if rowCount} - {firstRow}{'->'}{lastRow + 1} of {rowCount} rows - {:else} - {firstRow}{'->'}{lastRow + 1} - {/if} -
+ /> +
Enter to save, Esc to cancel
+ {/if} - + + + { + if (formatting) { + const header = scrollEl?.querySelector(`[data-header-column="${CSS.escape(formatting)}"]`) + // A hidden column has no header: its format opens from the columns button. + const el = header ?? columnsAnchor + if (el) formatRect = el.getBoundingClientRect() + } + return formatRect + }} +> +
+ {#if formatting} + {@const column = formatting} + setFormat(column, f)} + /> + {/if} +
+
diff --git a/frontend/src/lib/components/DbColumnFormatEditor.svelte b/frontend/src/lib/components/DbColumnFormatEditor.svelte new file mode 100644 index 0000000000..36f98574a1 --- /dev/null +++ b/frontend/src/lib/components/DbColumnFormatEditor.svelte @@ -0,0 +1,362 @@ + + +{#snippet sample(style: { bg?: string; text?: string; bold?: boolean; italic?: boolean })} + + Aa + +{/snippet} + +{#snippet presets(onPick: (preset: (typeof RULE_PRESETS)[number]) => void)} +
+ {#each RULE_PRESETS as preset, i (i)} + + {/each} +
+{/snippet} + +
+
+
+ Format {column} + Only changes how values show in this view. +
+ +
+ +
+ Unit +
+ ({ label: String(i), value: i })) + ]} + bind:value={ + () => format?.decimals ?? AUTO_DECIMALS, + (v) => update({ decimals: v === AUTO_DECIMALS ? undefined : v }) + } + /> + format?.notation ?? 'plain', + (v) => update({ notation: v === 'plain' ? undefined : (v as 'thousands' | 'compact') }) + } + > + {#snippet children({ item })} + + + + {/snippet} + +
+ + 1234567.891 shows as {formatValue(1234567.891, format) ?? '1234567.891'} + +
+ +
+ Alignment + format?.align ?? 'auto', + (v) => update({ align: v === 'auto' ? undefined : (v as 'left' | 'right') }) + } + > + {#snippet children({ item })} + + + + {/snippet} + +
+ +
+ Color rules + {#each rules as rule, i (i)} + +
{ + 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} +
+ {/if} + +
{ + dragFrom = i + const row = e.currentTarget.parentElement + if (row) e.dataTransfer?.setDragImage(row, 8, row.offsetHeight / 2) + }} + ondragend={() => { + dragFrom = undefined + dropAt = undefined + }} + > + +
+
+ = 4, =paid' }} + bind:value={() => rule.condition, (v) => setRule(i, { condition: v })} + /> +
+ setRule(i, { bg: e.currentTarget.value })} + /> + setRule(i, { text: e.currentTarget.value })} + /> +
+ {/each} +
+ + {#snippet trigger()} + + {/snippet} + {#snippet content()} + {@render presets((preset) => { + update({ rules: [...rules, { condition: '', ...preset }] }) + addRuleOpen = false + })} + {/snippet} + +
+ + Conditions use the search bar's syntax: text contains, =value is exact, numbers take >, + >=, <, <= and !=. An empty condition matches every cell. Every matching rule applies, + a later one overriding an earlier one. + +
+
diff --git a/frontend/src/lib/components/DbForeignKeyTooltip.svelte b/frontend/src/lib/components/DbForeignKeyTooltip.svelte deleted file mode 100644 index 526d6b2b69..0000000000 --- a/frontend/src/lib/components/DbForeignKeyTooltip.svelte +++ /dev/null @@ -1,23 +0,0 @@ - - -
-
- References {targetTable}.{targetColumn} -
- -
diff --git a/frontend/src/lib/components/DbSqlTab.svelte b/frontend/src/lib/components/DbSqlTab.svelte new file mode 100644 index 0000000000..a22f89c883 --- /dev/null +++ b/frontend/src/lib/components/DbSqlTab.svelte @@ -0,0 +1,63 @@ + + + + + (result = data)} + /> + + + {#if resultOps} + + {#key resultOps} + + {/key} + {:else} +
+ Run a query to see its result here +
+ {/if} +
+
diff --git a/frontend/src/lib/components/DbWorkerTagButton.svelte b/frontend/src/lib/components/DbWorkerTagButton.svelte index 3cbb60dc53..70db48b956 100644 --- a/frontend/src/lib/components/DbWorkerTagButton.svelte +++ b/frontend/src/lib/components/DbWorkerTagButton.svelte @@ -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)) - + open ?? false, (v) => (open = v)} + class={hideTrigger ? 'w-0 h-0 overflow-hidden' : undefined} + triggerAttrs={hideTrigger ? { tabindex: -1, 'aria-hidden': true } : undefined} +> {#snippet trigger()} - + {#if !hideTrigger} + + {/if} {/snippet} {#snippet content()}
diff --git a/frontend/src/lib/components/Editor.svelte b/frontend/src/lib/components/Editor.svelte index 6aabc30c2b..5ec3c62b5e 100644 --- a/frontend/src/lib/components/Editor.svelte +++ b/frontend/src/lib/components/Editor.svelte @@ -141,6 +141,9 @@ awareness?: any | undefined folding?: boolean args?: Record | 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') // 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, diff --git a/frontend/src/lib/components/FilterSearchbar.svelte b/frontend/src/lib/components/FilterSearchbar.svelte index 3a4833866a..30adb56c7c 100644 --- a/frontend/src/lib/components/FilterSearchbar.svelte +++ b/frontend/src/lib/components/FilterSearchbar.svelte @@ -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 = $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'} + + {/if} { @@ -752,7 +770,7 @@ /> {#if asText.val} (_value.current = {})} /> - {:else} + {:else if searchIconPosition === 'right'}
@@ -762,7 +780,11 @@ 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 > diff --git a/frontend/src/lib/components/SqlRepl.svelte b/frontend/src/lib/components/SqlRepl.svelte index d58ac65898..b5bdcb872a 100644 --- a/frontend/src/lib/components/SqlRepl.svelte +++ b/frontend/src/lib/components/SqlRepl.svelte @@ -1,5 +1,5 @@ - - - {#await import('$lib/components/Editor.svelte')} - - {:then Module} - - {/await} - - - - { - const data = e.detail as (typeof runHistory)[number] - editor?.setCode(data.code) - onData(data.result, data.code) - }} +
+ {#await import('$lib/components/Editor.svelte')} + + {:then Module} + code, (v) => ((code = v), onCodeChange?.(v))} + scriptLang="mysql" + sqlSchema={schema} + class="w-full h-full" + cmdEnterAction={run} /> - - + {/await} + + + {#if historyOpen} +
+
+ Run history +
+
+ { + const data = e.detail as (typeof runHistory)[number] + editor?.setCode(data.code) + onData(data.result, data.code) + }} + /> +
+
+ {:else} +
+
+ {/if} +
{#if datatableName && ws} - 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) => void | Promise + /** 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 = $state({}) let insertDrawer: Drawer | undefined = $state() @@ -35,7 +37,8 @@ + {#if unavailable} + + + + + {:else} + + {/if} {/if}