pod4 0.7.2 → 0.8.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.hgignore +1 -0
- data/.hgtags +1 -0
- data/Gemfile +3 -1
- data/Rakefile +17 -5
- data/lib/pod4/pg_interface.rb +81 -57
- data/lib/pod4/sequel_interface.rb +61 -6
- data/lib/pod4/sql_helper.rb +229 -0
- data/lib/pod4/tds_interface.rb +65 -50
- data/lib/pod4/version.rb +1 -1
- data/md/roadmap.md +81 -25
- data/spec/common/basic_model_spec.rb +5 -0
- data/spec/common/model_spec.rb +49 -7
- data/spec/common/nebulous_interface_spec.rb +2 -0
- data/spec/common/sequel_interface_pg_spec.rb +511 -0
- data/spec/common/sql_helper_spec.rb +299 -0
- data/spec/jruby/sequel_interface_jdbc_ms_spec.rb +525 -0
- data/spec/jruby/sequel_interface_jdbc_pg_spec.rb +520 -0
- data/spec/mri/pg_interface_spec.rb +140 -16
- data/spec/mri/sequel_interface_spec.rb +146 -13
- data/spec/mri/tds_interface_spec.rb +82 -12
- metadata +11 -4
- data/spec/jruby/pg_interface_spec.rb +0 -469
data/lib/pod4/tds_interface.rb
CHANGED
@@ -5,6 +5,7 @@ require 'bigdecimal'
|
|
5
5
|
|
6
6
|
require_relative 'interface'
|
7
7
|
require_relative 'errors'
|
8
|
+
require_relative 'sql_helper'
|
8
9
|
|
9
10
|
|
10
11
|
module Pod4
|
@@ -22,7 +23,10 @@ module Pod4
|
|
22
23
|
# set_id_fld :id
|
23
24
|
# end
|
24
25
|
#
|
26
|
+
# Note: TinyTDS does not appear to support parameterised queries!
|
27
|
+
#
|
25
28
|
class TdsInterface < Interface
|
29
|
+
include SQLHelper
|
26
30
|
|
27
31
|
attr_reader :id_fld
|
28
32
|
|
@@ -89,9 +93,10 @@ module Pod4
|
|
89
93
|
# much ignored.
|
90
94
|
#
|
91
95
|
def initialize(connectHash, testClient=nil)
|
92
|
-
|
93
|
-
raise(Pod4Error, 'no call to
|
94
|
-
raise(Pod4Error, 'no call to
|
96
|
+
sc = self.class
|
97
|
+
raise(Pod4Error, 'no call to set_db in the interface definition') if sc.db.nil?
|
98
|
+
raise(Pod4Error, 'no call to set_table in the interface definition') if sc.table.nil?
|
99
|
+
raise(Pod4Error, 'no call to set_id_fld in the interface definition') if sc.id_fld.nil?
|
95
100
|
raise(ArgumentError, 'invalid connection hash') unless connectHash.kind_of?(Hash)
|
96
101
|
|
97
102
|
@connect_hash = connectHash.dup
|
@@ -115,6 +120,10 @@ module Pod4
|
|
115
120
|
schema ? %Q|[#{schema}].[#{table}]| : %Q|[#{table}]|
|
116
121
|
end
|
117
122
|
|
123
|
+
def quote_field(fld)
|
124
|
+
"[#{super(fld, nil)}]"
|
125
|
+
end
|
126
|
+
|
118
127
|
|
119
128
|
##
|
120
129
|
# Selection is a hash or something like it: keys should be field names. We return any records
|
@@ -125,17 +134,8 @@ module Pod4
|
|
125
134
|
raise(Pod4::DatabaseError, 'selection parameter is not a hash') \
|
126
135
|
unless selection.nil? || selection.respond_to?(:keys)
|
127
136
|
|
128
|
-
|
129
|
-
|
130
|
-
sql = %Q|select *
|
131
|
-
from #{quoted_table}
|
132
|
-
where #{sel};|
|
133
|
-
|
134
|
-
else
|
135
|
-
sql = %Q|select * from #{quoted_table};|
|
136
|
-
end
|
137
|
-
|
138
|
-
select(sql) {|r| Octothorpe.new(r) }
|
137
|
+
sql, vals = sql_select(nil, selection)
|
138
|
+
select( sql_subst(sql, *vals.map{|v| quote v}) ) {|r| Octothorpe.new(r) }
|
139
139
|
|
140
140
|
rescue => e
|
141
141
|
handle_error(e)
|
@@ -145,22 +145,13 @@ module Pod4
|
|
145
145
|
##
|
146
146
|
# Record is a hash of field: value
|
147
147
|
#
|
148
|
-
# By a happy coincidence, insert returns the unique ID for the record, which is just what we
|
149
|
-
# want to do, too.
|
150
|
-
#
|
151
148
|
def create(record)
|
152
149
|
raise(ArgumentError, "Bad type for record parameter") \
|
153
150
|
unless record.kind_of?(Hash) || record.kind_of?(Octothorpe)
|
154
151
|
|
155
|
-
|
156
|
-
vs = record.values.map {|v| quote v }
|
157
|
-
|
158
|
-
sql = "insert into #{quoted_table}\n"
|
159
|
-
sql << " ( " << ks.join(",") << ")\n"
|
160
|
-
sql << " output inserted.[#{id_fld}]\n"
|
161
|
-
sql << " values( " << vs.join(",") << ");"
|
152
|
+
sql, vals = sql_insert(record)
|
162
153
|
|
163
|
-
x = select(sql)
|
154
|
+
x = select sql_subst(sql, *vals.map{|v| quote v})
|
164
155
|
x.first[id_fld]
|
165
156
|
|
166
157
|
rescue => e
|
@@ -174,18 +165,17 @@ module Pod4
|
|
174
165
|
def read(id)
|
175
166
|
raise(ArgumentError, "ID parameter is nil") if id.nil?
|
176
167
|
|
177
|
-
sql =
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
Octothorpe.new( select(sql).first )
|
168
|
+
sql, vals = sql_select(nil, id_fld => id)
|
169
|
+
rows = select sql_subst(sql, *vals.map{|v| quote v})
|
170
|
+
Octothorpe.new(rows.first)
|
182
171
|
|
183
172
|
rescue => e
|
184
173
|
# select already wrapped any error in a Pod4::DatabaseError, but in this case we want to try
|
185
174
|
# to catch something. (Side note: TinyTds' error class structure is a bit poor...)
|
186
175
|
raise CantContinue, "Problem reading record. Is '#{id}' really an ID?" \
|
187
176
|
if e.cause.class == TinyTds::Error \
|
188
|
-
&& e.cause.message =~ /
|
177
|
+
&& e.cause.message =~ /conversion failed/i
|
178
|
+
|
189
179
|
|
190
180
|
handle_error(e)
|
191
181
|
end
|
@@ -193,7 +183,7 @@ module Pod4
|
|
193
183
|
|
194
184
|
##
|
195
185
|
# ID is whatever you set in the interface using set_id_fld record should be a Hash or
|
196
|
-
|
186
|
+
# Octothorpe.
|
197
187
|
#
|
198
188
|
def update(id, record)
|
199
189
|
raise(ArgumentError, "Bad type for record parameter") \
|
@@ -201,12 +191,8 @@ module Pod4
|
|
201
191
|
|
202
192
|
read_or_die(id)
|
203
193
|
|
204
|
-
|
205
|
-
|
206
|
-
sql = "update #{quoted_table} set\n"
|
207
|
-
sql << sets.join(",") << "\n"
|
208
|
-
sql << "where [#{id_fld}] = #{quote id};"
|
209
|
-
execute(sql)
|
194
|
+
sql, vals = sql_update(record, id_fld => id)
|
195
|
+
execute sql_subst(sql, *vals.map{|v| quote v})
|
210
196
|
|
211
197
|
self
|
212
198
|
|
@@ -220,7 +206,9 @@ module Pod4
|
|
220
206
|
#
|
221
207
|
def delete(id)
|
222
208
|
read_or_die(id)
|
223
|
-
|
209
|
+
|
210
|
+
sql, vals = sql_delete(id_fld => id)
|
211
|
+
execute sql_subst(sql, *vals.map{|v| quote v})
|
224
212
|
|
225
213
|
self
|
226
214
|
|
@@ -229,6 +217,22 @@ module Pod4
|
|
229
217
|
end
|
230
218
|
|
231
219
|
|
220
|
+
##
|
221
|
+
# Override the sql_insert method in sql_helper since our SQL is rather different
|
222
|
+
#
|
223
|
+
def sql_insert(record)
|
224
|
+
flds, vals = parse_fldsvalues(record)
|
225
|
+
ph = vals.map{|x| placeholder }
|
226
|
+
|
227
|
+
sql = %Q|insert into #{quoted_table}
|
228
|
+
( #{flds.join ','} )
|
229
|
+
output inserted.#{quote_field id_fld}
|
230
|
+
values( #{ph.join ','} );|
|
231
|
+
|
232
|
+
[sql, vals]
|
233
|
+
end
|
234
|
+
|
235
|
+
|
232
236
|
##
|
233
237
|
# Run SQL code on the server. Return the results.
|
234
238
|
#
|
@@ -287,6 +291,16 @@ module Pod4
|
|
287
291
|
end
|
288
292
|
|
289
293
|
|
294
|
+
##
|
295
|
+
# Wrapper for the data source library escape routine, which is all we can offer in terms of SQL
|
296
|
+
# injection protection. (Its not much.)
|
297
|
+
#
|
298
|
+
def escape(thing)
|
299
|
+
open unless connected?
|
300
|
+
thing.kind_of?(String) ? @client.escape(thing) : thing
|
301
|
+
end
|
302
|
+
|
303
|
+
|
290
304
|
protected
|
291
305
|
|
292
306
|
|
@@ -354,21 +368,22 @@ module Pod4
|
|
354
368
|
end
|
355
369
|
|
356
370
|
|
371
|
+
##
|
372
|
+
# Overrride the quote routine in sql_helper.
|
373
|
+
#
|
374
|
+
# * TinyTDS doesn't cope with datetime
|
375
|
+
#
|
376
|
+
# * We might as well use it to escape strings, since that's the best we can do -- although I
|
377
|
+
# suspect that it's just turning ' into '' and nothing else...
|
378
|
+
#
|
357
379
|
def quote(fld)
|
358
|
-
|
359
380
|
case fld
|
360
381
|
when DateTime, Time
|
361
382
|
%Q|'#{fld.to_s[0..-7]}'|
|
362
|
-
when
|
363
|
-
%Q|'#{fld}'|
|
364
|
-
|
365
|
-
|
366
|
-
when BigDecimal
|
367
|
-
fld.to_f
|
368
|
-
when nil
|
369
|
-
'NULL'
|
370
|
-
else
|
371
|
-
fld
|
383
|
+
when String, Symbol
|
384
|
+
%Q|'#{escape fld.to_s}'|
|
385
|
+
else
|
386
|
+
super
|
372
387
|
end
|
373
388
|
|
374
389
|
end
|
data/lib/pod4/version.rb
CHANGED
data/md/roadmap.md
CHANGED
@@ -1,41 +1,95 @@
|
|
1
|
-
|
2
|
-
|
1
|
+
Transactions
|
2
|
+
============
|
3
3
|
|
4
|
-
|
5
|
-
|
6
|
-
Presumably this is a bad idea. :-)
|
4
|
+
I'd like to support basic transactions because without it we can't really claim to do optimistic
|
5
|
+
locking properly. And it would be nice to claim that.
|
7
6
|
|
8
|
-
|
9
|
-
|
10
|
-
of models, do some work, and then stop entirely -- but it _is_ rather silly.
|
7
|
+
Thought I had a solid idea of how to do it without any faffing around, but, it had some holes. Will
|
8
|
+
have to think again.
|
11
9
|
|
12
|
-
|
13
|
-
thinking in terms of a memoising object that stores the connection hash and
|
14
|
-
then gets passed to the interface. When the interface wants a connection, then
|
15
|
-
it asks the connection object. If the connection object doesn't have one, then
|
16
|
-
the interface connects, and gives the connection to the connection object.
|
10
|
+
But, this is top of my wish list.
|
17
11
|
|
12
|
+
For the record, my current thinking:
|
18
13
|
|
19
|
-
|
20
|
-
|
14
|
+
customer.new(4).read.or_die
|
15
|
+
customer.transaction do |c|
|
16
|
+
c.update(foo: 'bar')
|
17
|
+
c.orders.update(foo: 'bar')
|
18
|
+
end.or_die
|
19
|
+
|
20
|
+
* a method supports_transactions() will control whether the interface does that. sql_helper will
|
21
|
+
define it to return true.
|
22
|
+
|
23
|
+
* sql_helper will define a sql_transaction method which wraps SQL as a transaction.
|
21
24
|
|
22
|
-
|
23
|
-
|
25
|
+
* interface methods create() delete() and update now accept an extra parameter, a boolean; if true,
|
26
|
+
they will return sql (and values), rather than doing anything. This is defined in Pod4::Interface.
|
24
27
|
|
25
|
-
|
26
|
-
|
28
|
+
* BasicModel defines @in_transaction = false; @tx_sql = ""; @tx_vals = [].
|
29
|
+
|
30
|
+
* When a Model is @in_transaction, the create, delete and update methods pass the extra parameter
|
31
|
+
to the corresponding interface methods. The results are accumulated in @tx_sql and @tx_vals.
|
32
|
+
|
33
|
+
* the method BasicModel.transaction will:
|
34
|
+
|
35
|
+
* set interface.in_transaction = true, or raise an error if the interface doesn't support them
|
36
|
+
* yield a block passing the model instance so that the caller can run methods inside it
|
37
|
+
* set in_transaction back to false.
|
38
|
+
* call interface.executep( interface._sql_transaction( @tx_sql ), @tx_vals )
|
39
|
+
|
40
|
+
Notes:
|
41
|
+
|
42
|
+
* We will either have to standardize the execute method or check for it each time?
|
43
|
+
* Ditto with executep. Ditto with whether an interface supports parameterisation.
|
44
|
+
* trying to do a transaction across databases will fall over, but, really, no expectation there.
|
45
|
+
* You can't have a transaction that uses the result of the first half to do the last half.
|
46
|
+
* You can no longer call select() in a create, as we currently do for some interfaces? This is the
|
47
|
+
real problem I am wrestling with -- how to allow a transaction that returns a value from create().
|
27
48
|
|
28
49
|
|
29
50
|
Migrations
|
30
51
|
==========
|
31
52
|
|
32
|
-
This will almost certainly be something crude -- since we don't really control
|
33
|
-
|
34
|
-
|
35
|
-
|
53
|
+
This will almost certainly be something crude -- since we don't really control the database
|
54
|
+
connection in the same way as, say, ActiveRecord -- but I honestly think it's a worthwhile feature.
|
55
|
+
Just having something that you can version control and run to update a data model is enough,
|
56
|
+
really.
|
57
|
+
|
58
|
+
I'm not yet sure of the least useless way to implement it. Again, I favour SQL as the DSL.
|
59
|
+
|
60
|
+
We will clearly need transactions first, though.
|
36
61
|
|
37
|
-
|
38
|
-
|
62
|
+
My Current thoughts:
|
63
|
+
|
64
|
+
* a migration against a database is on a par with a model but very different. You subclass
|
65
|
+
migration and give it an interface, pointing to the table that stores the current migration
|
66
|
+
state.
|
67
|
+
|
68
|
+
* The methods in a module are exectute() up() and down() -- the last two call the first one.
|
69
|
+
|
70
|
+
* Each instance of a model is stored in a file and contains up and down SQL somehow. Each instance
|
71
|
+
has a version number.
|
72
|
+
|
73
|
+
* You run a migration by running up or down on your migration class, passing a version?
|
74
|
+
|
75
|
+
|
76
|
+
Connection Object
|
77
|
+
=================
|
78
|
+
|
79
|
+
PgInterface and TdsInterface both take a connection Hash, which is all very well, but it means that
|
80
|
+
we are running one database connection per model. Presumably this is a bad idea. :-)
|
81
|
+
|
82
|
+
This actually hasn't come up in my own use of Pod4 -- for complex reasons I'm either using
|
83
|
+
SequelInterface or running transient jobs which start up a couple of models, do some work, and then
|
84
|
+
stop entirely.
|
85
|
+
|
86
|
+
Connection is baked into those interfaces, and interface dependant. So I'm thinking in terms of a
|
87
|
+
memoising object that stores the connection hash and then gets passed to the interface. When the
|
88
|
+
interface wants a connection, then it asks the connection object. If the connection object doesn't
|
89
|
+
have one, then the interface connects, and gives the connection to the connection object.
|
90
|
+
|
91
|
+
It's looking as if we don't need this right now. One connection per model might not be as daft as
|
92
|
+
it seems.
|
39
93
|
|
40
94
|
|
41
95
|
JDBC-SQL interface
|
@@ -67,3 +121,5 @@ For the jdbc-msssqlserver gem. Doable ... I *think*.
|
|
67
121
|
conn.close
|
68
122
|
|
69
123
|
see https://github.com/jruby/jruby/wiki/JDBC
|
124
|
+
|
125
|
+
|
@@ -78,6 +78,11 @@ describe 'WeirdModel' do
|
|
78
78
|
expect( WeirdModel.new.alerts ).to eq([])
|
79
79
|
end
|
80
80
|
|
81
|
+
it 'doesn''t freak out if the ID is not an integer' do
|
82
|
+
expect{ CustomerModel.new("france") }.not_to raise_exception
|
83
|
+
expect( CustomerModel.new("france").model_id ).to eq "france"
|
84
|
+
end
|
85
|
+
|
81
86
|
end
|
82
87
|
##
|
83
88
|
|
data/spec/common/model_spec.rb
CHANGED
@@ -61,6 +61,7 @@ describe 'CustomerModel' do
|
|
61
61
|
{id: 20, name: 'Morticia', price: 2.34, groups: 'spanish' },
|
62
62
|
{id: 30, name: 'Wednesday', price: 3.45, groups: 'school' },
|
63
63
|
{id: 40, name: 'Pugsley', price: 4.56, groups: 'trains,school'} ]
|
64
|
+
|
64
65
|
end
|
65
66
|
|
66
67
|
let(:recordsx) do
|
@@ -83,21 +84,26 @@ describe 'CustomerModel' do
|
|
83
84
|
let(:model2) do
|
84
85
|
m = CustomerModel.new(30)
|
85
86
|
|
86
|
-
allow( m.interface ).to receive(:read).
|
87
|
-
and_return( Octothorpe.new(records[2]) )
|
88
|
-
|
87
|
+
allow( m.interface ).to receive(:read).and_return( Octothorpe.new(records[2]) )
|
89
88
|
m.read.or_die
|
90
89
|
end
|
91
90
|
|
92
91
|
let(:model3) do
|
93
92
|
m = CustomerModel.new(40)
|
94
93
|
|
95
|
-
allow( m.interface ).to receive(:read).
|
96
|
-
and_return( Octothorpe.new(records[3]) )
|
97
|
-
|
94
|
+
allow( m.interface ).to receive(:read).and_return( Octothorpe.new(records[3]) )
|
98
95
|
m.read.or_die
|
99
96
|
end
|
100
97
|
|
98
|
+
# Model4 is for a non-integer id
|
99
|
+
let(:thing) { Octothorpe.new(id: 'eek', name: 'thing', price: 9.99, groups: 'scuttering') }
|
100
|
+
|
101
|
+
let(:model4) do
|
102
|
+
m = CustomerModel.new('eek')
|
103
|
+
|
104
|
+
allow( m.interface ).to receive(:read).and_return(thing)
|
105
|
+
m.read.or_die
|
106
|
+
end
|
101
107
|
|
102
108
|
##
|
103
109
|
|
@@ -242,6 +248,11 @@ describe 'CustomerModel' do
|
|
242
248
|
expect( CustomerModel.new.alerts ).to eq([])
|
243
249
|
end
|
244
250
|
|
251
|
+
it 'doesn''t freak out if the ID is not an integer' do
|
252
|
+
expect{ CustomerModel.new("france") }.not_to raise_exception
|
253
|
+
expect( CustomerModel.new("france").model_id ).to eq "france"
|
254
|
+
end
|
255
|
+
|
245
256
|
end
|
246
257
|
##
|
247
258
|
|
@@ -532,6 +543,14 @@ describe 'CustomerModel' do
|
|
532
543
|
new_model.create
|
533
544
|
end
|
534
545
|
|
546
|
+
it 'doesn\'t freak out if the model is not an integer' do
|
547
|
+
expect( new_model.interface ).to receive(:create)
|
548
|
+
new_model.id = "handy"
|
549
|
+
new_model.name = "Thing"
|
550
|
+
|
551
|
+
expect{ new_model.create }.not_to raise_error
|
552
|
+
end
|
553
|
+
|
535
554
|
end
|
536
555
|
##
|
537
556
|
|
@@ -597,6 +616,12 @@ describe 'CustomerModel' do
|
|
597
616
|
expect( model.model_status ).to eq :warning
|
598
617
|
end
|
599
618
|
|
619
|
+
it 'doesn\'t freak out if the model is non-integer' do
|
620
|
+
allow( model.interface ).to receive(:read).and_return( thing )
|
621
|
+
|
622
|
+
expect{ CustomerModel.new('eek').read }.not_to raise_error
|
623
|
+
end
|
624
|
+
|
600
625
|
context 'if the interface.read returns an empty Octothorpe' do
|
601
626
|
let(:missing) { CustomerModel.new(99) }
|
602
627
|
|
@@ -658,7 +683,7 @@ describe 'CustomerModel' do
|
|
658
683
|
model3.update
|
659
684
|
end
|
660
685
|
|
661
|
-
it '
|
686
|
+
it 'doesn\'t call update on the interface if the validation fails' do
|
662
687
|
expect( model3.interface ).not_to receive(:update)
|
663
688
|
|
664
689
|
model3.name = "fall over" # triggers validation
|
@@ -670,6 +695,14 @@ describe 'CustomerModel' do
|
|
670
695
|
model3.update
|
671
696
|
end
|
672
697
|
|
698
|
+
it 'doesn\'t freak out if the model is non-integer' do
|
699
|
+
expect( model4.interface ).
|
700
|
+
to receive(:update).
|
701
|
+
and_return( model4.interface )
|
702
|
+
|
703
|
+
model4.update
|
704
|
+
end
|
705
|
+
|
673
706
|
context 'when the record already has error alerts' do
|
674
707
|
|
675
708
|
it 'passes if there is no longer anything wrong' do
|
@@ -753,6 +786,15 @@ describe 'CustomerModel' do
|
|
753
786
|
expect( model2.model_status ).to eq :deleted
|
754
787
|
end
|
755
788
|
|
789
|
+
it 'doesn\'t freak out if the model is non-integer' do
|
790
|
+
expect( model4.interface ).
|
791
|
+
to receive(:delete).
|
792
|
+
and_return( model4.interface )
|
793
|
+
|
794
|
+
model4.delete
|
795
|
+
end
|
796
|
+
|
797
|
+
|
756
798
|
end
|
757
799
|
##
|
758
800
|
|