activerecord-metal 0.1.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.
data/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # activerecord-metal
2
+
3
+ Because sometimes you need just SQL only.
4
+
5
+ ## Installation
6
+
7
+ gem install activerecord-metal
8
+
9
+ ## Usage - Querying
10
+
11
+ The initial impulse to build activerecord-metal is that I needed to run custom SQL
12
+ queries and wanted properly typed results - something that ActiveRecord gives you
13
+ only for columns that are defined in the table layout, (and "count", probably),
14
+ but not for custom calculated values.
15
+
16
+ So there is support for queries.
17
+
18
+ # use the current ActiveRecord::Base connection
19
+ metal = ActiveRecord::Metal.new
20
+
21
+ # ask for single results
22
+ metal.ask "SELECT 2+4" # => 6
23
+ metal.ask "SELECT '2+4=6'" # => '2+4=6'
24
+
25
+ # properly deduce types
26
+ metal.ask "SELECT '1999-01-01 00:00:00'::timestamp" # => returns a Time object
27
+
28
+ # iterate over results
29
+ metal.exec("SELECT id, name FROM users") do |id, name|
30
+ # do something with id and name
31
+ # ...
32
+ end
33
+
34
+ # also metal.exec returns all rows in an array, which also
35
+ # contains some type information.
36
+ results = metal.exec("SELECT id, name FROM users") # => ary
37
+ results.types # => [ Numerical, String ]
38
+ results.columns # => [ "id", "name" ]
39
+
40
+ # This information is contained in each of the rows also
41
+ row = results.first
42
+ row.types # => [ Numerical, String ]
43
+ row.columns # => [ "id", "name" ]
44
+
45
+ # use positional parameters: note: Rails' '?' placeholders don't work here,
46
+ # only what is the database's default; and Postgresql uses $1, $2, ...
47
+ metal.exec("SELECT id, name FROM users WHERE name=$1", "me")
48
+
49
+ ## Usage - Mass import
50
+
51
+ # Mass imports for array records
52
+ #
53
+ records = [
54
+ [1, "first user"],
55
+ [2, "second user"],
56
+ [3, "third user"]
57
+ ]
58
+
59
+ #
60
+ metal.import "users", records # fill from left
61
+ metal.import "users", records, :columns => [ "id", "name" ] # preferred
62
+
63
+ # Mass imports for hash records
64
+ #
65
+ records = [
66
+ {id:1, name:"first user", email:"first-user@inter.net"},
67
+ {id:2, name:"second user"},
68
+ {id:3, name:"third user"}
69
+ ]
70
+
71
+ #
72
+ metal.import "users", records
73
+
74
+ ### How fast is the mass import?
75
+
76
+ The metal's importer is faster than ActiveRecord::Base's, because:
77
+
78
+ - it is using one prepared statement per import session, as (AFAIK, again)
79
+ one prepared statement per individual record
80
+ - it does not fetch ids etc.
81
+ - it does not do all the rails shananiggins: no
82
+
83
+ Hmm, lets see: (Note: This is on a Macbook Air w/Ruby 1.9.2 and activerecord 2.3 - YMMV)
84
+
85
+ 1.9.2 ~/projects/gem/activerecord-metal[master] > bundle exec script/console
86
+ Loaded /Users/eno/.irbrc
87
+ irb(main):001:0> ActiveRecord::Metal::Postgresql.etest
88
+ Loaded suite ActiveRecord::Metal::Postgresql::Etest
89
+ Started
90
+ ....
91
+ INFO -- : 7.8 msecs INSERT 100 hashes into alloys
92
+ INFO -- : 6.8 msecs INSERT 100 arrays into alloys
93
+ INFO -- : 63.6 msecs Import 100 hashes via ActiveRecord::Base
94
+ INFO -- : 670.3 msecs INSERT 10000 hashes into alloys
95
+ INFO -- : 676.9 msecs INSERT 10000 arrays into alloys
96
+ INFO -- : 6520.9 msecs Import 10000 hashes via ActiveRecord::Base
97
+
98
+ A 10 times speed up sounds massive (and will probably become even faster once
99
+ the `COPY FROM STDIN` importer is completed)), but on the other hand
100
+ ActiveRecord::Base imports a single record in less than 700 microseconds,
101
+ which is probably fast enough for most cases.
102
+
103
+ ## Why not ActiveRecord::Base?
104
+
105
+ ActiveRecord::Base is limited in several ways. It does not
106
+
107
+ - defer data types from what is in the database - unless it is a column in one of your models.
108
+ - sometimes builds very slow queries
109
+ - use prepared statements (AFAIK)
110
+ - activerecord < 4 does not make use of hstore types
111
+
112
+ ## Why not ActiveRecord::Metal?
113
+
114
+ ActiveRecord::Metal depends heavily on features of specific databases and
115
+ adapters. It currently works only on Postgres. Your SQL must be adapted
116
+ to the database server. There is no validation (except what you are
117
+ implementing in SQL). And of course: the code base is not exactly mature ;)
118
+
119
+ And documentation is still missing. Code coverage is good, though:
120
+
121
+ All Files (97.22% covered at 88.78 hits/line)
122
+ 6 files in total. 288 relevant lines. 280 lines covered and 8 lines missed
123
+
124
+ ## License
125
+
126
+ The activerecord-metal gem is distributed under the terms of the Modified BSD License, see LICENSE.BSD for details.
@@ -0,0 +1,67 @@
1
+ require "active_record"
2
+
3
+ class ActiveRecord::Metal
4
+ attr :connection
5
+
6
+ def initialize(connection = ActiveRecord::Base.connection)
7
+ @connection = connection
8
+
9
+ extend implementation
10
+
11
+ initialize_implementation
12
+ end
13
+
14
+ private
15
+
16
+ # To be overridden by the implementation
17
+ def initialize_implementation
18
+ end
19
+
20
+ def implementation
21
+ case connection
22
+ when ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
23
+ require_relative "metal/postgresql"
24
+ ActiveRecord::Metal::Postgresql
25
+ end
26
+ end
27
+ end
28
+
29
+ require_relative "metal/logging"
30
+ require_relative "metal/transaction"
31
+
32
+ class ActiveRecord::Metal
33
+ include Transaction
34
+
35
+ include Logging
36
+ extend Logging
37
+ end
38
+
39
+ module ActiveRecord::Metal::EtestBase
40
+ SELF = self
41
+
42
+ def self.load_expectation_assertions
43
+ require "expectation/assertions"
44
+ extend Expectation::Assertions
45
+ end
46
+
47
+ def setup
48
+ SELF.load_expectation_assertions
49
+ end
50
+
51
+ def metal
52
+ @metal ||= ActiveRecord::Metal.new
53
+ end
54
+ end
55
+
56
+ module ActiveRecord::Metal::Etest
57
+ include ActiveRecord::Metal::EtestBase
58
+
59
+ def metal
60
+ @metal ||= ActiveRecord::Metal.new
61
+ end
62
+
63
+ def test_pg_connection
64
+ expect! metal.connection => ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
65
+ expect! metal.is_a?(ActiveRecord::Metal::Postgresql)
66
+ end
67
+ end
@@ -0,0 +1,43 @@
1
+ module ActiveRecord::Metal::Logging
2
+ SELF = self
3
+
4
+ @@logger = nil
5
+
6
+ def self.logger
7
+ @@logger ||= ActiveRecord::Base.logger
8
+ end
9
+
10
+ def self.logger=(logger)
11
+ @@logger = logger
12
+ end
13
+
14
+ def log_benchmark(severity, runtime, msg)
15
+ @benchmark_depth ||= 0
16
+ return if @benchmark_depth > 0
17
+ return unless logger = SELF.logger
18
+
19
+ threshold = ActiveRecord::Base.auto_explain_threshold_in_seconds
20
+ if threshold
21
+ return if runtime < threshold
22
+ severity = :info if severity == :debug
23
+ end
24
+
25
+ runtime = "%.1f msecs" % (runtime * 1000)
26
+
27
+ unless msg.gsub!(/\{\{runtime\}\}/, runtime)
28
+ msg = "#{runtime} #{msg}"
29
+ end
30
+
31
+ logger.send severity, msg
32
+ end
33
+
34
+ def benchmark(msg, severity = :info)
35
+ @benchmark_depth ||= 0
36
+ @benchmark_depth += 1
37
+ started_at = Time.now
38
+ yield
39
+ ensure
40
+ @benchmark_depth -= 1
41
+ log_benchmark severity, Time.now - started_at, msg
42
+ end
43
+ end
@@ -0,0 +1,126 @@
1
+ require "digest/md5"
2
+
3
+ module ActiveRecord::Metal::Postgresql
4
+ private
5
+
6
+ def prepared_statements_by_name
7
+ @prepared_statements_by_name ||= {}
8
+ end
9
+
10
+ def prepared_statements
11
+ @prepared_statements ||= Hash.new { |hsh, sql| hsh[sql] = _prepare(sql) }
12
+ end
13
+
14
+ def resolve_query(query)
15
+ query.is_a?(Symbol) ? prepared_statements_by_name[query] : query
16
+ end
17
+
18
+ def _prepare(sql)
19
+ name = prepared_statement_name(sql)
20
+ pg_conn.prepare(name, sql)
21
+ name = name.to_sym
22
+ prepared_statements_by_name[name] = sql
23
+ name
24
+ end
25
+
26
+ public
27
+
28
+ def prepare(sql)
29
+ prepared_statements[sql]
30
+ end
31
+
32
+ def unprepare(query)
33
+ expect! query => [ Symbol, String ]
34
+ case query
35
+ when Symbol
36
+ exec_("DEALLOCATE PREPARE #{query}")
37
+ sql = prepared_statements_by_name.delete query
38
+ prepared_statements.delete sql
39
+ when String
40
+ name = prepared_statement_name(query)
41
+ exec_("DEALLOCATE PREPARE #{name}")
42
+ prepared_statements.delete query
43
+ prepared_statements_by_name.delete name
44
+ end
45
+ end
46
+
47
+ def unprepare_all
48
+ exec_ "DEALLOCATE PREPARE ALL"
49
+ @prepared_statements_by_name = @prepared_statements = nil
50
+ end
51
+
52
+ private
53
+
54
+ # -- raw queries ----------------------------------------------------
55
+
56
+ def exec_(sql)
57
+ # STDERR.puts "exec_ --> #{sql}"
58
+ result = pg_conn.exec sql
59
+ result.check
60
+ result
61
+ end
62
+
63
+ def exec_prepared(sym, *args)
64
+ # STDERR.puts "exec_prepared: #{sym.inspect}"
65
+ args = args.map do |arg|
66
+ if arg.is_a?(Hash)
67
+ ActiveRecord::Metal::Postgresql::Conversions::HStore.escape(arg)
68
+ else
69
+ arg
70
+ end
71
+ end
72
+
73
+ result = pg_conn.exec_prepared(sym.to_s, args)
74
+ result.check
75
+ result
76
+ end
77
+
78
+ def prepared_statement_name(sql)
79
+ "pg_metal_#{Digest::MD5.hexdigest(sql)}"
80
+ end
81
+
82
+ # -- initialisation -------------------------------------------------
83
+
84
+ attr :pg_types, :pg_conn
85
+
86
+ def initialize_implementation
87
+ @pg_conn = connection.instance_variable_get("@connection")
88
+ @pg_types = load_pg_types
89
+
90
+ unprepare_all
91
+
92
+ name, installed_version = exec("SELECT name, installed_version FROM pg_available_extensions WHERE name='hstore'").first
93
+ exec_ "CREATE EXTENSION IF NOT EXISTS hstore" unless installed_version
94
+ end
95
+
96
+ def load_pg_types
97
+ Hash.new("_default").tap do |hsh|
98
+ hsh[17] = "bytea"
99
+
100
+ connection.select_all("SELECT typelem, typname FROM pg_type").
101
+ each do |record|
102
+ typelem, typname = record.values_at "typelem", "typname"
103
+ hsh[typelem.to_i] = typname
104
+ end
105
+ end
106
+ end
107
+
108
+ public
109
+
110
+ def has_table?(name)
111
+ ask "SELECT 't'::BOOLEAN FROM pg_tables WHERE tablename=$1", name
112
+ end
113
+
114
+ def has_index?(name)
115
+ ask "SELECT 't'::BOOLEAN FROM pg_indexes WHERE indexname=$1", name
116
+ end
117
+ end
118
+
119
+ require_relative "postgresql/conversions"
120
+ require_relative "postgresql/queries"
121
+ require_relative "postgresql/import"
122
+
123
+ module ActiveRecord::Metal::Postgresql
124
+ include Queries
125
+ include Import
126
+ end
@@ -0,0 +1,170 @@
1
+ module ActiveRecord::Metal::Postgresql::Conversions
2
+ module Numeric; end
3
+ module Time; end
4
+ module Date; end
5
+ module Boolean; end
6
+ module String; end
7
+ module HStore; end
8
+
9
+ include Numeric, Time, Date, Boolean, String, HStore
10
+ extend self
11
+
12
+ def resolve_type(symbol)
13
+ sub_converter = included_modules.detect { |mod| mod.method_defined?(symbol) }
14
+ sub_converter ? sub_converter::T : String
15
+ end
16
+ end
17
+
18
+ module ActiveRecord::Metal::Postgresql::Conversions::Numeric
19
+ T = ::Numeric
20
+
21
+ def _numeric(s)
22
+ s =~ /\D/ ? Float(s) : Integer(s)
23
+ end
24
+
25
+ def _int(s)
26
+ Integer(s)
27
+ end
28
+
29
+ def _float(s);
30
+ Float(s)
31
+ rescue ArgumentError
32
+ case s
33
+ when /^Infinity$/ then Float::INFINITY
34
+ when /^-Infinity$/ then -Float::INFINITY
35
+ when /^NaN$/ then Float::NAN
36
+ else raise
37
+ end
38
+ end
39
+
40
+ def _money(s)
41
+ Float s.gsub(/[^-0-9.]/, "")
42
+ end
43
+
44
+ def _oid(s)
45
+ Integer(s)
46
+ end
47
+ end
48
+
49
+ module ActiveRecord::Metal::Postgresql::Conversions::Date
50
+ T = ::Date
51
+
52
+ def _date(s)
53
+ T.parse(s)
54
+ end
55
+ end
56
+
57
+ module ActiveRecord::Metal::Postgresql::Conversions::Time
58
+ T = ::Time
59
+
60
+ def _time(s)
61
+ T.parse(s)
62
+ end
63
+
64
+ def _timestamp(s)
65
+ T.parse(s)
66
+ end
67
+
68
+ def _timetz(s)
69
+ T.parse(s)
70
+ end
71
+
72
+ def _timestamptz(s)
73
+ T.parse(s)
74
+ end
75
+ end
76
+
77
+ module ActiveRecord::Metal::Postgresql::Conversions::Boolean
78
+ T = ::TrueClass
79
+
80
+ def _bool(s); s == "t"; end
81
+ end
82
+
83
+ module ActiveRecord::Metal::Postgresql::Conversions::String
84
+ T = ::String
85
+
86
+ def _varchar(s)
87
+ s
88
+ end
89
+
90
+ def _text(s)
91
+ s
92
+ end
93
+
94
+ def _default(s)
95
+ s
96
+ end
97
+
98
+ def _name(s)
99
+ s
100
+ end
101
+
102
+ def _enum(s)
103
+ s
104
+ end
105
+ end
106
+
107
+ module ActiveRecord::Metal::Postgresql::Conversions::HStore
108
+ SELF = self
109
+
110
+ T = ::Hash
111
+ NULL = nil
112
+
113
+ def _hstore(s)
114
+ SELF.unescape(s)
115
+ end
116
+
117
+ HSTORE_ESCAPED = /[,\s=>\\]/
118
+
119
+ # From activerecord-postgres-hstore, r0.6
120
+ # Escapes values such that they will work in an hstore string
121
+ def self.escape_string(str)
122
+ if str.nil?
123
+ return 'NULL'
124
+ end
125
+
126
+ str = str.to_s
127
+ # backslash is an escape character for strings, and an escape character for gsub, so you need 6 backslashes to get 2 in the output.
128
+ # see http://stackoverflow.com/questions/1542214/weird-backslash-substitution-in-ruby for the gory details
129
+ str = str.gsub(/\\/, '\\\\\\')
130
+ # escape backslashes before injecting more backslashes
131
+ str = str.gsub(/"/, '\"')
132
+
133
+ if str =~ HSTORE_ESCAPED or str.empty?
134
+ str = '"%s"' % str
135
+ end
136
+
137
+ return str
138
+ end
139
+
140
+ def self.escape(hsh, connection=ActiveRecord::Base.connection)
141
+ hsh.map do |idx, val|
142
+ "%s=>%s" % [escape_string(idx), escape_string(val)]
143
+ end * ","
144
+ end
145
+
146
+ # Creates a hash from a valid double quoted hstore format, 'cause this is the format
147
+ # that postgresql spits out.
148
+ def self.unescape(str)
149
+ token_pairs = (str.scan(hstore_pair)).map { |k,v| [k,v =~ /^NULL$/i ? nil : v] }
150
+ token_pairs = token_pairs.map { |k,v|
151
+ [ unescape_string(k).to_sym, unescape_string(v) ]
152
+ }
153
+ ::Hash[ token_pairs ]
154
+ end
155
+
156
+ def self.unescape_string(str)
157
+ case str
158
+ when nil then str
159
+ when /\A"(.*)"\Z/m then $1.gsub(/\\(.)/, '\1')
160
+ else str.gsub(/\\(.)/, '\1')
161
+ end
162
+ end
163
+
164
+ def self.hstore_pair
165
+ quoted_string = /"[^"\\]*(?:\\.[^"\\]*)*"/
166
+ unquoted_string = /[^\s=,][^\s=,\\]*(?:\\.[^\s=,\\]*|=[^,>])*/
167
+ string = /(#{quoted_string}|#{unquoted_string})/
168
+ /#{string}\s*=>\s*#{string}/
169
+ end
170
+ end
@@ -0,0 +1,122 @@
1
+ # coding: utf-8
2
+ #require "active_record/metal/postgresql/conversions/etest"
3
+
4
+ module ActiveRecord::Metal::Postgresql::Conversions::Etest
5
+ include ActiveRecord::Metal::EtestBase
6
+
7
+ def test_numeric_types
8
+ expect! metal.ask("SELECT 1") => 1
9
+
10
+ [ "smallint", "integer", "bigint", "decimal", "numeric", "real", "double precision" ].each do |type|
11
+ expect! metal.ask("SELECT 1::#{type}") => 1
12
+ end
13
+ end
14
+
15
+ def test_special_numbers
16
+ expect! metal.ask("SELECT 'Infinity'::real") => Float::INFINITY
17
+ expect! metal.ask("SELECT '-Infinity'::real") => -Float::INFINITY
18
+
19
+ # Can't test for NAN == NAN
20
+ # expect! metal.ask("SELECT 'NaN'::real") => Float::NAN
21
+ end
22
+
23
+ def test_money
24
+ expect! metal.ask("SELECT 1234::text::money") => 1234.0
25
+ end
26
+
27
+ def test_strings
28
+ expect! metal.ask("SELECT 'ok'")
29
+ expect! metal.ask("SELECT 'good '") => 'good '
30
+ expect! metal.ask("SELECT 'too long'::varchar(5)") => 'too l'
31
+ expect! metal.ask("SELECT 'ä'") => 'ä'
32
+ end
33
+
34
+ def test_bytea
35
+ expect! metal.ask("SELECT '1'::bytea") => '1'
36
+ end
37
+
38
+ def test_dates
39
+ expect! metal.ask("SELECT '1/18/1999'::date") => Date.parse("18. 1. 1999")
40
+ expect! metal.ask("SELECT '1999-01-08 04:05:06'::timestamp") => Time.parse("1999-01-08 04:05:06")
41
+ expect! metal.ask("SELECT TIMESTAMP '1999-01-08 04:05:06'") => Time.parse("1999-01-08 04:05:06")
42
+ expect! metal.ask("SELECT CURRENT_TIME") => Time
43
+ expect! metal.ask("SELECT CURRENT_TIMESTAMP") => Time
44
+ end
45
+
46
+ def test_true_and_friends
47
+ expect! metal.ask("SELECT TRUE") => true
48
+ expect! metal.ask("SELECT FALSE") => false
49
+ expect! metal.ask("SELECT NULL") => nil
50
+ end
51
+
52
+ def test_empty_result
53
+ expect! metal.ask("SELECT NULL WHERE FALSE") => nil
54
+ expect! metal.ask("SELECT 1 WHERE FALSE") => nil
55
+ end
56
+
57
+ def test_column_names
58
+ result = metal.exec("SELECT 1 as one, 2 as two WHERE FALSE")
59
+ expect! result.columns == %w(one two)
60
+ expect! result.empty?
61
+
62
+ result = metal.exec("SELECT 1 as one, 2 as two")
63
+ expect! result.columns == %w(one two)
64
+ expect! result == [[ 1, 2 ]]
65
+ end
66
+
67
+ def test_hstore
68
+ result = metal.ask("SELECT 'foo=>foo,bar=>NULL'::hstore")
69
+ assert_equal result, foo: "foo", bar: nil
70
+
71
+ # C = PgTypedQueries::Conversions
72
+ #
73
+ # assert_equal C::HStore.escape(a: 1), "'a=>1'::hstore"
74
+ # assert_equal C::HStore.escape(foo: "foo", bar: nil), "'foo=>foo,bar=>NULL'::hstore"
75
+ end
76
+ end
77
+
78
+ module ActiveRecord::Metal::Postgresql::Conversions::Etest
79
+ include ActiveRecord::Metal::EtestBase
80
+
81
+ def test_numeric_types_args
82
+ [ "smallint", "integer", "bigint", "decimal", "numeric", "real", "double precision" ].each do |type|
83
+ expect! metal.ask("SELECT 1::#{type} WHERE 1=$1", 1) => 1
84
+ end
85
+ end
86
+
87
+ def test_money_args
88
+ expect! metal.ask("SELECT 1234::text::money WHERE 1234::text::money=$1", 1234.0) => 1234.0
89
+ end
90
+
91
+ def test_strings_args
92
+ expect! metal.ask("SELECT 'ok' WHERE 'ok'=$1", 'ok') => 'ok'
93
+ expect! metal.ask("SELECT 'ä' WHERE 'ä'=$1", 'ä') => 'ä'
94
+ end
95
+
96
+ def test_bytea_args
97
+ expect! metal.ask("SELECT '1'::bytea WHERE '1'::bytea=$1", '1') => '1'
98
+ end
99
+
100
+ def test_dates_args
101
+ date = Date.parse("18. 1. 1999")
102
+ expect! metal.ask("SELECT '1/18/1999'::date WHERE '1/18/1999'::date=$1", date) => date
103
+
104
+ ts = Time.parse("1999-01-08 04:05:06")
105
+ expect! metal.ask("SELECT '1999-01-08 04:05:06'::timestamp WHERE '1999-01-08 04:05:06'::timestamp=$1", ts) => ts
106
+ end
107
+
108
+ def test_true_and_friends_args
109
+ expect! metal.ask("SELECT TRUE WHERE TRUE=$1", true) => true
110
+ expect! metal.ask("SELECT FALSE WHERE FALSE=$1", false) => false
111
+ end
112
+
113
+ def test_hstore_args
114
+ id = metal.ask "INSERT INTO alloys(hsh) VALUES($1) RETURNING id", foo: "foo", bar: nil
115
+ assert_equal metal.ask("SELECT hsh FROM alloys WHERE id=$1", id), bar: nil, foo: "foo"
116
+
117
+
118
+ result = metal.ask("SELECT 'foo=>foo,bar=>NULL'::hstore WHERE 'foo=>foo,bar=>NULL'::hstore=$1",
119
+ foo: "foo", bar: nil)
120
+ assert_equal result, foo: "foo", bar: nil
121
+ end
122
+ end
@@ -0,0 +1,79 @@
1
+ # coding: utf-8
2
+
3
+ module ActiveRecord::Metal::Postgresql::Etest
4
+ include ActiveRecord::Metal::EtestBase
5
+
6
+ def test_simple_query
7
+ expect! metal.ask("SELECT 1") => 1
8
+ result = metal.exec("SELECT 1 AS number")
9
+ assert_equal(result, [[1]])
10
+ assert_equal(result.types, [Numeric])
11
+ assert_equal(result.columns, ["number"])
12
+ end
13
+
14
+ def test_null_query
15
+ expect! metal.ask("SELECT 1 AS number WHERE FALSE") => nil
16
+ result = metal.exec("SELECT 1 AS number WHERE FALSE")
17
+ assert_equal(result, [])
18
+ assert_equal(result.types, [Numeric])
19
+ assert_equal(result.columns, ["number"])
20
+ end
21
+
22
+ def test_positioned_parameters
23
+ expect! metal.ask("SELECT 1 AS number WHERE 1=$1", 1) => 1
24
+ assert_equal metal.exec("SELECT 1 AS number WHERE 1=$1", 1), [[1]]
25
+ assert_equal metal.exec("SELECT 1 AS value WHERE 1=$1", 1), [[1]]
26
+ assert_equal metal.exec("SELECT 1 AS value WHERE 1=$1", "1"), [[1]]
27
+ end
28
+
29
+ def test_exceptions
30
+ assert_raise() {
31
+ metal.exec("SELECT 1 FROM unknown")
32
+ }
33
+ end
34
+
35
+ def import_performance(count)
36
+ return if ENV["ARM_ENV"] == "test" && count > 10
37
+
38
+ # -- setup records ------------------------------------------------
39
+
40
+ records = 1.upto(count).map do |rec|
41
+ {
42
+ num: rand(100000),
43
+ num2: rand(100000),
44
+ str1: "alloy_#{Digest::MD5.hexdigest(rand(100000).to_s)}"
45
+ }
46
+ end
47
+
48
+ id = 0
49
+ values = records.map { |rec|
50
+ [ (id += 1) ] + rec.values_at(:num, :num2, :str1)
51
+ }
52
+
53
+ # -- run tests ----------------------------------------------------
54
+
55
+ metal.ask "DELETE FROM alloys"
56
+
57
+ metal.import "alloys", records
58
+ metal.ask "DELETE FROM alloys"
59
+
60
+ metal.import "alloys", values
61
+ metal.ask "DELETE FROM alloys"
62
+
63
+ ActiveRecord::Metal.benchmark "Import #{count} hashes via ActiveRecord::Base" do
64
+ Alloy.transaction do
65
+ records.each do |record|
66
+ Alloy.create! record
67
+ end
68
+ end
69
+ end
70
+
71
+ metal.ask "DELETE FROM alloys"
72
+ end
73
+
74
+ def test_import_performance
75
+ import_performance(1)
76
+ import_performance(100)
77
+ import_performance(10000)
78
+ end
79
+ end
@@ -0,0 +1,141 @@
1
+ module ActiveRecord::Metal::Postgresql::Import
2
+ #
3
+ # Import a number of records into a table.
4
+ # records is either an Array of Hashes or an Array of Arrays.
5
+ # In the latter case each record *must* match the order of columns
6
+ # in the table.
7
+ def import(table_name, records, options = {})
8
+ return if records.empty?
9
+ importer = records.first.is_a?(Hash) ? :hashes : :arrays
10
+
11
+ benchmark "INSERT #{records.length} #{importer} into #{table_name}" do
12
+ expect! table_name => /^\S+$/
13
+ expect! records.first => [ nil, Hash, Array ]
14
+
15
+ case records.length
16
+ when 0 then :nop
17
+ when 1 then send("import_#{importer}", table_name, records, options)
18
+ else transaction { send("import_#{importer}", table_name, records, options) }
19
+ end
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def import_hashes(table_name, records, _)
26
+ keys = records.inject([]) do |ary, record|
27
+ ary | record.keys
28
+ end
29
+
30
+ keys.each { |key| expect! key => Symbol }
31
+ keys.each { |key| expect! key.to_s => /^\S+$/ }
32
+
33
+ values = 1.upto(keys.length).map { |idx| "$#{idx}" }
34
+
35
+ sql = "INSERT INTO #{table_name}(#{keys.join(",")}) VALUES(#{values.join(",")})"
36
+ stmt = prepare(sql)
37
+ records.each do |record|
38
+ exec_prepared stmt, *record.values_at(*keys)
39
+ end
40
+ rescue
41
+ logger.warn "#{$!.class.name}: #{$!}"
42
+ raise
43
+ ensure
44
+ unprepare(stmt)
45
+ end
46
+
47
+ def import_arrays(table_name, records, options)
48
+ columns = if options[:columns]
49
+ "(" + options[:columns].join(",") + ")"
50
+ end
51
+
52
+ values = 1.upto(records.first.length).map { |idx| "$#{idx}" }
53
+
54
+ sql = "INSERT INTO #{table_name}#{columns} VALUES(#{values.join(",")})"
55
+ stmt = prepare(sql)
56
+
57
+ records.each do |record|
58
+ exec_prepared stmt, *record
59
+ end
60
+ rescue
61
+ logger.warn "#{$!.class.name}: #{$!}"
62
+ raise
63
+ ensure
64
+ unprepare(stmt)
65
+ end
66
+ end
67
+
68
+ module ActiveRecord::Metal::Postgresql::Import::Etest
69
+ include ActiveRecord::Metal::EtestBase
70
+
71
+ # metal.ask "CREATE TABLE test(num INTEGER, num2 INTEGER, str1 VARCHAR)"
72
+
73
+ def test_import_none
74
+ metal.ask "DELETE FROM alloys"
75
+ metal.import "alloys", []
76
+ expect! metal.ask("SELECT COUNT(*) FROM alloys") => 0
77
+ end
78
+
79
+ def test_import_array
80
+ metal.ask "DELETE FROM alloys"
81
+ metal.import "alloys", [[1,2,"one"]], :columns => %w(num num2 str1)
82
+ expect! metal.ask("SELECT COUNT(*) FROM alloys") => 1
83
+ end
84
+
85
+ def test_import_array_wo_columns
86
+ metal.ask "DELETE FROM alloys"
87
+ metal.import "alloys", [[1,1,2,"one"]]
88
+ expect! metal.ask("SELECT COUNT(*) FROM alloys") => 1
89
+ end
90
+
91
+ def test_import_hashes
92
+ metal.ask "DELETE FROM alloys"
93
+ metal.import "alloys", [ num: 1, num2: 1, str1: "one" ]
94
+ expect! metal.ask("SELECT COUNT(*) FROM alloys") => 1
95
+ end
96
+ end
97
+
98
+ __END__
99
+
100
+ # This is example code to load a table via COPY FROM
101
+ def flush_load(records)
102
+ copy_data = records.map do |name, value, timestamp, payload|
103
+ escaped_payload = PgTypedQueries::Conversions::HStore.escape_without_type(payload, connection) if payload
104
+ escaped_payload ||= "NULL"
105
+
106
+ "#{name}|#{value}|#{timestamp.to_i}|#{escaped_payload}\n"
107
+ end.join
108
+
109
+ # STDERR.puts "Running COPY command with #{copy_data.bytesize} bytes for #{records.length} records"
110
+
111
+ copy_data = StringIO.new(copy_data)
112
+
113
+ connection.transaction do
114
+ conn = connection.instance_variable_get "@connection"
115
+ buf = ''
116
+ conn.exec("COPY #{table_name} FROM STDIN WITH DELIMITER '|' NULL 'NULL'")
117
+ begin
118
+ while copy_data.read(256, buf)
119
+ ### Uncomment this to test error-handling for exceptions from the reader side:
120
+ # raise Errno::ECONNRESET, "socket closed while reading"
121
+ # $stderr.puts " sending %d bytes of data..." % [ buf.length ]
122
+ # $stderr.puts "copy #{buf}"
123
+ until conn.put_copy_data( buf )
124
+ $stderr.puts " waiting for connection to be writable..."
125
+ sleep 0.1
126
+ end
127
+ end
128
+
129
+ # puts "done copying"
130
+ rescue Errno => err
131
+ errmsg = "%s while reading copy data: %s" % [ err.class.name, err.message ]
132
+ conn.put_copy_end(errmsg)
133
+ else
134
+ conn.put_copy_end
135
+ while res = conn.get_result
136
+ $stderr.puts "Result of COPY is: %s" % [ res.res_status(res.result_status) ]
137
+ $stderr.puts res.error_message()
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,129 @@
1
+ module ActiveRecord::Metal::Postgresql::Queries
2
+ class ArrayWithTypeInfo < Array
3
+ attr :columns, :types
4
+
5
+ def initialize(values, columns, types)
6
+ @columns, @types = columns, types
7
+ replace(values)
8
+ end
9
+ end
10
+
11
+ class Result < Array
12
+ attr :columns, :types
13
+
14
+ def initialize(metal, pg_result, *args)
15
+ @metal, @pg_result = metal, pg_result
16
+ @current_row = 0
17
+
18
+ setup_colums
19
+ setup_types
20
+ end
21
+
22
+ def next_row
23
+ return nil if @current_row >= @pg_result.ntuples
24
+
25
+ values = 0.upto(@pg_result.nfields-1).map do |column_number|
26
+ data = @pg_result.getvalue(@current_row, column_number)
27
+ data = @converters[column_number].call(data) if data
28
+ data
29
+ end
30
+
31
+ ArrayWithTypeInfo.new values, columns, types
32
+ ensure
33
+ @current_row += 1
34
+ end
35
+
36
+ private
37
+
38
+ def setup_colums
39
+ @columns = (0 ... @pg_result.nfields).map { |i| @pg_result.fname(i) }
40
+ end
41
+
42
+ Conversions = ActiveRecord::Metal::Postgresql::Conversions
43
+
44
+ def setup_types
45
+ metal_pg_types = @metal.send(:pg_types)
46
+
47
+ pg_types = (0 ... @pg_result.nfields).map do |i|
48
+ pg_type_id = @pg_result.ftype(i)
49
+ pg_type = metal_pg_types[pg_type_id] || raise("Unknown pg_type_id: #{pg_type_id}")
50
+ pg_type.gsub(/\d+$/, "").to_sym
51
+ end
52
+
53
+ # -- ruby types -------------------------------------------------
54
+
55
+ @types = pg_types.map do |pg_type|
56
+ Conversions.resolve_type(pg_type)
57
+ end
58
+
59
+ # -- converters -------------------------------------------------
60
+
61
+ pg_conn = @metal.send(:pg_conn)
62
+ unescape_bytea = lambda { |s| pg_conn.unescape_bytea(s) }
63
+
64
+ @converters = pg_types.map do |pg_type|
65
+ if pg_type == :_bytea
66
+ unescape_bytea
67
+ else
68
+ Conversions.method(pg_type)
69
+ end
70
+ end
71
+ end
72
+ end
73
+
74
+ def exec(sql, *args, &block)
75
+ started_at = Time.now
76
+
77
+ # prepared queries - denoted by symbols - are executed as such, and
78
+ # not cleaned up. A caller can get a prepared query by calling
79
+ # metal.prepare.
80
+ if sql.is_a?(Symbol)
81
+ pg_result = exec_prepared(sql, *args)
82
+ elsif args.empty?
83
+ pg_result = exec_(sql)
84
+ else
85
+ name = prepare(sql)
86
+ pg_result = exec_prepared(name, *args)
87
+ unprepare(sql)
88
+ end
89
+
90
+ result = Result.new(self, pg_result, *args)
91
+
92
+ rows = []
93
+ while row = result.next_row
94
+ yield row if block
95
+ rows << row
96
+ end
97
+
98
+ ArrayWithTypeInfo.new rows, result.columns, result.types
99
+ ensure
100
+ log_benchmark :debug, Time.now - started_at,
101
+ "SQL: {{runtime}} %s %s" % [ sql.is_a?(Symbol) ? "[P]" : " ", resolve_query(sql) ]
102
+ end
103
+
104
+ def ask(sql, *args)
105
+ first_row = nil
106
+
107
+ catch(:received_first_row) do
108
+ exec(sql, *args) do |row|
109
+ first_row = row
110
+ throw :received_first_row
111
+ end
112
+ end
113
+
114
+ first_row && first_row.first
115
+ end
116
+ end
117
+
118
+ module ActiveRecord::Metal::Postgresql::Queries::Etest
119
+ include ActiveRecord::Metal::EtestBase
120
+
121
+ def test_benchmark
122
+ metal.ask("SELECT 1")
123
+ metal.ask("SELECT 1 WHERE 1=$1", 1)
124
+ query = metal.prepare("SELECT 1 WHERE 1=$1")
125
+ metal.ask(query, 1)
126
+ metal.unprepare(query)
127
+ end
128
+ end
129
+
@@ -0,0 +1,42 @@
1
+ module ActiveRecord::Metal::Transaction
2
+ # We use ActiveRecord::Base's transaction, because this one supports
3
+ # nested transactions by automatically falling back to savepoints
4
+ # when needed.
5
+ def transaction(mode = :readwrite, &block)
6
+ if mode == :readonly
7
+ ro_transaction(&block)
8
+ else
9
+ connection.transaction(&block)
10
+ end
11
+ end
12
+
13
+ private
14
+
15
+ def ro_transaction(&block)
16
+ r = nil
17
+ connection.transaction do
18
+ r = yield
19
+ raise ActiveRecord::Rollback
20
+ end
21
+ r
22
+ end
23
+ end
24
+
25
+ module ActiveRecord::Metal::Transaction::Etest
26
+ def test_transaction_return_values
27
+ expect! metal.transaction { 1 } => 1
28
+ expect! metal.transaction(:readonly) { 1 } => 1
29
+ end
30
+
31
+ def test_transaction
32
+ expect! metal.ask("SELECT COUNT(*) FROM alloys") => 0
33
+ metal.transaction { metal.ask "INSERT INTO alloys(num) VALUES(1)" }
34
+ expect! metal.ask("SELECT COUNT(*) FROM alloys") => 1
35
+ end
36
+
37
+ def test_ro_transaction
38
+ expect! metal.ask("SELECT COUNT(*) FROM alloys") => 0
39
+ metal.transaction(:readonly) { metal.ask "INSERT INTO alloys(num) VALUES(1)" }
40
+ expect! metal.ask("SELECT COUNT(*) FROM alloys") => 0
41
+ end
42
+ end
@@ -0,0 +1,5 @@
1
+ module ActiveRecord; end
2
+
3
+ class ActiveRecord::Metal
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1 @@
1
+ require "active_record/metal"
data/test/pg_test.rb ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ require_relative "test_helper"
3
+
4
+ Dir.glob(File.dirname(__FILE__) + "/../lib/active_record/metal/**/etest.rb").each do |file|
5
+ load file
6
+ end
7
+
8
+ class PostgresTest < Test::Unit::TestCase
9
+ include ActiveRecord::Metal::Etest
10
+ include ActiveRecord::Metal::Transaction::Etest
11
+ include ActiveRecord::Metal::Postgresql::Etest
12
+ include ActiveRecord::Metal::Postgresql::Import::Etest
13
+ include ActiveRecord::Metal::Postgresql::Conversions::Etest
14
+ include ActiveRecord::Metal::Postgresql::Queries::Etest
15
+ end
@@ -0,0 +1,37 @@
1
+ $:.unshift File.expand_path("../../lib", __FILE__)
2
+
3
+ require "bundler"
4
+ Bundler.setup(:test)
5
+ require "simplecov"
6
+
7
+ require "etest-unit"
8
+ require "active_record/metal"
9
+
10
+ ActiveRecord::Base.establish_connection(
11
+ :adapter => "postgresql",
12
+ :database => "activerecord_metal"
13
+ )
14
+
15
+ metal = ActiveRecord::Metal.new
16
+ metal.ask "DROP TABLE IF EXISTS alloys"
17
+ metal.ask <<-SQL
18
+ CREATE TABLE alloys(
19
+ id SERIAL PRIMARY KEY,
20
+ num INTEGER,
21
+ num2 INTEGER,
22
+ str1 VARCHAR,
23
+ hsh hstore
24
+ )
25
+ SQL
26
+
27
+ class Alloy < ActiveRecord::Base
28
+ end
29
+
30
+ require "logger"
31
+
32
+ ENV["ARM_ENV"] = "test"
33
+
34
+ # ActiveRecord::Base.logger = Logger.new File.open(File.expand_path("../../log/test.log", __FILE__), "w")
35
+ ActiveRecord::Base.logger = Logger.new STDERR
36
+ ActiveRecord::Base.logger.level = Logger::INFO
37
+ # ActiveRecord::Base.auto_explain_threshold_in_seconds = 0.010
metadata ADDED
@@ -0,0 +1,77 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: activerecord-metal
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - radiospiel
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2013-07-18 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: activerecord
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ! '>='
28
+ - !ruby/object:Gem::Version
29
+ version: '0'
30
+ description: Build your tests alongside your code.
31
+ email: eno@radiospiel.org
32
+ executables: []
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - lib/active_record/metal/logging.rb
37
+ - lib/active_record/metal/postgresql/conversions/etest.rb
38
+ - lib/active_record/metal/postgresql/conversions.rb
39
+ - lib/active_record/metal/postgresql/etest.rb
40
+ - lib/active_record/metal/postgresql/import.rb
41
+ - lib/active_record/metal/postgresql/queries.rb
42
+ - lib/active_record/metal/postgresql.rb
43
+ - lib/active_record/metal/transaction.rb
44
+ - lib/active_record/metal/version.rb
45
+ - lib/active_record/metal.rb
46
+ - lib/activerecord-metal.rb
47
+ - README.md
48
+ - test/pg_test.rb
49
+ - test/test_helper.rb
50
+ homepage: http://github.com/radiospiel/etest
51
+ licenses: []
52
+ post_install_message:
53
+ rdoc_options: []
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ! '>='
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ segments:
63
+ - 0
64
+ hash: -1973847718838922274
65
+ required_rubygems_version: !ruby/object:Gem::Requirement
66
+ none: false
67
+ requirements:
68
+ - - ! '>='
69
+ - !ruby/object:Gem::Version
70
+ version: '0'
71
+ requirements: []
72
+ rubyforge_project:
73
+ rubygems_version: 1.8.25
74
+ signing_key:
75
+ specification_version: 3
76
+ summary: Build your tests alongside your code.
77
+ test_files: []