activerecord-metal 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []