Skip to content

Commit

Permalink
Merge pull request #2150 from quadratichq/data-tables-ref
Browse files Browse the repository at this point in the history
Data tables references
  • Loading branch information
davidfig authored Dec 26, 2024
2 parents 45e9432 + e7c002d commit ec4117f
Show file tree
Hide file tree
Showing 7 changed files with 969 additions and 0 deletions.
17 changes: 17 additions & 0 deletions quadratic-core/src/a1/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ pub enum A1Error {
InvalidExclusion(String),
TranslateInvalid(String),
SheetNotFound,

InvalidTableRef(String),
TableNotFound(String),
MultipleRowDefinitions,
UnexpectedRowNumber,
InvalidRowRange(String),
}

impl From<A1Error> for String {
Expand Down Expand Up @@ -46,6 +52,17 @@ impl std::fmt::Display for A1Error {
A1Error::InvalidExclusion(msg) => write!(f, "Invalid Exclusion: {msg}"),
A1Error::TranslateInvalid(msg) => write!(f, "Translate Invalid: {msg}"),
A1Error::SheetNotFound => write!(f, "Sheet Not Found"),

A1Error::InvalidTableRef(msg) => write!(f, "Invalid Table Ref: {msg}"),
A1Error::TableNotFound(msg) => write!(f, "Table Not Found: {msg}"),
A1Error::MultipleRowDefinitions => {
write!(f, "Table reference may only have one row definition")
}
A1Error::UnexpectedRowNumber => write!(
f,
"Row numbers in tables must be defined with # (e.g., [#12,15-12])"
),
A1Error::InvalidRowRange(msg) => write!(f, "Invalid row range: {msg}"),
}
}
}
2 changes: 2 additions & 0 deletions quadratic-core/src/a1/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ mod error;
mod js_selection;
mod ref_range_bounds;
mod sheet_cell_ref_range;
mod table_ref;

pub use a1_selection::*;
pub use a1_sheet_name::*;
Expand All @@ -19,6 +20,7 @@ pub use error::*;
pub use js_selection::*;
pub use ref_range_bounds::*;
pub use sheet_cell_ref_range::*;
pub use table_ref::*;

/// Name to use when a sheet ID has no corresponding name.
///
Expand Down
94 changes: 94 additions & 0 deletions quadratic-core/src/a1/table_ref/column_range.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
//! ColumnRange and RowRange
//!
//! These are used to defined the range of columns and rows within a table. Any
//! table reference may only have one list of row ranges, and any number of
//! column ranges.
//!
//! We serialize/deserialize RowRangeEntry#End to -1 if equal to UNBOUNDED
//! (i64::MAX). This is to ensure compatibility with JS.
//!
//! i64 is used to maintain compatibility with CellRefCoord.
use serde::{Deserialize, Serialize};
use ts_rs::TS;

use crate::CellRefCoord;

#[derive(Clone, Debug, Eq, Hash, PartialEq, TS, Serialize, Deserialize)]
pub struct RowRangeEntry {
pub start: CellRefCoord,
pub end: CellRefCoord,
}

impl RowRangeEntry {
pub fn new_rel(start: i64, end: i64) -> Self {
Self {
start: CellRefCoord::new_rel(start),
end: CellRefCoord::new_rel(end),
}
}

pub fn new_abs(start: i64, end: i64) -> Self {
Self {
start: CellRefCoord::new_abs(start),
end: CellRefCoord::new_abs(end),
}
}
}

#[derive(Default, Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize, TS)]
pub enum RowRange {
#[default]
All,
CurrentRow,
Rows(Vec<RowRangeEntry>),
}

#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize, TS)]
pub enum ColRange {
Col(String),
ColRange(String, String),
ColumnToEnd(String),
}

#[cfg(test)]
#[serial_test::parallel]
mod tests {
use super::*;

#[test]
fn test_row_range_entry_new() {
let row_range = RowRangeEntry::new_rel(1, 15);
assert_eq!(row_range.start, CellRefCoord::new_rel(1));
assert_eq!(row_range.end, CellRefCoord::new_rel(15));
}

#[test]
fn test_row_range_serialization() {
let row_range = RowRangeEntry {
start: CellRefCoord::new_rel(1),
end: CellRefCoord::new_rel(i64::MAX),
};
let serialized = serde_json::to_string(&row_range).unwrap();
assert_eq!(
serialized,
r#"{"start":{"coord":1,"is_absolute":false},"end":{"coord":-1,"is_absolute":false}}"#
);

let deserialized: RowRangeEntry = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized, row_range);

let row_range = RowRangeEntry {
start: CellRefCoord::new_rel(10),
end: CellRefCoord::new_rel(15),
};
let serialized = serde_json::to_string(&row_range).unwrap();
assert_eq!(
serialized,
r#"{"start":{"coord":10,"is_absolute":false},"end":{"coord":15,"is_absolute":false}}"#
);

let deserialized: RowRangeEntry = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized, row_range);
}
}
128 changes: 128 additions & 0 deletions quadratic-core/src/a1/table_ref/display.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
//! Display TableRef as a string.
use crate::UNBOUNDED;

use super::*;

impl TableRef {
/// Returns true if the table reference is the default table reference.
pub fn is_default(&self) -> bool {
self.data
&& !self.headers
&& !self.totals
&& self.row_ranges == RowRange::All
&& self.col_ranges.is_empty()
}

fn row_range_entry_to_string(entry: &RowRangeEntry) -> String {
if entry.start.coord == 1 && entry.end.coord == UNBOUNDED {
return String::default();
}
let start = entry.start.coord.to_string();
let end = if entry.end.coord == UNBOUNDED {
"".to_string()
} else {
entry.end.coord.to_string()
};

if start == end {
format!("[#{}]", start)
} else {
format!("[#{}:{}]", start, end)
}
}

/// Returns the string representation of the row range.
fn row_range_to_string(&self) -> Vec<String> {
match &self.row_ranges {
RowRange::All => vec![],
RowRange::CurrentRow => vec!["[#THIS ROW]".to_string()],
RowRange::Rows(rows) => rows
.iter()
.map(TableRef::row_range_entry_to_string)
.collect::<Vec<String>>(),
}
}

fn col_range_entry_to_string(entry: &ColRange) -> String {
match entry {
ColRange::Col(col) => format!("[{}]", col),
ColRange::ColRange(start, end) => format!("[{}]:[{}]", start, end),
ColRange::ColumnToEnd(col) => format!("[{}]:", col),
}
}

fn col_ranges_to_string(&self) -> Vec<String> {
self.col_ranges
.iter()
.map(TableRef::col_range_entry_to_string)
.collect::<Vec<String>>()
}

/// Returns the string representation of the table reference.
pub fn to_string(&self) -> String {
if self.is_default() {
return self.table_name.to_string();
}

let mut entries = vec![];

// only show special markers if not default, which is #[DATA] only
if !(self.data && !self.headers && !self.totals) {
if self.headers && self.data && self.totals {
entries.push("[#ALL]".to_string());
} else {
if self.data {
entries.push("[#DATA]".to_string());
}
if self.headers {
entries.push("[#HEADERS]".to_string());
}
if self.totals {
entries.push("[#TOTALS]".to_string());
}
}
}
entries.extend(self.row_range_to_string());
entries.extend(self.col_ranges_to_string());

format!("{}[{}]", self.table_name.to_string(), entries.join(","))
}
}

#[cfg(test)]
#[serial_test::parallel]
mod tests {
use super::*;

#[test]
fn test_to_string_only_table_name() {
let names = vec!["Table1".to_string()];
let table_ref = TableRef::parse("Table1", &names).unwrap_or_else(|e| {
panic!("Failed to parse Table1: {}", e);
});
assert_eq!(table_ref.to_string(), "Table1");
}

#[test]
fn test_to_string() {
let names = vec!["Table1".to_string()];
let tests = [
"Table1[[#12:]]",
"Table1[[#12:15]]",
"Table1[[#12:]]",
"Table1[[#ALL]]",
"Table1[[#HEADERS],[#TOTALS]]",
"Table1[[#HEADERS],[Column 1]]",
"Table1[[#HEADERS],[Column 1],[Column 2]]",
"Table1[[#HEADERS],[Column 1],[Column 2],[Column 3]:[Column 4],[Column 6]]",
"Table1[[#DATA],[#HEADERS],[Column 1]]",
];

for test in tests {
let table_ref = TableRef::parse(test, &names)
.unwrap_or_else(|e| panic!("Failed to parse {}: {}", test, e));
assert_eq!(table_ref.to_string(), test, "{}", test);
}
}
}
81 changes: 81 additions & 0 deletions quadratic-core/src/a1/table_ref/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//! A reference to data within a table.
//!
//! Table name rules:
//! - may not have spaces (TODO)
//! - maximum of 255 characters (TODO)
//! - must be unique across the .grid file
//!
//! Table references do not require ''
//!
//! Table references:
//! - Table1[Column Name] - reference only the data in that column
//! - Table1[[Column 1]:[Column 3]] - all data within the range of the columns
//! - Table1[[Column 1],[Column 3]] - all data within the list of columns
//! - (not yet supported) Table1[[Column 1] [Column 3]] - the intersection of
//! two or more columns -- I don't understand this one
//! - Table1[[#ALL], [Column Name]] - column header and data
//! - Table1[#HEADERS] - only the table headers
//! - (not yet supported) Table1[#TOTALS] - reference the total line at the end
//! of the table (also known as the footer)
//! - Table1[[#HEADERS], [#DATA]] - table headers and data across entire table
//! - Table1 or Table1[#DATA] - table data without headers or totals
//! - Table1[@Column Name] - data in column name at the same row as the code
//! cell
//! - Table1[[#This Row],[Colum Name]] - dat in column name at the same row as
//! cell
//!
//! For purposes of data frames, we'll probably ignore #DATA, since we want to
//! define the data frame with the headers.
//!
//! Quadratic extends the table reference to also allow specific rows within
//! columns. The row range may change based on the sort/filter of the column:
//! - Table1[[#10]] - all data in row 10
//! - Table1[[#12],[Column 1]]
//! - Table1[[#12:15],[Column 1]]
//! - Table1[[#12:],[Column 1]] - from row 12 to the end of the rows
//! - Table1[[#12,15],[Column 1]]
//! - Table1[[#12,14,20],[Column 1]:[Column 2]]
//! - (possibly support) Table1[#$12],[Column 1] - maintains reference to the
//! absolute row 12, regardless of sorting/filtering
//! - Table1[[#LAST],[Column 1]] - last row in the table
//!
//! When parsing, we first try to see if it references a table. If not, then we
//! try A1 parsing. This allows Table1 to be a proper reference, even though it
//! can also be parsed as A1 (with a large column offset).
//!
//! Double brackets allow escaping of special characters, eg,
//! DeptSalesFYSummary[[Total $ Amount]]
//!
//! Special characters that require [[ ]] are: comma, :, [, ], and @ (Excel
//! requires more characters to be escaped--Quadratic will still accept them
//! with or without the double bracket)
//!
//! Special characters can also be escaped within column names using a single
//! quote: [, ], @, #, and '. For example: DeptSales['[No Idea why a left
//! bracket is needed here]
//!
//! The space character can be used to improve readability in a structured
//! reference. It is ignored in parsing: =DeptSales[ [Sales Person]:[Region] ]
//! =DeptSales[[#Headers], [#Data], [% Commission]]
mod column_range;
pub mod display;
pub mod parse;
mod tokenize;

pub use column_range::*;

use serde::{Deserialize, Serialize};
use ts_rs::TS;

use super::A1Error;

#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize, TS)]
pub struct TableRef {
pub table_name: String,
pub data: bool,
pub headers: bool,
pub totals: bool,
pub row_ranges: RowRange,
pub col_ranges: Vec<ColRange>,
}
Loading

0 comments on commit ec4117f

Please sign in to comment.