oculus 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. data/.gitignore +17 -0
  2. data/.travis.yml +7 -0
  3. data/Gemfile +3 -0
  4. data/LICENSE +22 -0
  5. data/README.md +34 -0
  6. data/Rakefile +18 -0
  7. data/TODO.md +12 -0
  8. data/bin/oculus +32 -0
  9. data/features/query.feature +26 -0
  10. data/features/step_definitions/query_steps.rb +35 -0
  11. data/features/support/env.rb +22 -0
  12. data/lib/oculus/connection/mysql2.rb +22 -0
  13. data/lib/oculus/connection.rb +7 -0
  14. data/lib/oculus/presenters/query_presenter.rb +40 -0
  15. data/lib/oculus/presenters.rb +1 -0
  16. data/lib/oculus/query.rb +61 -0
  17. data/lib/oculus/server/public/css/bootstrap.min.css +689 -0
  18. data/lib/oculus/server/public/css/codemirror.css +112 -0
  19. data/lib/oculus/server/public/css/reset.css +47 -0
  20. data/lib/oculus/server/public/css/style.css +107 -0
  21. data/lib/oculus/server/public/img/glyphicons-halflings-white.png +0 -0
  22. data/lib/oculus/server/public/img/glyphicons-halflings.png +0 -0
  23. data/lib/oculus/server/public/js/application.js +160 -0
  24. data/lib/oculus/server/public/js/bootstrap.min.js +6 -0
  25. data/lib/oculus/server/public/js/codemirror.min.js +1 -0
  26. data/lib/oculus/server/public/js/jquery.min.js +4 -0
  27. data/lib/oculus/server/public/js/spin.min.js +2 -0
  28. data/lib/oculus/server/views/history.erb +35 -0
  29. data/lib/oculus/server/views/index.erb +97 -0
  30. data/lib/oculus/server/views/layout.erb +37 -0
  31. data/lib/oculus/server/views/show.erb +59 -0
  32. data/lib/oculus/server.rb +88 -0
  33. data/lib/oculus/storage/file_store.rb +129 -0
  34. data/lib/oculus/storage.rb +7 -0
  35. data/lib/oculus/version.rb +3 -0
  36. data/lib/oculus.rb +29 -0
  37. data/oculus.gemspec +26 -0
  38. data/spec/connection_spec.rb +61 -0
  39. data/spec/file_store_spec.rb +111 -0
  40. data/spec/query_presenter_spec.rb +54 -0
  41. data/spec/query_spec.rb +107 -0
  42. metadata +173 -0
@@ -0,0 +1,129 @@
1
+ require 'yaml'
2
+ require 'csv'
3
+
4
+ module Oculus
5
+ module Storage
6
+ class FileStore
7
+ def initialize(root)
8
+ @root = root
9
+ end
10
+
11
+ def all_queries
12
+ Dir["#{root}/*.query"].map do |path|
13
+ File.parse(path)
14
+ end.sort { |a,b| b.id <=> a.id }
15
+ end
16
+
17
+ def save_query(query)
18
+ query.id = next_id if query.id.nil?
19
+
20
+ File.open(filename_for_id(query.id), 'w') do |file|
21
+ file.write_prelude(query.attributes)
22
+ file.write_results(query.results) if query.results
23
+ end
24
+ end
25
+
26
+ def load_query(id)
27
+ path = filename_for_id(id)
28
+
29
+ if File.exist?(path)
30
+ File.parse(path)
31
+ else
32
+ raise QueryNotFound, id
33
+ end
34
+ end
35
+
36
+ def delete_query(id)
37
+ path = filename_for_id(id)
38
+
39
+ if File.exist?(path)
40
+ File.unlink(path)
41
+ else
42
+ raise QueryNotFound, id
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ class File < ::File
49
+ def self.parse(path)
50
+ file = File.open(path)
51
+ attributes = file.attributes
52
+ attributes[:results] = file.results
53
+ Oculus::Query.new(attributes).tap do |query|
54
+ query.id = File.basename(path).split('.').first.to_i
55
+ end
56
+ end
57
+
58
+ def write_prelude(attributes)
59
+ write(YAML.dump(attributes))
60
+ puts("---")
61
+ end
62
+
63
+ def write_results(results)
64
+ csv_data = CSV.generate do |csv|
65
+ csv << results.first
66
+ results[1..-1].each do |result|
67
+ csv << result
68
+ end
69
+ end
70
+
71
+ write csv_data
72
+ end
73
+
74
+ def attributes
75
+ rewind
76
+
77
+ raw = gets
78
+
79
+ until (line = gets) == "---\n"
80
+ raw += line
81
+ end
82
+
83
+ YAML.load(raw)
84
+ end
85
+
86
+ def results
87
+ rewind
88
+
89
+ section = 0
90
+ section += 1 if gets.rstrip == "---" until section == 2 || eof?
91
+
92
+ CSV.new(read).to_a
93
+ end
94
+ end
95
+
96
+ def filename_for_id(id)
97
+ raise ArgumentError unless id.is_a?(Integer) || id =~ /^[0-9]+/
98
+ File.join(root, "#{id}.query")
99
+ end
100
+
101
+ def next_id
102
+ reset_primary_key unless File.exist?(primary_key_path)
103
+ id_file = File.open(primary_key_path, 'r+')
104
+ id_file.flock(File::LOCK_EX)
105
+
106
+ id = id_file.read.to_i
107
+
108
+ id_file.rewind
109
+ id_file.write(id + 1)
110
+ id_file.flock(File::LOCK_UN)
111
+ id_file.close
112
+
113
+ id
114
+ end
115
+
116
+ def reset_primary_key
117
+ File.open(primary_key_path, 'w') do |file|
118
+ file.puts '0'
119
+ end
120
+ end
121
+
122
+ def primary_key_path
123
+ File.join(root, 'NEXT_ID')
124
+ end
125
+
126
+ attr_reader :root
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,7 @@
1
+ require 'oculus/storage/file_store'
2
+
3
+ module Oculus
4
+ module Storage
5
+ class QueryNotFound < RuntimeError; end
6
+ end
7
+ end
@@ -0,0 +1,3 @@
1
+ module Oculus
2
+ VERSION = "0.5.0"
3
+ end
data/lib/oculus.rb ADDED
@@ -0,0 +1,29 @@
1
+ require "oculus/version"
2
+ require "oculus/storage"
3
+ require "oculus/connection"
4
+ require "oculus/query"
5
+
6
+ module Oculus
7
+ extend self
8
+
9
+ DEFAULT_CONNECTION_OPTIONS = { :host => 'localhost', :username => 'root' }
10
+
11
+ attr_writer :cache_path
12
+
13
+ def cache_path
14
+ @cache_path ||= 'tmp/data'
15
+ end
16
+
17
+ attr_writer :data_store
18
+
19
+ def data_store
20
+ @data_store ||= Oculus::Storage::FileStore.new(Oculus.cache_path)
21
+ end
22
+
23
+ attr_writer :connection_options
24
+
25
+ def connection_options
26
+ @connection_options ||= DEFAULT_CONNECTION_OPTIONS
27
+ end
28
+ end
29
+
data/oculus.gemspec ADDED
@@ -0,0 +1,26 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require File.expand_path('../lib/oculus/version', __FILE__)
3
+
4
+ Gem::Specification.new do |gem|
5
+ gem.authors = ["Paul Rosania"]
6
+ gem.email = ["paul.rosania@gmail.com"]
7
+ gem.description = %q{Oculus is a web-based logging SQL client. It keeps a history of your queries and the results they returned, so your research is always at hand, easy to share and easy to repeat or reproduce in the future.}
8
+ gem.summary = %q{Oculus is a web-based logging SQL client.}
9
+ gem.homepage = "https://github.com/paulrosania/oculus"
10
+
11
+ gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
12
+ gem.files = `git ls-files`.split("\n")
13
+ gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
14
+ gem.name = "oculus"
15
+ gem.require_paths = ["lib"]
16
+ gem.version = Oculus::VERSION
17
+
18
+ gem.add_runtime_dependency "sinatra", [">= 1.3.0"]
19
+ gem.add_runtime_dependency "mysql2", [">= 0.3.11"]
20
+ gem.add_runtime_dependency "vegas", [">= 0.1.4"]
21
+
22
+ gem.add_development_dependency "rake"
23
+ gem.add_development_dependency "cucumber", [">= 1"]
24
+ gem.add_development_dependency "rspec", [">= 2"]
25
+ gem.add_development_dependency "capybara", [">= 1"]
26
+ end
@@ -0,0 +1,61 @@
1
+ require 'oculus'
2
+
3
+ describe Oculus::Connection do
4
+ before(:all) do
5
+ client = Mysql2::Client.new(:host => "localhost", :username => "root")
6
+ client.query "CREATE DATABASE IF NOT EXISTS test"
7
+ client.query "USE test"
8
+ client.query %[
9
+ CREATE TABLE IF NOT EXISTS oculus_users (
10
+ id MEDIUMINT NOT NULL AUTO_INCREMENT,
11
+ name VARCHAR(255),
12
+ PRIMARY KEY (id)
13
+ );
14
+ ]
15
+
16
+ client.query 'TRUNCATE oculus_users'
17
+
18
+ client.query %[
19
+ INSERT INTO oculus_users (name) VALUES ('Paul'), ('Amy'), ('Peter')
20
+ ]
21
+ end
22
+
23
+ subject { Oculus::Connection::Mysql2.new(:database => 'test') }
24
+
25
+ it "fetches a result set" do
26
+ subject.execute("SELECT * FROM oculus_users").should == [['id', 'name'], [1, 'Paul'], [2, 'Amy'], [3, 'Peter']]
27
+ end
28
+
29
+ it "returns nil for queries that don't return result sets" do
30
+ query_connection = Mysql2::Client.new(:host => "localhost", :database => "test")
31
+ thread_id = query_connection.thread_id
32
+ Thread.new {
33
+ query_connection.execute("SELECT * FROM oculus_users WHERE SLEEP(2)")
34
+ }
35
+
36
+ sleep 0.1
37
+ subject.execute("KILL QUERY #{thread_id}").should be_nil
38
+ end
39
+
40
+ it "raises a Connection::Error on syntax errors" do
41
+ lambda {
42
+ subject.execute("FOO BAZ QUUX")
43
+ }.should raise_error(Oculus::Connection::Error)
44
+ end
45
+
46
+ it "raises a Connection::Error when the query is interrupted" do
47
+ thread_id = subject.thread_id
48
+ Thread.new {
49
+ sleep 0.1
50
+ Mysql2::Client.new(:host => "localhost", :username => "root").query("KILL QUERY #{thread_id}")
51
+ }
52
+
53
+ lambda {
54
+ subject.execute("SELECT * FROM oculus_users WHERE SLEEP(2)")
55
+ }.should raise_error(Oculus::Connection::Error)
56
+ end
57
+
58
+ it "provides the connection's thread_id" do
59
+ subject.thread_id.should be_an Integer
60
+ end
61
+ end
@@ -0,0 +1,111 @@
1
+ require 'oculus'
2
+
3
+ describe Oculus::Storage::FileStore do
4
+ subject { Oculus::Storage::FileStore.new('tmp/test_cache') }
5
+
6
+ let(:query) do
7
+ Oculus::Query.new(:name => "All users",
8
+ :query => "SELECT * FROM oculus_users",
9
+ :author => "Paul",
10
+ :thread_id => 42,
11
+ :results => [['id', 'users'], ['1', 'Paul'], ['2', 'Amy']])
12
+ end
13
+
14
+ let(:other_query) do
15
+ Oculus::Query.new(:name => "Admin users",
16
+ :query => "SELECT * FROM oculus_users WHERE is_admin = 1",
17
+ :author => "Paul",
18
+ :thread_id => 42,
19
+ :results => [['id', 'users'], ['2', 'Amy']])
20
+ end
21
+
22
+ let(:broken_query) do
23
+ Oculus::Query.new(:name => "Admin users",
24
+ :query => "FOO BAZ QUUX",
25
+ :author => "Paul",
26
+ :thread_id => 42,
27
+ :error => "You have an error in your SQL syntax")
28
+ end
29
+
30
+ before do
31
+ FileUtils.mkdir_p('tmp/test_cache')
32
+ end
33
+
34
+ after do
35
+ FileUtils.rm_r('tmp/test_cache')
36
+ end
37
+
38
+ it "round-trips a query with no results to disk" do
39
+ query = Oculus::Query.new(:name => "Unfinished query", :author => "Me")
40
+ subject.save_query(query)
41
+ subject.load_query(query.id).results.should == []
42
+ subject.load_query(query.id).query.should == query.query
43
+ subject.load_query(query.id).date.should == query.date
44
+ subject.load_query(query.id).author.should == query.author
45
+ subject.load_query(query.id).id.should == query.id
46
+ subject.load_query(query.id).thread_id.should == query.thread_id
47
+ end
48
+
49
+ it "round-trips a query with an error to disk" do
50
+ subject.save_query(broken_query)
51
+ subject.load_query(broken_query.id).results.should == []
52
+ subject.load_query(broken_query.id).error.should == broken_query.error
53
+ subject.load_query(broken_query.id).query.should == broken_query.query
54
+ subject.load_query(broken_query.id).date.should == broken_query.date
55
+ subject.load_query(broken_query.id).author.should == broken_query.author
56
+ subject.load_query(broken_query.id).id.should == broken_query.id
57
+ subject.load_query(broken_query.id).thread_id.should == broken_query.thread_id
58
+ end
59
+
60
+ it "round-trips a query to disk" do
61
+ subject.save_query(query)
62
+ subject.load_query(query.id).results.should == query.results
63
+ subject.load_query(query.id).query.should == query.query
64
+ subject.load_query(query.id).date.should == query.date
65
+ subject.load_query(query.id).author.should == query.author
66
+ subject.load_query(query.id).id.should == query.id
67
+ subject.load_query(query.id).thread_id.should == query.thread_id
68
+ end
69
+
70
+ it "doesn't overwrite an existing query id when saving" do
71
+ subject.save_query(query)
72
+ original_id = query.id
73
+ subject.save_query(query)
74
+ query.id.should == original_id
75
+ end
76
+
77
+ it "raises QueryNotFound for missing queries" do
78
+ lambda {
79
+ subject.load_query(39827493)
80
+ }.should raise_error(Oculus::Storage::QueryNotFound)
81
+ end
82
+
83
+ it "fetches all queries in reverse chronological order" do
84
+ subject.save_query(query)
85
+ subject.save_query(other_query)
86
+
87
+ subject.all_queries.map(&:results).should == [other_query.results, query.results]
88
+ end
89
+
90
+ it "deletes queries" do
91
+ subject.save_query(query)
92
+ subject.load_query(query.id).name.should == query.name
93
+ subject.delete_query(query.id)
94
+
95
+ lambda {
96
+ subject.load_query(query.id)
97
+ }.should raise_error(Oculus::Storage::QueryNotFound)
98
+ end
99
+
100
+ it "raises QueryNotFound when deleting a nonexistent query" do
101
+ lambda {
102
+ subject.delete_query(10983645)
103
+ }.should raise_error(Oculus::Storage::QueryNotFound)
104
+ end
105
+
106
+ it "sanitizes query IDs" do
107
+ lambda {
108
+ subject.delete_query('..')
109
+ }.should raise_error(ArgumentError)
110
+ end
111
+ end
@@ -0,0 +1,54 @@
1
+ require_relative '../lib/oculus/query'
2
+ require_relative '../lib/oculus/presenters/query_presenter'
3
+
4
+ describe Oculus::Presenters::QueryPresenter do
5
+ let(:query) { Oculus::Query.new }
6
+ let(:presenter) { Oculus::Presenters::QueryPresenter.new(query) }
7
+
8
+ it "should delegate to the underlying query" do
9
+ query.name = 'foo'
10
+ presenter.description.should == 'foo'
11
+ end
12
+
13
+ it "has a formatted date" do
14
+ query.date = Time.mktime(2010, 1, 1, 12, 34)
15
+ presenter.formatted_date.should == '2010-01-01 12:34'
16
+ end
17
+
18
+ it "reports successful queries" do
19
+ query.stub(:complete?).and_return(true)
20
+ presenter.status.should == 'done'
21
+ end
22
+
23
+ it "reports failed queries" do
24
+ query.stub(:complete?).and_return(true)
25
+ query.stub(:error).and_return("you fail")
26
+ presenter.status.should == 'error'
27
+ end
28
+
29
+ it "reports loading queries" do
30
+ query.stub(:complete?).and_return(false)
31
+ presenter.status.should == 'loading'
32
+ end
33
+
34
+ it "uses name for a description when there is one" do
35
+ query.name = "foo"
36
+ presenter.description.should == "foo"
37
+ end
38
+
39
+ it "uses SQL for a description when there isn't a name" do
40
+ query.name = nil
41
+ query.query = "SELECT * FROM foo"
42
+ presenter.description.should == "SELECT * FROM foo"
43
+ end
44
+
45
+ it "reports that the query has been named" do
46
+ query.name = "Select all the things"
47
+ presenter.should be_named
48
+ end
49
+
50
+ it "reports that the query has not been named" do
51
+ query.name = nil
52
+ presenter.should_not be_named
53
+ end
54
+ end
@@ -0,0 +1,107 @@
1
+ require 'oculus'
2
+
3
+ describe Oculus::Query do
4
+ before do
5
+ Oculus.data_store = stub
6
+ end
7
+
8
+ it "runs the query against the supplied connection" do
9
+ connection = stub
10
+ query = Oculus::Query.new(:query => 'SELECT * FROM users')
11
+ connection.should_receive(:execute).with('SELECT * FROM users')
12
+ query.execute(connection)
13
+ end
14
+
15
+ it "stores the results of running the query" do
16
+ connection = stub(:execute => [['id', 'name'], [1, 'Paul']])
17
+ query = Oculus::Query.new(:query => 'SELECT * FROM users')
18
+ query.execute(connection)
19
+ query.results.should == [['id', 'name'], [1, 'Paul']]
20
+ end
21
+
22
+ it "stores errors when queries fail" do
23
+ connection = stub
24
+ query = Oculus::Query.new(:query => 'SELECT * FROM users')
25
+ connection.stub(:execute).and_raise(Oculus::Connection::Error.new('You have an error in your SQL syntax'))
26
+ query.execute(connection)
27
+ query.error.should == 'You have an error in your SQL syntax'
28
+ end
29
+
30
+ it "stores the query itself" do
31
+ query = Oculus::Query.new(:query => 'SELECT * FROM users')
32
+ query.query.should == 'SELECT * FROM users'
33
+ end
34
+
35
+ it "stores the querying connection's thread ID" do
36
+ query = Oculus::Query.new(:thread_id => 42)
37
+ query.thread_id.should == 42
38
+ end
39
+
40
+ it "has a date" do
41
+ query = Oculus::Query.new
42
+ query.date.should be nil
43
+ end
44
+
45
+ it "updates date on save" do
46
+ Oculus.data_store.stub(:save_query)
47
+ Time.stub(:now).and_return(now = stub)
48
+ query = Oculus::Query.create(:results => [['id', 'name'], [1, 'Paul']])
49
+ query.date.should == now
50
+ end
51
+
52
+ it "has a name" do
53
+ query = Oculus::Query.new(:name => 'foo')
54
+ query.name.should == 'foo'
55
+ end
56
+
57
+ it "has an author" do
58
+ query = Oculus::Query.new(:author => 'Paul')
59
+ query.author.should == 'Paul'
60
+ end
61
+
62
+ it "stores new queries in the data store on creation" do
63
+ Oculus.data_store.should_receive(:save_query)
64
+ query = Oculus::Query.create(:results => [['id', 'name'], [1, 'Paul']])
65
+ end
66
+
67
+ it "stores new queries in the data store on save" do
68
+ Oculus.data_store.should_receive(:save_query)
69
+ query = Oculus::Query.new(:results => [['id', 'name'], [1, 'Paul']])
70
+ query.save
71
+ end
72
+
73
+ it "retrieves cached queries from the data store" do
74
+ Oculus.data_store.should_receive(:load_query).with(1)
75
+ Oculus::Query.find(1)
76
+ end
77
+
78
+ it "is not complete when no results are present" do
79
+ query = Oculus::Query.new(:query => 'SELECT * FROM users')
80
+ query.should_not be_complete
81
+ end
82
+
83
+ it "is complete when results are present" do
84
+ query = Oculus::Query.new(:results => [['id', 'name'], [1, 'Paul']])
85
+ query.should be_complete
86
+ end
87
+
88
+ it "is complete when there is an error" do
89
+ query = Oculus::Query.new(:error => "That's not how to write SQL")
90
+ query.should be_complete
91
+ end
92
+
93
+ it "is not successful when it's not complete" do
94
+ query = Oculus::Query.new(:query => 'SELECT * FROM users')
95
+ query.succeeded?.should be false
96
+ end
97
+
98
+ it "is successful when results are present" do
99
+ query = Oculus::Query.new(:results => [['id', 'name'], [1, 'Paul']])
100
+ query.succeeded?.should be true
101
+ end
102
+
103
+ it "is not successful when there is an error" do
104
+ query = Oculus::Query.new(:error => "That's not how to write SQL")
105
+ query.succeeded?.should be false
106
+ end
107
+ end