diff --git a/Gemfile b/Gemfile index bd413c0e..e7def431 100644 --- a/Gemfile +++ b/Gemfile @@ -3,7 +3,7 @@ source "https://rubygems.org" gemspec group :development do - gem "minitest", "5.20.0" + gem "minitest", "5.21.1" gem "rake-compiler", "1.2.5" gem "rake-compiler-dock", "1.4.0" diff --git a/ext/sqlite3/statement.c b/ext/sqlite3/statement.c index 5616e15b..9367bc64 100644 --- a/ext/sqlite3/statement.c +++ b/ext/sqlite3/statement.c @@ -418,6 +418,153 @@ bind_parameter_count(VALUE self) return INT2NUM(sqlite3_bind_parameter_count(ctx->st)); } +enum stmt_stat_sym { + stmt_stat_sym_fullscan_steps, + stmt_stat_sym_sorts, + stmt_stat_sym_autoindexes, + stmt_stat_sym_vm_steps, +#ifdef SQLITE_STMTSTATUS_REPREPARE + stmt_stat_sym_reprepares, +#endif +#ifdef SQLITE_STMTSTATUS_RUN + stmt_stat_sym_runs, +#endif +#ifdef SQLITE_STMTSTATUS_FILTER_MISS + stmt_stat_sym_filter_misses, +#endif +#ifdef SQLITE_STMTSTATUS_FILTER_HIT + stmt_stat_sym_filter_hits, +#endif + stmt_stat_sym_last +}; + +static VALUE stmt_stat_symbols[stmt_stat_sym_last]; + +static void +setup_stmt_stat_symbols(void) +{ + if (stmt_stat_symbols[0] == 0) { +#define S(s) stmt_stat_symbols[stmt_stat_sym_##s] = ID2SYM(rb_intern_const(#s)) + S(fullscan_steps); + S(sorts); + S(autoindexes); + S(vm_steps); +#ifdef SQLITE_STMTSTATUS_REPREPARE + S(reprepares); +#endif +#ifdef SQLITE_STMTSTATUS_RUN + S(runs); +#endif +#ifdef SQLITE_STMTSTATUS_FILTER_MISS + S(filter_misses); +#endif +#ifdef SQLITE_STMTSTATUS_FILTER_HIT + S(filter_hits); +#endif +#undef S + } +} + +static size_t +stmt_stat_internal(VALUE hash_or_sym, sqlite3_stmt *stmt) +{ + VALUE hash = Qnil, key = Qnil; + + setup_stmt_stat_symbols(); + + if (RB_TYPE_P(hash_or_sym, T_HASH)) { + hash = hash_or_sym; + } + else if (SYMBOL_P(hash_or_sym)) { + key = hash_or_sym; + } + else { + rb_raise(rb_eTypeError, "non-hash or symbol argument"); + } + +#define SET(name, stat_type) \ + if (key == stmt_stat_symbols[stmt_stat_sym_##name]) \ + return sqlite3_stmt_status(stmt, stat_type, 0); \ + else if (hash != Qnil) \ + rb_hash_aset(hash, stmt_stat_symbols[stmt_stat_sym_##name], SIZET2NUM(sqlite3_stmt_status(stmt, stat_type, 0))); + + SET(fullscan_steps, SQLITE_STMTSTATUS_FULLSCAN_STEP); + SET(sorts, SQLITE_STMTSTATUS_SORT); + SET(autoindexes, SQLITE_STMTSTATUS_AUTOINDEX); + SET(vm_steps, SQLITE_STMTSTATUS_VM_STEP); +#ifdef SQLITE_STMTSTATUS_REPREPARE + SET(reprepares, SQLITE_STMTSTATUS_REPREPARE); +#endif +#ifdef SQLITE_STMTSTATUS_RUN + SET(runs, SQLITE_STMTSTATUS_RUN); +#endif +#ifdef SQLITE_STMTSTATUS_FILTER_MISS + SET(filter_misses, SQLITE_STMTSTATUS_FILTER_MISS); +#endif +#ifdef SQLITE_STMTSTATUS_FILTER_HIT + SET(filter_hits, SQLITE_STMTSTATUS_FILTER_HIT); +#endif +#undef SET + + if (!NIL_P(key)) { /* matched key should return above */ + rb_raise(rb_eArgError, "unknown key: %"PRIsVALUE, rb_sym2str(key)); + } + + return 0; +} + +/* call-seq: stmt.stats_as_hash(hash) + * + * Returns a Hash containing information about the statement. + */ +static VALUE +stats_as_hash(VALUE self) +{ + sqlite3StmtRubyPtr ctx; + TypedData_Get_Struct(self, sqlite3StmtRuby, &statement_type, ctx); + REQUIRE_OPEN_STMT(ctx); + VALUE arg = rb_hash_new(); + + stmt_stat_internal(arg, ctx->st); + return arg; +} + +/* call-seq: stmt.stmt_stat(hash_or_key) + * + * Returns a Hash containing information about the statement. + */ +static VALUE +stat_for(VALUE self, VALUE key) +{ + sqlite3StmtRubyPtr ctx; + TypedData_Get_Struct(self, sqlite3StmtRuby, &statement_type, ctx); + REQUIRE_OPEN_STMT(ctx); + + if (SYMBOL_P(key)) { + size_t value = stmt_stat_internal(key, ctx->st); + return SIZET2NUM(value); + } + else { + rb_raise(rb_eTypeError, "non-symbol given"); + } +} + +#ifdef SQLITE_STMTSTATUS_MEMUSED +/* call-seq: stmt.memory_used + * + * Return the approximate number of bytes of heap memory used to store the prepared statement + */ +static VALUE +memused(VALUE self) +{ + sqlite3StmtRubyPtr ctx; + TypedData_Get_Struct(self, sqlite3StmtRuby, &statement_type, ctx); + REQUIRE_OPEN_STMT(ctx); + + return INT2NUM(sqlite3_stmt_status(ctx->st, SQLITE_STMTSTATUS_MEMUSED, 0)); +} +#endif + #ifdef HAVE_SQLITE3_COLUMN_DATABASE_NAME /* call-seq: stmt.database_name(column_index) @@ -454,9 +601,14 @@ init_sqlite3_statement(void) rb_define_method(cSqlite3Statement, "column_name", column_name, 1); rb_define_method(cSqlite3Statement, "column_decltype", column_decltype, 1); rb_define_method(cSqlite3Statement, "bind_parameter_count", bind_parameter_count, 0); - rb_define_private_method(cSqlite3Statement, "prepare", prepare, 2); - #ifdef HAVE_SQLITE3_COLUMN_DATABASE_NAME rb_define_method(cSqlite3Statement, "database_name", database_name, 1); #endif +#ifdef SQLITE_STMTSTATUS_MEMUSED + rb_define_method(cSqlite3Statement, "memused", memused, 0); +#endif + + rb_define_private_method(cSqlite3Statement, "prepare", prepare, 2); + rb_define_private_method(cSqlite3Statement, "stats_as_hash", stats_as_hash, 0); + rb_define_private_method(cSqlite3Statement, "stat_for", stat_for, 1); } diff --git a/lib/sqlite3/statement.rb b/lib/sqlite3/statement.rb index 7b972763..f0f1bcc0 100644 --- a/lib/sqlite3/statement.rb +++ b/lib/sqlite3/statement.rb @@ -145,6 +145,33 @@ def must_be_open! # :nodoc: end end + # Returns a Hash containing information about the statement. + # The contents of the hash are implementation specific and may change in + # the future without notice. The hash includes information about internal + # statistics about the statement such as: + # - +fullscan_steps+: the number of times that SQLite has stepped forward + # in a table as part of a full table scan + # - +sorts+: the number of sort operations that have occurred + # - +autoindexes+: the number of rows inserted into transient indices + # that were created automatically in order to help joins run faster + # - +vm_steps+: the number of virtual machine operations executed by the + # prepared statement + # - +reprepares+: the number of times that the prepare statement has been + # automatically regenerated due to schema changes or changes to bound + # parameters that might affect the query plan + # - +runs+: the number of times that the prepared statement has been run + # - +filter_misses+: the number of times that the Bloom filter returned + # a find, and thus the join step had to be processed as normal + # - +filter_hits+: the number of times that a join step was bypassed + # because a Bloom filter returned not-found + def stat key = nil + if key + stat_for(key) + else + stats_as_hash + end + end + private # A convenience method for obtaining the metadata about the query. Note diff --git a/test/test_statement.rb b/test/test_statement.rb index 5d4d4377..a13050e0 100644 --- a/test/test_statement.rb +++ b/test/test_statement.rb @@ -288,5 +288,144 @@ def test_clear_bindings! stmt.close end + + def test_stat + assert @stmt.stat.is_a?(Hash) + end + + def test_stat_fullscan_steps + @db.execute 'CREATE TABLE test_table (id INTEGER PRIMARY KEY, name TEXT);' + 10.times do |i| + @db.execute 'INSERT INTO test_table (name) VALUES (?)', "name_#{i}" + end + @db.execute 'DROP INDEX IF EXISTS idx_test_table_id;' + stmt = @db.prepare("SELECT * FROM test_table WHERE name LIKE 'name%'") + stmt.execute.to_a + + assert_equal 9, stmt.stat(:fullscan_steps) + + stmt.close + end + + def test_stat_sorts + @db.execute 'CREATE TABLE test1(a)' + @db.execute 'INSERT INTO test1 VALUES (1)' + stmt = @db.prepare('select * from test1 order by a') + stmt.execute.to_a + + assert_equal 1, stmt.stat(:sorts) + + stmt.close + end + + def test_stat_autoindexes + @db.execute "CREATE TABLE t1(a,b);" + @db.execute "CREATE TABLE t2(c,d);" + 10.times do |i| + @db.execute 'INSERT INTO t1 (a, b) VALUES (?, ?)', [i, i.to_s] + @db.execute 'INSERT INTO t2 (c, d) VALUES (?, ?)', [i, i.to_s] + end + stmt = @db.prepare("SELECT * FROM t1, t2 WHERE a=c;") + stmt.execute.to_a + + assert_equal 9, stmt.stat(:autoindexes) + + stmt.close + end + + def test_stat_vm_steps + @db.execute 'CREATE TABLE test1(a)' + @db.execute 'INSERT INTO test1 VALUES (1)' + stmt = @db.prepare('select * from test1 order by a') + stmt.execute.to_a + + assert_operator stmt.stat(:vm_steps), :>, 0 + + stmt.close + end + + def test_stat_reprepares + @db.execute 'CREATE TABLE test_table (id INTEGER PRIMARY KEY, name TEXT);' + 10.times do |i| + @db.execute 'INSERT INTO test_table (name) VALUES (?)', "name_#{i}" + end + stmt = @db.prepare("SELECT * FROM test_table WHERE name LIKE ?") + stmt.execute('name%').to_a + + if stmt.stat.key?(:reprepares) + assert_equal 1, stmt.stat(:reprepares) + else + assert_raises(ArgumentError, "unknown key: reprepares") { stmt.stat(:reprepares) } + end + + stmt.close + end + + def test_stat_runs + @db.execute 'CREATE TABLE test1(a)' + @db.execute 'INSERT INTO test1 VALUES (1)' + stmt = @db.prepare('select * from test1') + stmt.execute.to_a + + if stmt.stat.key?(:runs) + assert_equal 1, stmt.stat(:runs) + else + assert_raises(ArgumentError, "unknown key: runs") { stmt.stat(:runs) } + end + + stmt.close + end + + def test_stat_filter_misses + @db.execute "CREATE TABLE t1(a,b);" + @db.execute "CREATE TABLE t2(c,d);" + 10.times do |i| + @db.execute 'INSERT INTO t1 (a, b) VALUES (?, ?)', [i, i.to_s] + @db.execute 'INSERT INTO t2 (c, d) VALUES (?, ?)', [i, i.to_s] + end + stmt = @db.prepare("SELECT * FROM t1, t2 WHERE a=c;") + stmt.execute.to_a + + if stmt.stat.key?(:filter_misses) + assert_equal 10, stmt.stat(:filter_misses) + else + assert_raises(ArgumentError, "unknown key: filter_misses") { stmt.stat(:filter_misses) } + end + + stmt.close + end + + def test_stat_filter_hits + @db.execute "CREATE TABLE t1(a,b);" + @db.execute "CREATE TABLE t2(c,d);" + 10.times do |i| + @db.execute 'INSERT INTO t1 (a, b) VALUES (?, ?)', [i, i.to_s] + @db.execute 'INSERT INTO t2 (c, d) VALUES (?, ?)', [i+1, i.to_s] + end + stmt = @db.prepare("SELECT * FROM t1, t2 WHERE a=c AND b = '1' AND d = '1';") + stmt.execute.to_a + + if stmt.stat.key?(:filter_hits) + assert_equal 1, stmt.stat(:filter_hits) + else + assert_raises(ArgumentError, "unknown key: filter_hits") { stmt.stat(:filter_hits) } + end + + stmt.close + end + + def test_memused + @db.execute 'CREATE TABLE test1(a)' + @db.execute 'INSERT INTO test1 VALUES (1)' + stmt = @db.prepare('select * from test1') + + skip("memused not defined") unless stmt.respond_to?(:memused) + + stmt.execute.to_a + + assert_operator stmt.memused, :>, 0 + + stmt.close + end end end