diff --git a/quadratic-api/src/ai/docs/ConnectionDocs.ts b/quadratic-api/src/ai/docs/ConnectionDocs.ts
index 36f1a322c5..7186414d29 100644
--- a/quadratic-api/src/ai/docs/ConnectionDocs.ts
+++ b/quadratic-api/src/ai/docs/ConnectionDocs.ts
@@ -14,6 +14,7 @@ There are some slight differences between SQL syntax across databases to keep in
* In Postgres it is best practice use quotes around table names and column names.
* In MySQL it is best practice to use backticks around table names and column names.
* In MS SQL Server it is best practice to use double quotes around table names and column names.
+* In Snowflake it is best practice to use double quotes around table names and column names.
* BIGQUERY uses Standard SQL with nested and repeated fields, requiring backticks for table references and GoogleSQL functions for analytics\n
* COCKROACHDB, SUPABASE and NEON have the same syntax as POSTGRES
* MARIADB has the same syntax as MySQL
@@ -22,16 +23,87 @@ In PostgreSQL, identifiers like table names and column names that contain spaces
## SQL references
-You can create parametrized SQL queries that reference sheet data by using {{}} notation.
+You can create parametrized SQL queries that reference sheet data by using {{}} notation. This may include one cell, or a 1d range of cells. For example, if you want to reference the cell A1, you would use {{A1}}. If you want to reference the cells A1:A5, you would use {{A1:A5}}.
-### Example
+Note, this will add the naked values of the cells to the query. It will not place quotation marks around those values. So if the SQL query needs quotation marks, you will need to add them yourself (e.g., '{{A1}}').
-Parametrized queries in SQL can only read single cells from the file. They can only be read using A1 notation.
+You may also reference table columns using the A1 table column reference, eg, {{Table1[Column name]}}. When referencing a table column, Quadratic will insert the values as a comma-delimited list (e.g., 123,456,789). Your SQL query must account for this format.
+
+IMPORTANT: Since Quadratic inserts raw comma-delimited values without quotes, this works well for numeric values with the IN clause. For string values, you'll need to use database-specific functions:
+- MySQL/MariaDB: FIND_IN_SET()
+- PostgreSQL/CockroachDB/Supabase/Neon: string_to_array() with = ANY or UNNEST
+- MS SQL Server: STRING_SPLIT()
+- BigQuery: SPLIT() with UNNEST
+- Snowflake: SPLIT() with ARRAY_CONTAINS or IN with TABLE(FLATTEN())
+
+If you're working with a connection type not listed above, you'll need to research how that specific database handles comma-delimited string values in SQL queries. Look for string splitting or array functions that can convert the naked comma-delimited list into a format that can be used with IN clauses or comparison operators.
+
+### Examples
+
+#### Single Cell References
+
+Parametrized queries in SQL can read single cells from the file. They can only be read using A1 notation.
\`\`\`sql
SELECT * FROM {{A1}} WHERE {{column_name}} = {{Sheet2!B7}}
\`\`\`
+#### MySQL Examples
+
+\`\`\`mysql
+-- For numeric values, use IN clause
+SELECT * FROM \`users\` WHERE \`user_id\` IN ({{Table1[User ID]}})
+
+-- For string values, use FIND_IN_SET (searches if column value exists in the comma-delimited list)
+SELECT * FROM \`users\` WHERE FIND_IN_SET(\`email\`, '{{Table1[Email]}}') > 0
+\`\`\`
+
+#### PostgreSQL Examples (also applies to CockroachDB, Supabase, Neon)
+
+\`\`\`sql
+-- For numeric values, use IN clause
+SELECT * FROM "users" WHERE "user_id" IN ({{Table1[User ID]}})
+
+-- For string values, use = ANY with string_to_array
+SELECT * FROM "users" WHERE "email" = ANY(string_to_array('{{Table1[Email]}}', ','))
+
+-- Alternative for strings: use IN with UNNEST
+SELECT * FROM "users" WHERE "email" IN (SELECT unnest(string_to_array('{{Table1[Email]}}', ',')))
+\`\`\`
+
+#### MS SQL Server Examples
+
+\`\`\`sql
+-- For numeric values, use IN clause
+SELECT * FROM "users" WHERE "user_id" IN ({{Table1[User ID]}})
+
+-- For string values, use STRING_SPLIT (SQL Server 2016+)
+SELECT * FROM "users" WHERE "email" IN (SELECT value FROM STRING_SPLIT('{{Table1[Email]}}', ','))
+\`\`\`
+
+#### BigQuery Examples
+
+\`\`\`sql
+-- For numeric values, use IN clause
+SELECT * FROM \`project.dataset.users\` WHERE \`user_id\` IN ({{Table1[User ID]}})
+
+-- For string values, use SPLIT
+SELECT * FROM \`project.dataset.users\` WHERE \`email\` IN UNNEST(SPLIT('{{Table1[Email]}}', ','))
+\`\`\`
+
+#### Snowflake Examples
+
+\`\`\`sql
+-- For numeric values, use IN clause
+SELECT * FROM "users" WHERE "user_id" IN ({{Table1[User ID]}})
+
+-- For string values, use ARRAY_CONTAINS with SPLIT
+SELECT * FROM "users" WHERE ARRAY_CONTAINS("email"::VARIANT, SPLIT('{{Table1[Email]}}', ','))
+
+-- Alternative for strings: use IN with TABLE(FLATTEN())
+SELECT * FROM "users" WHERE "email" IN (SELECT value::STRING FROM TABLE(FLATTEN(SPLIT('{{Table1[Email]}}', ','))))
+\`\`\`
+
## Getting Schema from Database
Use the get_database_schemas tool to get the schema of a database.
diff --git a/quadratic-client/src/app/grid/sheet/SheetCursor.ts b/quadratic-client/src/app/grid/sheet/SheetCursor.ts
index cfaca08778..eb3cb25cf1 100644
--- a/quadratic-client/src/app/grid/sheet/SheetCursor.ts
+++ b/quadratic-client/src/app/grid/sheet/SheetCursor.ts
@@ -523,4 +523,8 @@ export class SheetCursor {
this.sheets.jsA1Context
);
};
+
+ is1dRange = (): boolean => {
+ return this.jsSelection.is1dRange(this.sheets.jsA1Context);
+ };
}
diff --git a/quadratic-client/src/app/ui/QuadraticSidebar.tsx b/quadratic-client/src/app/ui/QuadraticSidebar.tsx
index d4b3b924a3..a76e938734 100644
--- a/quadratic-client/src/app/ui/QuadraticSidebar.tsx
+++ b/quadratic-client/src/app/ui/QuadraticSidebar.tsx
@@ -50,6 +50,7 @@ export const QuadraticSidebar = () => {
to="/"
reloadDocument
className="group relative flex h-9 w-9 items-center justify-center rounded text-muted-foreground hover:bg-border"
+ data-testid="back-to-dashboard-link"
>
{isRunningAsyncAction && (
diff --git a/quadratic-client/src/app/ui/menus/CodeEditor/CodeEditorRefButton.tsx b/quadratic-client/src/app/ui/menus/CodeEditor/CodeEditorRefButton.tsx
index 721d8516cd..f3066d220d 100644
--- a/quadratic-client/src/app/ui/menus/CodeEditor/CodeEditorRefButton.tsx
+++ b/quadratic-client/src/app/ui/menus/CodeEditor/CodeEditorRefButton.tsx
@@ -29,7 +29,7 @@ export const CodeEditorRefButton = () => {
} else {
// for connections, we currently only support one cursor position
if (codeCellIsAConnection(codeEditor.language)) {
- setDisabled(!sheets.sheet.cursor.isSingleSelection());
+ setDisabled(!sheets.sheet.cursor.isSingleSelection() && !sheets.sheet.cursor.is1dRange());
} else {
setDisabled(false);
}
@@ -48,7 +48,7 @@ export const CodeEditorRefButton = () => {
!disabled
? `Insert ${relative ? 'relative ' : ''}cell reference`
: codeCellIsAConnection(codeEditor.language)
- ? `Select only one cell to insert cell reference.`
+ ? `Select only one cell or a 1d range of cells to insert cell reference.`
: `Select cells on the grid to insert cell reference.`,
[codeEditor.language, disabled, relative]
);
diff --git a/quadratic-core/src/a1/a1_selection/query.rs b/quadratic-core/src/a1/a1_selection/query.rs
index 61955cc547..a53a132a00 100644
--- a/quadratic-core/src/a1/a1_selection/query.rs
+++ b/quadratic-core/src/a1/a1_selection/query.rs
@@ -441,6 +441,46 @@ impl A1Selection {
|| (one_cell && range.is_single_cell(a1_context))
}
+ /// Returns true if the selection is a 1d range (ie, a list of columns or rows)
+ pub fn is_1d_range(&self, a1_context: &A1Context) -> bool {
+ if self.ranges.len() != 1 {
+ return false;
+ }
+ let Some(range) = self.ranges.first() else {
+ return false;
+ };
+
+ // checks if the range is a single cell
+ if range.is_single_cell(a1_context) {
+ return true;
+ }
+
+ // checks if the range is a single column or row range
+ if let CellRefRange::Sheet { range: sheet_range } = &range
+ && ((sheet_range.start.col() == sheet_range.end.col()
+ && sheet_range.start.col() != UNBOUNDED)
+ || (sheet_range.start.row() == sheet_range.end.row()
+ && sheet_range.start.row() != UNBOUNDED))
+ {
+ return true;
+ }
+
+ // checks table ranges
+ if let CellRefRange::Table { range: table_range } = &range
+ && let Some(table) = a1_context.try_table(&table_range.table_name)
+ {
+ // a single column in a table
+ if matches!(table_range.col_range, ColRange::Col(_)) {
+ return true;
+ }
+ // the entire table with a table width == 1
+ if matches!(table_range.col_range, ColRange::All) && table.bounds.width() == 1 {
+ return true;
+ }
+ }
+ false
+ }
+
/// Returns true if the selection can insert column or row:
/// The selection is a single range AND
/// 1. is a column or row selection OR
@@ -1901,4 +1941,49 @@ mod tests {
assert!(!A1Selection::test_a1("B2:").can_insert_column_row());
assert!(!A1Selection::test_a1("*").can_insert_column_row());
}
+
+ #[test]
+ fn test_is_1d_range() {
+ let context = A1Context::test(
+ &[],
+ &[
+ ("Table1", &["A"], Rect::test_a1("A1:A4")), // Single column table
+ ("Table2", &["A", "B"], Rect::test_a1("C1:D4")), // Multi-column table
+ ],
+ );
+
+ // Test single column selections
+ assert!(A1Selection::test_a1("A").is_1d_range(&context));
+ assert!(A1Selection::test_a1("B").is_1d_range(&context));
+
+ // Test single row selections
+ assert!(A1Selection::test_a1("1").is_1d_range(&context));
+ assert!(A1Selection::test_a1("2").is_1d_range(&context));
+
+ // Test single cell selections
+ assert!(A1Selection::test_a1("A1").is_1d_range(&context));
+ assert!(A1Selection::test_a1("B2").is_1d_range(&context));
+
+ // Test a range of columns
+ assert!(A1Selection::test_a1("A3:C3").is_1d_range(&context));
+ assert!(A1Selection::test_a1("D10:E10").is_1d_range(&context));
+
+ // Test a range of rows
+ assert!(A1Selection::test_a1("A3:A5").is_1d_range(&context));
+ assert!(A1Selection::test_a1("D10:D12").is_1d_range(&context));
+
+ // Test table selections
+ assert!(A1Selection::test_a1_context("Table1", &context).is_1d_range(&context)); // Single column table
+ assert!(!A1Selection::test_a1_context("Table2", &context).is_1d_range(&context)); // Multi-column table
+ assert!(A1Selection::test_a1_context("Table2[A]", &context).is_1d_range(&context)); // Single column from table
+
+ // Test non-1D ranges (should be false)
+ assert!(!A1Selection::test_a1("A:B").is_1d_range(&context)); // Multiple columns
+ assert!(!A1Selection::test_a1("1:2").is_1d_range(&context)); // Multiple rows
+ assert!(!A1Selection::test_a1("A1:B2").is_1d_range(&context)); // Rectangle
+ assert!(!A1Selection::test_a1("A1,B2").is_1d_range(&context)); // Multiple cells
+ assert!(!A1Selection::test_a1("A,B").is_1d_range(&context)); // Multiple columns
+ assert!(!A1Selection::test_a1("1,2").is_1d_range(&context)); // Multiple rows
+ assert!(!A1Selection::test_a1("*").is_1d_range(&context)); // All cells
+ }
}
diff --git a/quadratic-core/src/controller/execution/run_code/run_connection.rs b/quadratic-core/src/controller/execution/run_code/run_connection.rs
index 2712d07bcc..d73b669c05 100644
--- a/quadratic-core/src/controller/execution/run_code/run_connection.rs
+++ b/quadratic-core/src/controller/execution/run_code/run_connection.rs
@@ -1,13 +1,32 @@
use anyhow::Result;
use crate::{
- RunError, RunErrorMsg, SheetPos,
+ Pos, Rect, RunError, RunErrorMsg, SheetPos,
a1::{A1Error, A1Selection},
controller::{GridController, active_transactions::pending_transaction::PendingTransaction},
- grid::{CodeCellLanguage, CodeCellValue, ConnectionKind, HANDLEBARS_REGEX_COMPILED, SheetId},
+ grid::{
+ CodeCellLanguage, CodeCellValue, ConnectionKind, HANDLEBARS_REGEX_COMPILED, Sheet, SheetId,
+ },
};
impl GridController {
+ /// Returns a string of cells for a connection. For more than one cell, the
+ /// cells are comma-delimited.
+ pub fn get_cells_comma_delimited_string(sheet: &Sheet, rect: Rect) -> String {
+ let mut response = String::new();
+ for y in rect.y_range() {
+ for x in rect.x_range() {
+ if let Some(cell) = sheet.display_value(Pos { x, y }) {
+ if !response.is_empty() {
+ response.push(',');
+ }
+ response.push_str(&cell.to_get_cells());
+ }
+ }
+ }
+ response
+ }
+
/// Attempts to replace handlebars with the actual value from the grid
fn replace_handlebars(
&self,
@@ -37,25 +56,32 @@ impl GridController {
let content = cap.get(1).map(|m| m.as_str().trim()).unwrap_or("");
let selection = A1Selection::parse_a1(content, default_sheet_id, context)?;
- let Some(pos) = selection.try_to_pos(context) else {
+ // connections support either one cell or a 1d range of cells (ie,
+ // one column or row), which are entered as a comma-delimited list
+ // of entries (e.g., "2,3,10,1,...") in the query
+
+ if !selection.is_1d_range(context) {
return Err(A1Error::WrongCellCount(
- "Connections only supports one cell".to_string(),
+ "Connections only supports one cell or a 1d range of cells".to_string(),
));
- };
+ }
let Some(sheet) = self.try_sheet(selection.sheet_id) else {
return Err(A1Error::SheetNotFound);
};
- let value = sheet
- .display_value(pos)
- .map(|value| value.to_display())
- .unwrap_or_default();
+ let rects = sheet.selection_to_rects(&selection, false, false, true, context);
+ if rects.len() > 1 {
+ return Err(A1Error::WrongCellCount(
+ "Connections only supports one cell or a 1d range of cells".to_string(),
+ ));
+ }
+ let rect = rects[0];
+ result.push_str(&Self::get_cells_comma_delimited_string(sheet, rect));
transaction
.cells_accessed
- .add_sheet_pos(SheetPos::new(sheet.id, pos.x, pos.y));
- result.push_str(&value);
+ .add_sheet_rect(rect.to_sheet_rect(sheet.id));
last_match_end = whole_match.end();
}
@@ -124,6 +150,7 @@ mod tests {
GridController, active_transactions::pending_transaction::PendingTransaction,
},
grid::{CodeCellLanguage, ConnectionKind, SheetId},
+ test_util::*,
};
#[test]
@@ -275,4 +302,60 @@ mod tests {
test_error(&mut gc, r#"{{'Sheet 2'!A2}}"#, sheet_id);
test_error(&mut gc, r#"{{'Sheet 2'!$A$2}}"#, sheet_id);
}
+
+ #[test]
+ fn test_get_cells_for_connections() {
+ use crate::Rect;
+
+ let mut gc = test_create_gc();
+ let sheet_id = first_sheet_id(&gc);
+
+ // Test single cell
+ gc.set_cell_value(pos![sheet_id!A1], "test".to_string(), None, false);
+
+ assert_eq!(
+ GridController::get_cells_comma_delimited_string(
+ gc.sheet(sheet_id),
+ Rect::test_a1("A1")
+ ),
+ "test"
+ );
+
+ // Test multiple cells in the same row
+ gc.set_cell_value(pos![sheet_id!A2], "123".to_string(), None, false);
+ assert_eq!(
+ GridController::get_cells_comma_delimited_string(
+ gc.sheet(sheet_id),
+ Rect::test_a1("A1:A2")
+ ),
+ "test,123"
+ );
+
+ // Test multiple cells in the same column
+ gc.set_cell_value(pos![sheet_id!B1], "456".to_string(), None, false);
+ assert_eq!(
+ GridController::get_cells_comma_delimited_string(
+ gc.sheet(sheet_id),
+ Rect::test_a1("A1:B1")
+ ),
+ "test,456"
+ );
+
+ // test code cells
+ gc.set_code_cell(
+ pos![sheet_id!C1],
+ CodeCellLanguage::Formula,
+ "=A2 * 2".to_string(),
+ None,
+ None,
+ false,
+ );
+ assert_eq!(
+ GridController::get_cells_comma_delimited_string(
+ gc.sheet(sheet_id),
+ Rect::test_a1("A1:C1")
+ ),
+ "test,456,246"
+ );
+ }
}
diff --git a/quadratic-core/src/wasm_bindings/js_selection/query.rs b/quadratic-core/src/wasm_bindings/js_selection/query.rs
index 41f154b8de..2e540ff3f6 100644
--- a/quadratic-core/src/wasm_bindings/js_selection/query.rs
+++ b/quadratic-core/src/wasm_bindings/js_selection/query.rs
@@ -348,4 +348,9 @@ impl JsSelection {
.map(|c| *c as u32)
.collect()
}
+
+ #[wasm_bindgen(js_name = "is1dRange")]
+ pub fn is_1d_range(&self, context: &JsA1Context) -> bool {
+ self.selection.is_1d_range(context.get_context())
+ }
}
diff --git a/test/e2e/src/helpers/auth.helpers.ts b/test/e2e/src/helpers/auth.helpers.ts
index 67cfe3f958..ca8485e9f3 100644
--- a/test/e2e/src/helpers/auth.helpers.ts
+++ b/test/e2e/src/helpers/auth.helpers.ts
@@ -50,11 +50,12 @@ export const logIn = async (page: Page, options: LogInOptions): Promise
await handleQuadraticLoading(page);
// go to dashboard if in app
- const dashboardLink = page.locator('nav a[href="/"]');
- while (await dashboardLink.isVisible()) {
- await dashboardLink.click({ timeout: 60 * 1000 });
- await handleQuadraticLoading(page);
- }
+ const dashboardLink = page.locator('[data-testid="back-to-dashboard-link"]');
+ await dashboardLink.waitFor({ state: 'visible', timeout: 60 * 1000 });
+ await dashboardLink.click({ timeout: 60 * 1000 });
+ await handleQuadraticLoading(page);
+ // Wait a while to ensure navigation completes
+ await page.waitForTimeout(5 * 1000);
// If onboarding video is shown, click "Skip" to proceed
const getStartedHeader = page.locator('h1:has-text("Get started")');