Skip to content

Commit cee6939

Browse files
authored
Merge pull request #61 from ocean/bugfix-queryable-execute-nil-return
Bugfix queryable execute nil return
2 parents e8e62f5 + bd18f53 commit cee6939

7 files changed

Lines changed: 357 additions & 18 deletions

File tree

.beads/last-touched

Lines changed: 0 additions & 1 deletion
This file was deleted.

lib/ecto/adapters/libsql/connection.ex

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -738,7 +738,7 @@ defmodule Ecto.Adapters.LibSql.Connection do
738738
{join, wheres} = using_join(query, :update_all, "FROM", sources)
739739
where = where(%{query | wheres: wheres}, sources)
740740

741-
["UPDATE ", from, " AS ", name, " SET ", fields, join, where]
741+
["UPDATE ", from, " AS ", name, " SET ", fields, join, where | returning(query, sources)]
742742
end
743743

744744
@impl true
@@ -749,7 +749,7 @@ defmodule Ecto.Adapters.LibSql.Connection do
749749
{join, wheres} = using_join(query, :delete_all, "USING", sources)
750750
where = where(%{query | wheres: wheres}, sources)
751751

752-
["DELETE FROM ", from, " AS ", name, join, where]
752+
["DELETE FROM ", from, " AS ", name, join, where | returning(query, sources)]
753753
end
754754

755755
@impl true
@@ -1359,6 +1359,53 @@ defmodule Ecto.Adapters.LibSql.Connection do
13591359
[" RETURNING " | intersperse_map(returning, ?,, &quote_name/1)]
13601360
end
13611361

1362+
# Generate RETURNING clause from query select (for update_all/delete_all).
1363+
# Returns empty list if no select clause is present.
1364+
defp returning(%{select: nil}, _sources), do: []
1365+
1366+
defp returning(%{select: %{fields: fields}} = query, sources) do
1367+
[" RETURNING " | returning_fields(fields, sources, query)]
1368+
end
1369+
1370+
# Format fields for RETURNING clause.
1371+
# SQLite's RETURNING clause doesn't support table aliases, so we use bare column names.
1372+
defp returning_fields([], _sources, _query), do: ["1"]
1373+
1374+
defp returning_fields(fields, sources, query) do
1375+
intersperse_map(fields, ", ", fn
1376+
{:&, _, [idx]} ->
1377+
# Selecting all fields from a source - list all schema fields.
1378+
{_source, _name, schema} = elem(sources, idx)
1379+
1380+
if schema do
1381+
Enum.map_join(schema.__schema__(:fields), ", ", &quote_name/1)
1382+
else
1383+
"*"
1384+
end
1385+
1386+
{key, value} when is_atom(key) ->
1387+
# Key-value pair (for maps) - return as "expr AS key".
1388+
# Use returning_expr to avoid table aliases.
1389+
[returning_expr(value, sources, query), " AS ", quote_name(key)]
1390+
1391+
value ->
1392+
returning_expr(value, sources, query)
1393+
end)
1394+
end
1395+
1396+
# Generate expressions for RETURNING clause without table aliases.
1397+
# SQLite doesn't support table aliases in RETURNING.
1398+
defp returning_expr({{:., _, [{:&, _, [_idx]}, field]}, _, []}, _sources, _query)
1399+
when is_atom(field) do
1400+
# Simple field reference like u.name - return just the quoted field name.
1401+
quote_name(field)
1402+
end
1403+
1404+
defp returning_expr(value, sources, query) do
1405+
# For other expressions, fall back to the normal expr function.
1406+
expr(value, sources, query)
1407+
end
1408+
13621409
defp intersperse_map(list, separator, mapper) do
13631410
intersperse_map(list, separator, mapper, [])
13641411
end

lib/ecto_libsql/native.ex

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -708,7 +708,7 @@ defmodule EctoLibSql.Native do
708708
end
709709

710710
# For INSERT/UPDATE/DELETE without RETURNING, columns and rows will be empty
711-
# Set them to nil to match Ecto's expectations
711+
# Set them to nil to match Ecto's expectations for write operations
712712
{columns, rows} =
713713
if command in [:insert, :update, :delete] and columns == [] and rows == [] do
714714
{nil, nil}
@@ -897,13 +897,46 @@ defmodule EctoLibSql.Native do
897897
@spec detect_command(String.t()) :: EctoLibSql.Result.command_type()
898898
def detect_command(query) when is_binary(query) do
899899
query
900-
|> String.trim_leading()
900+
|> skip_leading_comments_and_whitespace()
901901
|> extract_first_word()
902902
|> command_atom()
903903
end
904904

905905
def detect_command(_), do: :unknown
906906

907+
# Skip leading whitespace and SQL comments (both -- and /* */ styles).
908+
# This ensures queries starting with comments are correctly classified.
909+
defp skip_leading_comments_and_whitespace(query) do
910+
query
911+
|> String.trim_leading()
912+
|> do_skip_comments()
913+
end
914+
915+
defp do_skip_comments(<<"--", rest::binary>>) do
916+
# Single-line comment: skip to end of line
917+
rest
918+
|> skip_to_newline()
919+
|> skip_leading_comments_and_whitespace()
920+
end
921+
922+
defp do_skip_comments(<<"/*", rest::binary>>) do
923+
# Block comment: skip to closing */
924+
rest
925+
|> skip_to_block_end()
926+
|> skip_leading_comments_and_whitespace()
927+
end
928+
929+
defp do_skip_comments(query), do: query
930+
931+
defp skip_to_newline(<<"\n", rest::binary>>), do: rest
932+
defp skip_to_newline(<<"\r\n", rest::binary>>), do: rest
933+
defp skip_to_newline(<<_::binary-size(1), rest::binary>>), do: skip_to_newline(rest)
934+
defp skip_to_newline(<<>>), do: <<>>
935+
936+
defp skip_to_block_end(<<"*/", rest::binary>>), do: rest
937+
defp skip_to_block_end(<<_::binary-size(1), rest::binary>>), do: skip_to_block_end(rest)
938+
defp skip_to_block_end(<<>>), do: <<>>
939+
907940
defp extract_first_word(query) do
908941
# Extract first word more efficiently - stop at first whitespace
909942
first_word =

lib/ecto_libsql/result.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ defmodule EctoLibSql.Result do
99
## Fields
1010
1111
- `:command` - The type of SQL command (`:select`, `:insert`, `:update`, `:delete`, `:create`, `:begin`, `:commit`, `:rollback`, `:pragma`, `:batch`, `:unknown`, `:other`, or `nil`)
12-
- `:columns` - List of column names (for SELECT queries), or `nil`
13-
- `:rows` - List of rows, where each row is a list of values, or `nil`
12+
- `:columns` - List of column names (for SELECT queries), or `nil` for write operations
13+
- `:rows` - List of rows, where each row is a list of values, or `nil` for write operations
1414
- `:num_rows` - Number of rows affected or returned
1515
1616
## Examples

native/ecto_libsql/src/tests/utils_tests.rs

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -298,10 +298,31 @@ mod should_use_query_tests {
298298

299299
#[test]
300300
fn test_sql_with_comments() {
301-
// Comments BEFORE the statement: we don't parse SQL comments,
302-
// so "-- Comment\nSELECT" won't detect SELECT (first non-whitespace is '-')
303-
// This is fine - Ecto doesn't generate SQL with leading comments
304-
assert!(!should_use_query("-- Comment\nSELECT * FROM users"));
301+
// Leading single-line comments are now skipped correctly
302+
assert!(should_use_query("-- Comment\nSELECT * FROM users"));
303+
assert!(should_use_query(
304+
"-- First comment\n-- Second comment\nSELECT * FROM users"
305+
));
306+
307+
// Leading block comments are also skipped correctly
308+
assert!(should_use_query("/* Block comment */ SELECT * FROM users"));
309+
assert!(should_use_query(
310+
"/* Multi\nline\nblock */ SELECT * FROM users"
311+
));
312+
313+
// Mixed comments and whitespace
314+
assert!(should_use_query(" -- Comment\n SELECT * FROM users"));
315+
assert!(should_use_query(
316+
"/* comment */ -- another\nSELECT * FROM users"
317+
));
318+
319+
// Leading comments on other statements
320+
assert!(!should_use_query(
321+
"-- Comment\nINSERT INTO users VALUES (1)"
322+
));
323+
assert!(should_use_query(
324+
"-- Comment\nINSERT INTO users VALUES (1) RETURNING id"
325+
));
305326

306327
// Comments WITHIN the statement are fine - we detect keywords/clauses
307328
assert!(should_use_query(

native/ecto_libsql/src/utils.rs

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,60 @@ pub fn detect_query_type(query: &str) -> QueryType {
304304
}
305305
}
306306

307+
/// Skip leading whitespace and SQL comments in a byte slice.
308+
///
309+
/// Handles both single-line comments (`-- comment`) and block comments (`/* comment */`).
310+
/// Returns the index of the first non-whitespace, non-comment character.
311+
#[inline]
312+
fn skip_whitespace_and_comments(bytes: &[u8]) -> usize {
313+
let len = bytes.len();
314+
let mut pos = 0;
315+
316+
loop {
317+
// Skip whitespace
318+
while pos < len && bytes[pos].is_ascii_whitespace() {
319+
pos += 1;
320+
}
321+
322+
if pos >= len {
323+
return pos;
324+
}
325+
326+
// Check for single-line comment: -- ...
327+
if pos + 1 < len && bytes[pos] == b'-' && bytes[pos + 1] == b'-' {
328+
pos += 2;
329+
// Skip to end of line
330+
while pos < len && bytes[pos] != b'\n' {
331+
pos += 1;
332+
}
333+
// Skip the newline if present
334+
if pos < len && bytes[pos] == b'\n' {
335+
pos += 1;
336+
}
337+
continue;
338+
}
339+
340+
// Check for block comment: /* ... */
341+
if pos + 1 < len && bytes[pos] == b'/' && bytes[pos + 1] == b'*' {
342+
pos += 2;
343+
// Find closing */
344+
while pos + 1 < len {
345+
if bytes[pos] == b'*' && bytes[pos + 1] == b'/' {
346+
pos += 2;
347+
break;
348+
}
349+
pos += 1;
350+
}
351+
continue;
352+
}
353+
354+
// Not whitespace or comment, we're done
355+
break;
356+
}
357+
358+
pos
359+
}
360+
307361
/// Determines if a query should use query() or execute()
308362
///
309363
/// Returns true if should use query() (SELECT or has RETURNING clause).
@@ -314,10 +368,15 @@ pub fn detect_query_type(query: &str) -> QueryType {
314368
/// - Early termination on match
315369
/// - Case-insensitive ASCII comparison without allocations
316370
///
317-
/// ## Limitation: String and Comment Handling
371+
/// ## Comment Handling
372+
///
373+
/// This function correctly skips leading SQL comments (both `-- single line`
374+
/// and `/* block */` style) before checking for query keywords.
375+
///
376+
/// ## Limitation: String Literal Handling
318377
///
319378
/// This function performs simple keyword matching and does not parse SQL syntax.
320-
/// It will match keywords appearing in string literals or comments.
379+
/// It will match keywords appearing in string literals.
321380
///
322381
/// **Why this is acceptable**:
323382
/// - False positives (using `query()` when `execute()` would suffice) are **safe**
@@ -332,11 +391,8 @@ pub fn should_use_query(sql: &str) -> bool {
332391
return false;
333392
}
334393

335-
// Find first non-whitespace character
336-
let mut start = 0;
337-
while start < len && bytes[start].is_ascii_whitespace() {
338-
start += 1;
339-
}
394+
// Skip leading whitespace and comments
395+
let start = skip_whitespace_and_comments(bytes);
340396

341397
if start >= len {
342398
return false;

0 commit comments

Comments
 (0)