pod4 0.7.2 → 0.8.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.
- 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
|
|