Skip to content

Commit 2d0139d

Browse files
Provide the SQL statement context (#554)
* Provide the SQL statement context for errors that occur when preparing a SQL statement * Extract status2class function * Use status2class in CHECK_MSG / rb_sqlite3_raise_msg * Prefer rb_sprintf to asprintf+strnew2+free * Add two failing tests for SQL with newlines * Move sql error message formatting into Ruby to handle newlines and handle versions of sqlite3 without sqlite3_error_offset() * doc: update CHANGELOG --------- Co-authored-by: Mike Dalessio <[email protected]>
1 parent 557ce1b commit 2d0139d

File tree

7 files changed

+174
-67
lines changed

7 files changed

+174
-67
lines changed

CHANGELOG.md

+7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# sqlite3-ruby Changelog
22

3+
## next / unreleased
4+
5+
### Improved
6+
7+
- SQL Syntax errors during `Database#prepare` will raise a verbose exception with a multiline message indicating with a "^" exactly where in the statement the error occurred. [#554] @fractaledmind @flavorjones
8+
9+
310
## 2.1.1 / 2024-10-22
411

512
### Dependencies

ext/sqlite3/exception.c

+71-66
Original file line numberDiff line numberDiff line change
@@ -1,101 +1,82 @@
11
#include <sqlite3_ruby.h>
22

3-
void
4-
rb_sqlite3_raise(sqlite3 *db, int status)
3+
static VALUE
4+
status2klass(int status)
55
{
6-
VALUE klass = Qnil;
7-
86
/* Consider only lower 8 bits, to work correctly when
97
extended result codes are enabled. */
108
switch (status & 0xff) {
119
case SQLITE_OK:
12-
return;
13-
break;
10+
return Qnil;
1411
case SQLITE_ERROR:
15-
klass = rb_path2class("SQLite3::SQLException");
16-
break;
12+
return rb_path2class("SQLite3::SQLException");
1713
case SQLITE_INTERNAL:
18-
klass = rb_path2class("SQLite3::InternalException");
19-
break;
14+
return rb_path2class("SQLite3::InternalException");
2015
case SQLITE_PERM:
21-
klass = rb_path2class("SQLite3::PermissionException");
22-
break;
16+
return rb_path2class("SQLite3::PermissionException");
2317
case SQLITE_ABORT:
24-
klass = rb_path2class("SQLite3::AbortException");
25-
break;
18+
return rb_path2class("SQLite3::AbortException");
2619
case SQLITE_BUSY:
27-
klass = rb_path2class("SQLite3::BusyException");
28-
break;
20+
return rb_path2class("SQLite3::BusyException");
2921
case SQLITE_LOCKED:
30-
klass = rb_path2class("SQLite3::LockedException");
31-
break;
22+
return rb_path2class("SQLite3::LockedException");
3223
case SQLITE_NOMEM:
33-
klass = rb_path2class("SQLite3::MemoryException");
34-
break;
24+
return rb_path2class("SQLite3::MemoryException");
3525
case SQLITE_READONLY:
36-
klass = rb_path2class("SQLite3::ReadOnlyException");
37-
break;
26+
return rb_path2class("SQLite3::ReadOnlyException");
3827
case SQLITE_INTERRUPT:
39-
klass = rb_path2class("SQLite3::InterruptException");
40-
break;
28+
return rb_path2class("SQLite3::InterruptException");
4129
case SQLITE_IOERR:
42-
klass = rb_path2class("SQLite3::IOException");
43-
break;
30+
return rb_path2class("SQLite3::IOException");
4431
case SQLITE_CORRUPT:
45-
klass = rb_path2class("SQLite3::CorruptException");
46-
break;
32+
return rb_path2class("SQLite3::CorruptException");
4733
case SQLITE_NOTFOUND:
48-
klass = rb_path2class("SQLite3::NotFoundException");
49-
break;
34+
return rb_path2class("SQLite3::NotFoundException");
5035
case SQLITE_FULL:
51-
klass = rb_path2class("SQLite3::FullException");
52-
break;
36+
return rb_path2class("SQLite3::FullException");
5337
case SQLITE_CANTOPEN:
54-
klass = rb_path2class("SQLite3::CantOpenException");
55-
break;
38+
return rb_path2class("SQLite3::CantOpenException");
5639
case SQLITE_PROTOCOL:
57-
klass = rb_path2class("SQLite3::ProtocolException");
58-
break;
40+
return rb_path2class("SQLite3::ProtocolException");
5941
case SQLITE_EMPTY:
60-
klass = rb_path2class("SQLite3::EmptyException");
61-
break;
42+
return rb_path2class("SQLite3::EmptyException");
6243
case SQLITE_SCHEMA:
63-
klass = rb_path2class("SQLite3::SchemaChangedException");
64-
break;
44+
return rb_path2class("SQLite3::SchemaChangedException");
6545
case SQLITE_TOOBIG:
66-
klass = rb_path2class("SQLite3::TooBigException");
67-
break;
46+
return rb_path2class("SQLite3::TooBigException");
6847
case SQLITE_CONSTRAINT:
69-
klass = rb_path2class("SQLite3::ConstraintException");
70-
break;
48+
return rb_path2class("SQLite3::ConstraintException");
7149
case SQLITE_MISMATCH:
72-
klass = rb_path2class("SQLite3::MismatchException");
73-
break;
50+
return rb_path2class("SQLite3::MismatchException");
7451
case SQLITE_MISUSE:
75-
klass = rb_path2class("SQLite3::MisuseException");
76-
break;
52+
return rb_path2class("SQLite3::MisuseException");
7753
case SQLITE_NOLFS:
78-
klass = rb_path2class("SQLite3::UnsupportedException");
79-
break;
54+
return rb_path2class("SQLite3::UnsupportedException");
8055
case SQLITE_AUTH:
81-
klass = rb_path2class("SQLite3::AuthorizationException");
82-
break;
56+
return rb_path2class("SQLite3::AuthorizationException");
8357
case SQLITE_FORMAT:
84-
klass = rb_path2class("SQLite3::FormatException");
85-
break;
58+
return rb_path2class("SQLite3::FormatException");
8659
case SQLITE_RANGE:
87-
klass = rb_path2class("SQLite3::RangeException");
88-
break;
60+
return rb_path2class("SQLite3::RangeException");
8961
case SQLITE_NOTADB:
90-
klass = rb_path2class("SQLite3::NotADatabaseException");
91-
break;
62+
return rb_path2class("SQLite3::NotADatabaseException");
9263
default:
93-
klass = rb_path2class("SQLite3::Exception");
64+
return rb_path2class("SQLite3::Exception");
65+
}
66+
}
67+
68+
void
69+
rb_sqlite3_raise(sqlite3 *db, int status)
70+
{
71+
VALUE klass = status2klass(status);
72+
if (NIL_P(klass)) {
73+
return;
9474
}
9575

96-
klass = rb_exc_new2(klass, sqlite3_errmsg(db));
97-
rb_iv_set(klass, "@code", INT2FIX(status));
98-
rb_exc_raise(klass);
76+
VALUE exception = rb_exc_new2(klass, sqlite3_errmsg(db));
77+
rb_iv_set(exception, "@code", INT2FIX(status));
78+
79+
rb_exc_raise(exception);
9980
}
10081

10182
/*
@@ -104,14 +85,38 @@ rb_sqlite3_raise(sqlite3 *db, int status)
10485
void
10586
rb_sqlite3_raise_msg(sqlite3 *db, int status, const char *msg)
10687
{
107-
VALUE exception;
108-
109-
if (status == SQLITE_OK) {
88+
VALUE klass = status2klass(status);
89+
if (NIL_P(klass)) {
11090
return;
11191
}
11292

113-
exception = rb_exc_new2(rb_path2class("SQLite3::Exception"), msg);
93+
VALUE exception = rb_exc_new2(klass, msg);
94+
rb_iv_set(exception, "@code", INT2FIX(status));
11495
sqlite3_free((void *)msg);
96+
97+
rb_exc_raise(exception);
98+
}
99+
100+
void
101+
rb_sqlite3_raise_with_sql(sqlite3 *db, int status, const char *sql)
102+
{
103+
VALUE klass = status2klass(status);
104+
if (NIL_P(klass)) {
105+
return;
106+
}
107+
108+
const char *error_msg = sqlite3_errmsg(db);
109+
int error_offset = -1;
110+
#ifdef HAVE_SQLITE3_ERROR_OFFSET
111+
error_offset = sqlite3_error_offset(db);
112+
#endif
113+
114+
VALUE exception = rb_exc_new2(klass, error_msg);
115115
rb_iv_set(exception, "@code", INT2FIX(status));
116+
if (sql) {
117+
rb_iv_set(exception, "@sql", rb_str_new2(sql));
118+
rb_iv_set(exception, "@sql_offset", INT2FIX(error_offset));
119+
}
120+
116121
rb_exc_raise(exception);
117122
}

ext/sqlite3/exception.h

+2
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33

44
#define CHECK(_db, _status) rb_sqlite3_raise(_db, _status);
55
#define CHECK_MSG(_db, _status, _msg) rb_sqlite3_raise_msg(_db, _status, _msg);
6+
#define CHECK_PREPARE(_db, _status, _sql) rb_sqlite3_raise_with_sql(_db, _status, _sql)
67

78
void rb_sqlite3_raise(sqlite3 *db, int status);
89
void rb_sqlite3_raise_msg(sqlite3 *db, int status, const char *msg);
10+
void rb_sqlite3_raise_with_sql(sqlite3 *db, int status, const char *sql);
911

1012
#endif

ext/sqlite3/extconf.rb

+1
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ def configure_extension
132132

133133
have_func("sqlite3_prepare_v2")
134134
have_func("sqlite3_db_name", "sqlite3.h") # v3.39.0
135+
have_func("sqlite3_error_offset", "sqlite3.h") # v3.38.0
135136

136137
have_type("sqlite3_int64", "sqlite3.h")
137138
have_type("sqlite3_uint64", "sqlite3.h")

ext/sqlite3/statement.c

+1-1
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ prepare(VALUE self, VALUE db, VALUE sql)
7878
&tail
7979
);
8080

81-
CHECK(db_ctx->db, status);
81+
CHECK_PREPARE(db_ctx->db, status, StringValuePtr(sql));
8282
timespecclear(&db_ctx->stmt_deadline);
8383

8484
return rb_utf8_str_new_cstr(tail);

lib/sqlite3/errors.rb

+28
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,34 @@ module SQLite3
44
class Exception < ::StandardError
55
# A convenience for accessing the error code for this exception.
66
attr_reader :code
7+
8+
# If the error is associated with a SQL query, this is the query
9+
attr_reader :sql
10+
11+
# If the error is associated with a particular offset in a SQL query, this is the non-negative
12+
# offset. If the offset is not available, this will be -1.
13+
attr_reader :sql_offset
14+
15+
def message
16+
[super, sql_error].compact.join(":\n")
17+
end
18+
19+
private def sql_error
20+
return nil unless @sql
21+
return @sql.chomp unless @sql_offset >= 0
22+
23+
offset = @sql_offset
24+
sql.lines.flat_map do |line|
25+
if offset >= 0 && line.length > offset
26+
blanks = " " * offset
27+
offset = -1
28+
[line.chomp, blanks + "^"]
29+
else
30+
offset -= line.length if offset
31+
line.chomp
32+
end
33+
end.join("\n")
34+
end
735
end
836

937
class SQLException < Exception; end

test/test_integration.rb

+64
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,70 @@ def test_prepare_invalid_syntax
107107
end
108108
end
109109

110+
def test_prepare_exception_shows_error_position
111+
exception = assert_raise(SQLite3::SQLException) do
112+
@db.prepare "select from foo"
113+
end
114+
if exception.sql_offset >= 0 # HAVE_SQLITE_ERROR_OFFSET
115+
assert_equal(<<~MSG.chomp, exception.message)
116+
near "from": syntax error:
117+
select from foo
118+
^
119+
MSG
120+
else
121+
assert_equal(<<~MSG.chomp, exception.message)
122+
near "from": syntax error:
123+
select from foo
124+
MSG
125+
end
126+
end
127+
128+
def test_prepare_exception_shows_error_position_newline1
129+
exception = assert_raise(SQLite3::SQLException) do
130+
@db.prepare(<<~SQL)
131+
select
132+
from foo
133+
SQL
134+
end
135+
if exception.sql_offset >= 0 # HAVE_SQLITE_ERROR_OFFSET
136+
assert_equal(<<~MSG.chomp, exception.message)
137+
near "from": syntax error:
138+
select
139+
from foo
140+
^
141+
MSG
142+
else
143+
assert_equal(<<~MSG.chomp, exception.message)
144+
near "from": syntax error:
145+
select
146+
from foo
147+
MSG
148+
end
149+
end
150+
151+
def test_prepare_exception_shows_error_position_newline2
152+
exception = assert_raise(SQLite3::SQLException) do
153+
@db.prepare(<<~SQL)
154+
select asdf
155+
from foo
156+
SQL
157+
end
158+
if exception.sql_offset >= 0 # HAVE_SQLITE_ERROR_OFFSET
159+
assert_equal(<<~MSG.chomp, exception.message)
160+
no such column: asdf:
161+
select asdf
162+
^
163+
from foo
164+
MSG
165+
else
166+
assert_equal(<<~MSG.chomp, exception.message)
167+
no such column: asdf:
168+
select asdf
169+
from foo
170+
MSG
171+
end
172+
end
173+
110174
def test_prepare_invalid_column
111175
assert_raise(SQLite3::SQLException) do
112176
@db.prepare "select k from foo"

0 commit comments

Comments
 (0)