Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
74 changes: 73 additions & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ rustls-pemfile = "2"
rustls-platform-verifier = "0.6"
notify = "6"
ulid = "1.2.1"
sqlparser = "0.62"

# GTK dependencies for Wayland window title workaround (Linux only)
[target.'cfg(target_os = "linux")'.dependencies]
Expand Down
6 changes: 3 additions & 3 deletions src-tauri/src/drivers/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ pub use blob::{
DEFAULT_MAX_BLOB_SIZE, MAX_BLOB_PREVIEW_SIZE,
};
pub use query::{
build_paginated_query, calculate_offset, extract_user_limit, extract_user_offset,
is_explainable_query,
is_select_query, returns_result_set, strip_leading_sql_comments, strip_limit_offset,
annotate_error_with_query, build_paginated_query, calculate_offset, extract_user_limit,
extract_user_offset, is_explainable_query, is_select_query, returns_result_set,
strip_leading_sql_comments, strip_limit_offset, PaginationDialect, EXECUTED_QUERY_MARKER,
};
pub use safe_int::{
i64_to_json, parse_unsafe_bigint_string, u64_to_json, JS_MAX_SAFE_INTEGER, JS_MAX_SAFE_UINT,
Expand Down
212 changes: 207 additions & 5 deletions src-tauri/src/drivers/common/query.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
use sqlparser::ast::{Expr, LimitClause, Offset, OffsetRows, Statement, Value};
use sqlparser::dialect::{Dialect, MySqlDialect, PostgreSqlDialect, SQLiteDialect};
use sqlparser::parser::Parser;

/// Check if a query is a SELECT statement.
///
/// Leading SQL comments are stripped before checking, matching
Expand Down Expand Up @@ -281,22 +285,182 @@ pub fn extract_user_offset(query: &str) -> Option<u32> {
None
}

/// SQL dialect used to parse a query before rewriting its pagination.
///
/// Driver-facing wrapper so the `sqlparser` dialect types stay internal to
/// this module — each driver passes the variant matching its engine.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PaginationDialect {
MySql,
Postgres,
Sqlite,
}

impl PaginationDialect {
fn parser_dialect(self) -> Box<dyn Dialect> {
match self {
PaginationDialect::MySql => Box::new(MySqlDialect {}),
PaginationDialect::Postgres => Box::new(PostgreSqlDialect {}),
PaginationDialect::Sqlite => Box::new(SQLiteDialect {}),
}
}
}

/// LIMIT/OFFSET read from a query's parsed AST.
struct ParsedPagination {
/// User-supplied LIMIT, if it is a plain numeric literal.
user_limit: Option<u32>,
/// User-supplied OFFSET (0 when absent or non-numeric).
user_offset: u32,
/// Whether the query carries a top-level LIMIT/OFFSET clause to strip.
has_limit_clause: bool,
}

/// Resolve a numeric-literal expression to a `u32`.
///
/// Non-literal expressions (placeholders, arithmetic, bind parameters) yield
/// `None` so they are treated as "no value" rather than being mis-read.
fn eval_u32(expr: &Expr) -> Option<u32> {
match expr {
Expr::Value(value) => match &value.value {
Value::Number(n, _) => n.parse().ok(),
_ => None,
},
_ => None,
}
}

/// Parse `query` with `dialect` and read the top-level LIMIT/OFFSET from the
/// AST, normalising MySQL's `LIMIT <offset>, <count>` form to the same shape.
///
/// Returns `None` — so the caller falls back to the token heuristics — when
/// the input is not a single query the parser understands, or when it relies
/// on a `FETCH FIRST … ROWS` clause this rewriter does not handle.
fn parse_pagination(query: &str, dialect: PaginationDialect) -> Option<ParsedPagination> {
let statements = Parser::parse_sql(dialect.parser_dialect().as_ref(), query).ok()?;
if statements.len() != 1 {
return None;
}
let Statement::Query(q) = &statements[0] else {
return None;
};

// FETCH FIRST … ROWS ONLY is out of scope; defer to the fallback path so
// its behaviour is unchanged rather than producing a mixed clause.
if q.fetch.is_some() {
return None;
}

match &q.limit_clause {
None => Some(ParsedPagination {
user_limit: None,
user_offset: 0,
has_limit_clause: false,
}),
Some(LimitClause::LimitOffset { limit, offset, .. }) => Some(ParsedPagination {
user_limit: limit.as_ref().and_then(eval_u32),
user_offset: offset
.as_ref()
.and_then(|o| eval_u32(&o.value))
.unwrap_or(0),
has_limit_clause: true,
}),
Some(LimitClause::OffsetCommaLimit { offset, limit }) => Some(ParsedPagination {
user_limit: eval_u32(limit),
user_offset: eval_u32(offset).unwrap_or(0),
has_limit_clause: true,
}),
}
}

/// True for the keyword/value tokens that make up a trailing LIMIT/OFFSET
/// clause (standard, MySQL comma, and FETCH spellings), so a LIMIT/OFFSET that
/// appears mid-query is never mistaken for the trailing pagination clause.
///
/// The numeric arm accepts digits interleaved with commas so MySQL's
/// `LIMIT <offset>,<count>` is recognised even when written without spaces
/// (`LIMIT 0,1`): the tokenizer splits only on whitespace, so the count and
/// offset arrive glued together as a single `0,1` token.
fn is_pagination_tail_token(tok: &str) -> bool {
let upper = tok.to_uppercase();
matches!(
upper.as_str(),
"LIMIT" | "OFFSET" | "BY" | "ROW" | "ROWS" | "ONLY" | "NEXT" | "FIRST" | ","
) || (!tok.is_empty() && tok.chars().all(|c| c.is_ascii_digit() || c == ','))
}

/// Cut the query immediately before its trailing top-level LIMIT/OFFSET clause.
///
/// Reuses [`tokenize_sql_with_pos`], which collapses parenthesised groups into
/// a single token, so a LIMIT/OFFSET inside a subquery is never seen and only
/// the outer clause is removed. Unlike [`strip_limit_offset`], it cuts at the
/// keyword regardless of the value shape, so MySQL's `LIMIT <offset>, <count>`
/// is handled. Falls back to [`strip_limit_offset`] if no trailing clause is
/// found (kept total; the parser having reported a clause makes this rare).
fn strip_at_limit_keyword(query: &str) -> String {
let trimmed = query.trim_end();
let tokens = tokenize_sql_with_pos(trimmed);
for (idx, (tok, pos)) in tokens.iter().enumerate() {
let upper = tok.to_uppercase();
if (upper == "LIMIT" || upper == "OFFSET")
&& tokens[idx + 1..]
.iter()
.all(|(t, _)| is_pagination_tail_token(t))
{
return trimmed[..*pos].trim_end().to_string();
}
}
strip_limit_offset(query)
}

/// Build a numeric-literal expression for the rendered pagination clause.
fn number_expr(n: u32) -> Expr {
Expr::value(Value::Number(n.to_string(), false))
}

/// Build a paginated query by stripping any user-supplied LIMIT/OFFSET and
/// appending pagination clauses directly. ORDER BY is left in place so that
/// table-qualified column references (e.g. `o.created_at`) remain valid —
/// wrapping the original query in a subquery would move those references out
/// of scope and cause "unknown column" errors.
///
/// The user's LIMIT/OFFSET are read from the parsed AST (using `dialect`), so
/// dialect-specific forms such as MySQL's `LIMIT <offset>, <count>` and
/// `OFFSET` before `LIMIT` are understood. When the parser cannot handle the
/// input, it falls back to a token-aware heuristic scan. The appended
/// pagination clause is rendered from a [`LimitClause`] AST node and
/// concatenated to the verbatim sliced base, so leading comments, inline
/// hints, and the body's formatting are preserved.
///
/// When the user wrote an explicit LIMIT, it is honoured as a cap on the total
/// number of rows returned across all pages. A user-supplied OFFSET is honoured
/// too: it is added to the per-page offset so that pagination walks the result
/// set the user actually asked for (the rows after their OFFSET). Discarding it
/// silently collapsed `LIMIT 1 OFFSET 1` to `LIMIT 1 OFFSET 0` on page 1.
pub fn build_paginated_query(query: &str, page_size: u32, page: u32) -> String {
pub fn build_paginated_query(
query: &str,
page_size: u32,
page: u32,
dialect: PaginationDialect,
) -> String {
let page_offset = calculate_offset(page, page_size);
let user_limit = extract_user_limit(query);
let user_offset = extract_user_offset(query).unwrap_or(0);
let base = strip_limit_offset(query);

let (user_limit, user_offset, base) = match parse_pagination(query, dialect) {
Some(parsed) => {
let base = if parsed.has_limit_clause {
strip_at_limit_keyword(query)
} else {
query.trim_end().to_string()
};
(parsed.user_limit, parsed.user_offset, base)
}
// Parser could not handle the input — fall back to the token heuristics.
None => (
extract_user_limit(query),
extract_user_offset(query).unwrap_or(0),
strip_limit_offset(query),
),
};

let fetch_count = match user_limit {
Some(ul) => {
Expand All @@ -309,5 +473,43 @@ pub fn build_paginated_query(query: &str, page_size: u32, page: u32) -> String {

let offset = user_offset.saturating_add(page_offset);

format!("{} LIMIT {} OFFSET {}", base, fetch_count, offset)
// Render the pagination clause from an AST node so the output is built by
// the parser rather than hand-formatted.
let clause = LimitClause::LimitOffset {
limit: Some(number_expr(fetch_count)),
offset: Some(Offset {
value: number_expr(offset),
rows: OffsetRows::None,
}),
limit_by: Vec::new(),
};

// `LimitClause`'s Display renders a leading space (it is meant to follow a
// preceding clause), so concatenate without inserting another one.
format!("{}{}", base, clause)
}

/// Sentinel that separates a human error message from the actual SQL that
/// produced it inside a single error string. The leading code point is from
/// the Unicode private-use area, so it never appears in real SQL text or DB
/// driver error messages and survives JSON/IPC transport unescaped — letting
/// the frontend split on this marker without colliding with the `\n\n`
/// brief/detail convention. Must stay in sync with the parser in
/// `src/components/ui/ErrorDisplay.tsx`.
pub const EXECUTED_QUERY_MARKER: &str = "\u{E000}__TABULARIS_EXECUTED_QUERY__";

/// Appends the executed SQL to a DB error message so the UI can show the user
/// the exact statement that failed. This matters when pagination rewrote the
/// query (appending `LIMIT`/`OFFSET`): the database complains about clauses the
/// user never typed, and without the rewritten text the error is baffling.
///
/// When `executed` matches `original` (ignoring surrounding whitespace) the
/// error is returned unchanged — echoing back the query the user can already
/// see would only add noise.
pub fn annotate_error_with_query(err: String, executed: &str, original: &str) -> String {
if executed.trim() == original.trim() {
err
} else {
format!("{err}{EXECUTED_QUERY_MARKER}{executed}")
}
}
Loading