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
28 changes: 26 additions & 2 deletions native/ecto_libsql/src/tests/utils_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,8 +261,32 @@ mod should_use_query_tests {

#[test]
fn test_pragma_statements() {
assert!(!should_use_query("PRAGMA table_info(users)"));
assert!(!should_use_query("PRAGMA foreign_keys = ON"));
// PRAGMA statements always use query() since they can return rows.
// E.g. PRAGMA wal_checkpoint(FULL) returns busy/log/checkpointed columns.
assert!(should_use_query("PRAGMA table_info(users)"));
assert!(should_use_query("PRAGMA foreign_keys = ON"));
assert!(should_use_query("PRAGMA wal_checkpoint(FULL)"));
assert!(should_use_query("pragma journal_mode"));
assert!(should_use_query("PRAGMA foreign_keys"));
}

#[test]
fn test_not_pragma_if_part_of_word() {
// "PRAGMATIC" and similar should not match PRAGMA.
assert!(!should_use_query("PRAGMATIC table_name"));
assert!(!should_use_query("PRAGMATICS"));
}

#[test]
fn test_pragma_with_whitespace() {
// Leading whitespace before PRAGMA must be skipped correctly.
assert!(should_use_query(" PRAGMA foreign_keys"));
assert!(should_use_query("\tPRAGMA journal_mode"));
assert!(should_use_query("\n PRAGMA wal_checkpoint(FULL)"));
// Mixed-case with leading whitespace exercises both skip_whitespace_and_comments
// and the case-insensitive PRAGMA comparison end-to-end.
assert!(should_use_query(" PrAgMa journal_mode"));
assert!(should_use_query("\tpRaGmA wal_checkpoint(FULL)"));
}

// ===== Edge Cases =====
Expand Down
25 changes: 24 additions & 1 deletion native/ecto_libsql/src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,14 @@ fn skip_whitespace_and_comments(bytes: &[u8]) -> usize {

/// Determines if a query should use query() or execute()
///
/// Returns true if should use query() (SELECT or has RETURNING clause).
/// Returns true if the statement should use `query()` rather than `execute()`.
///
/// Routes through `query()` when the statement may return rows:
/// - `SELECT` — always returns rows
/// - `WITH` — CTE; typically precedes a `SELECT`
/// - `EXPLAIN` — always returns rows
/// - `PRAGMA` — may return rows (e.g. `PRAGMA wal_checkpoint(FULL)`)
/// - Any statement containing a `RETURNING` clause
///
/// Performance optimisations:
/// - Zero allocations (no to_uppercase())
Expand Down Expand Up @@ -414,6 +421,22 @@ pub fn should_use_query(sql: &str) -> bool {
return true;
}

// Check if starts with PRAGMA (case-insensitive)
// PRAGMA statements may return rows (e.g. PRAGMA wal_checkpoint(FULL) returns 3 columns),
// so always route through query() to avoid "Execute returned rows" errors.
if len - start >= 6
&& (bytes[start] == b'P' || bytes[start] == b'p')
&& (bytes[start + 1] == b'R' || bytes[start + 1] == b'r')
&& (bytes[start + 2] == b'A' || bytes[start + 2] == b'a')
&& (bytes[start + 3] == b'G' || bytes[start + 3] == b'g')
&& (bytes[start + 4] == b'M' || bytes[start + 4] == b'm')
&& (bytes[start + 5] == b'A' || bytes[start + 5] == b'a')
// Verify it's followed by whitespace or end of string
&& (start + 6 >= len || bytes[start + 6].is_ascii_whitespace())
{
return true;
}

// Check if starts with SELECT (case-insensitive)
if len - start >= 6
&& (bytes[start] == b'S' || bytes[start] == b's')
Expand Down
48 changes: 22 additions & 26 deletions test/ecto_integration_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,25 @@ defmodule Ecto.Integration.EctoLibSqlTest do
)
""")

# Create the locations table and composite unique index used by the
# on_conflict tests. Creating these once in setup_all avoids per-test
# DDL that can cause schema-visibility races in the connection pool.
Ecto.Adapters.SQL.query!(TestRepo, """
CREATE TABLE IF NOT EXISTS locations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL,
parent_slug TEXT,
name TEXT NOT NULL,
inserted_at DATETIME,
updated_at DATETIME
)
""")

Ecto.Adapters.SQL.query!(
TestRepo,
"CREATE UNIQUE INDEX IF NOT EXISTS locations_slug_parent_index ON locations (slug, parent_slug)"
)

on_exit(fn ->
EctoLibSql.TestHelpers.cleanup_db_files(@test_db)
end)
Expand Down Expand Up @@ -719,32 +738,9 @@ defmodule Ecto.Integration.EctoLibSqlTest do
end

setup do
# Drop index and table to ensure clean state
Ecto.Adapters.SQL.query!(TestRepo, "DROP INDEX IF EXISTS locations_slug_parent_index")
Ecto.Adapters.SQL.query!(TestRepo, "DROP TABLE IF EXISTS locations")

# Create table with composite unique index
Ecto.Adapters.SQL.query!(TestRepo, """
CREATE TABLE locations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL,
parent_slug TEXT,
name TEXT NOT NULL,
inserted_at DATETIME,
updated_at DATETIME
)
""")

Ecto.Adapters.SQL.query!(
TestRepo,
"CREATE UNIQUE INDEX IF NOT EXISTS locations_slug_parent_index ON locations (slug, parent_slug)"
)

on_exit(fn ->
Ecto.Adapters.SQL.query!(TestRepo, "DROP INDEX IF EXISTS locations_slug_parent_index")
Ecto.Adapters.SQL.query!(TestRepo, "DROP TABLE IF EXISTS locations")
end)

# The locations table and its unique index are created once in setup_all.
# Just delete any rows left over from a previous test run.
Ecto.Adapters.SQL.query!(TestRepo, "DELETE FROM locations")
:ok
end

Expand Down