Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 75 additions & 3 deletions quadratic-api/src/ai/docs/ConnectionDocs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
86 changes: 86 additions & 0 deletions quadratic-core/src/a1/a1_selection/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,47 @@ 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 {
if (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
Expand Down Expand Up @@ -1901,4 +1942,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
}
}
95 changes: 83 additions & 12 deletions quadratic-core/src/controller/execution/run_code/run_connection.rs
Original file line number Diff line number Diff line change
@@ -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_for_connections(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,
Expand Down Expand Up @@ -37,32 +56,39 @@ 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_for_connections(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();
}

// Add the remaining part of the string
result.push_str(&code[last_match_end..]);

dbgjs!(&result);
Ok(result)
}

Expand Down Expand Up @@ -124,6 +150,7 @@ mod tests {
GridController, active_transactions::pending_transaction::PendingTransaction,
},
grid::{CodeCellLanguage, ConnectionKind, SheetId},
test_util::*,
};

#[test]
Expand Down Expand Up @@ -275,4 +302,48 @@ 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_for_connections(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_for_connections(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_for_connections(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_for_connections(gc.sheet(sheet_id), Rect::test_a1("A1:C1")),
"test,456,246"
);
}
}
Loading