pod4 0.6.2

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.
@@ -0,0 +1,460 @@
1
+ require 'pg'
2
+ require 'octothorpe'
3
+ require 'date'
4
+ require 'time'
5
+ require 'bigdecimal'
6
+
7
+ require_relative 'interface'
8
+ require_relative 'errors'
9
+
10
+
11
+ module Pod4
12
+
13
+
14
+ ##
15
+ # Pod4 Interface for requests on a SQL table via pg, the PostgresQL adapter.
16
+ #
17
+ # If your DB table is one-one with your model, you shouldn't need to override
18
+ # anything.
19
+ #
20
+ # Example:
21
+ # class CustomerInterface < SwingShift::PgInterface
22
+ # set_schema :public # optional
23
+ # set_table :customer
24
+ # set_id_fld :id
25
+ # end
26
+ #
27
+ class PgInterface < Interface
28
+
29
+ attr_reader :id_fld
30
+
31
+
32
+ class << self
33
+ #--
34
+ # These are set in the class because it keeps the model code cleaner: the
35
+ # definition of the interface stays in the interface, and doesn't leak
36
+ # out into the model.
37
+ #++
38
+
39
+ ##
40
+ # Set the name of the schema. This is optional.
41
+ #
42
+ def set_schema(schema)
43
+ define_class_method(:schema) {schema.to_s.to_sym}
44
+ end
45
+
46
+ def schema; nil; end
47
+
48
+
49
+ ##
50
+ # Set the name of the database table
51
+ #
52
+ def set_table(table)
53
+ define_class_method(:table) {table.to_s.to_sym}
54
+ end
55
+
56
+ def table
57
+ raise Pod4Error, "You need to use set_table to set the table name"
58
+ end
59
+
60
+
61
+ ##
62
+ # Set the name of the column that holds the unique id for the table.
63
+ #
64
+ def set_id_fld(idFld)
65
+ define_class_method(:id_fld) {idFld.to_s.to_sym}
66
+ end
67
+
68
+ def id_fld
69
+ raise Pod4Error, "You need to use set_id_fld to set the ID column"
70
+ end
71
+
72
+ end
73
+ ##
74
+
75
+
76
+ ##
77
+ # Initialise the interface by passing it a Pg connection hash.
78
+ # For testing ONLY you can also pass an object which pretends to be a
79
+ # Pg client, in which case the hash is pretty much ignored.
80
+ #
81
+ def initialize(connectHash, testClient=nil)
82
+ raise(ArgumentError, 'invalid connection hash') \
83
+ unless connectHash.kind_of?(Hash)
84
+
85
+ @connect_hash = connectHash.dup
86
+ @test_client = testClient
87
+ @client = nil
88
+
89
+ rescue => e
90
+ handle_error(e)
91
+ end
92
+
93
+
94
+ def schema; self.class.schema; end
95
+ def table; self.class.table; end
96
+ def id_fld; self.class.id_fld; end
97
+
98
+ def quoted_table
99
+ schema ? %Q|"#{schema}"."#{table}"| : %Q|"#{table}"|
100
+ end
101
+
102
+
103
+ ##
104
+ # Selection is whatever Sequel's `where` supports.
105
+ #
106
+ def list(selection=nil)
107
+ raise(ArgumentError, 'selection parameter is not a hash') \
108
+ unless selection.nil? || selection.respond_to?(:keys)
109
+
110
+ if selection
111
+ sel = selection.map {|k,v| %Q|"#{k}" = #{quote v}| }.join(" and ")
112
+ sql = %Q|select *
113
+ from #{quoted_table}
114
+ where #{sel};|
115
+
116
+ else
117
+ sql = %Q|select * from #{quoted_table};|
118
+ end
119
+
120
+ select(sql) {|r| Octothorpe.new(r) }
121
+
122
+ rescue => e
123
+ handle_error(e)
124
+ end
125
+
126
+
127
+ ##
128
+ # Record is a hash of field: value
129
+ # By a happy coincidence, insert returns the unique ID for the record,
130
+ # which is just what we want to do, too.
131
+ #
132
+ def create(record)
133
+ raise(ArgumentError, "Bad type for record parameter") \
134
+ unless record.kind_of?(Hash) || record.kind_of?(Octothorpe)
135
+
136
+ ks = record.keys.map {|k| %Q|"#{k}"| }.join(',')
137
+ vs = record.values.map {|v| quote v }.join(',')
138
+
139
+ sql = %Q|insert into #{quoted_table}
140
+ ( #{ks} )
141
+ values( #{vs} )
142
+ returning "#{id_fld}";|
143
+
144
+ x = select(sql)
145
+ x.first[id_fld]
146
+
147
+ rescue => e
148
+ handle_error(e)
149
+ end
150
+
151
+
152
+ ##
153
+ # ID corresponds to whatever you set in set_id_fld
154
+ #
155
+ def read(id)
156
+ raise(ArgumentError, "ID parameter is nil") if id.nil?
157
+
158
+ sql = %Q|select *
159
+ from #{quoted_table}
160
+ where "#{id_fld}" = #{quote id};|
161
+
162
+ Octothorpe.new( select(sql).first )
163
+
164
+ rescue => e
165
+ # Select has already wrapped the error in a Pod4Error, but in this case
166
+ # we want to catch something
167
+ raise CantContinue, "That doesn't look like an ID" \
168
+ if e.cause.class == PG::InvalidTextRepresentation
169
+
170
+ handle_error(e)
171
+ end
172
+
173
+
174
+ ##
175
+ # ID is whatever you set in the interface using set_id_fld
176
+ # record should be a Hash or Octothorpe.
177
+ #
178
+ def update(id, record)
179
+ raise(ArgumentError, "Bad type for record parameter") \
180
+ unless record.kind_of?(Hash) || record.kind_of?(Octothorpe)
181
+
182
+ read_or_die(id)
183
+ sets = record.map {|k,v| %Q| "#{k}" = #{quote v}| }.join(',')
184
+
185
+ sql = %Q|update #{quoted_table} set
186
+ #{sets}
187
+ where "#{id_fld}" = #{quote id};|
188
+
189
+ execute(sql)
190
+
191
+ self
192
+
193
+ rescue => e
194
+ handle_error(e)
195
+ end
196
+
197
+
198
+ ##
199
+ # ID is whatever you set in the interface using set_id_fld
200
+ #
201
+ def delete(id)
202
+ read_or_die(id)
203
+ execute( %Q|delete from #{quoted_table} where "#{id_fld}" = #{quote id};| )
204
+
205
+ self
206
+
207
+ rescue => e
208
+ handle_error(e)
209
+ end
210
+
211
+
212
+ ##
213
+ # Run SQL code on the server. Return the results.
214
+ #
215
+ # Will return an array of records, or you can use it in block mode, like
216
+ # this:
217
+ #
218
+ # select("select * from customer") do |r|
219
+ # # r is a single record
220
+ # end
221
+ #
222
+ # The returned results will be an array of hashes (or if you passed a
223
+ # block, of whatever you returned from the block).
224
+ #
225
+ def select(sql)
226
+ raise(ArgumentError, "Bad SQL parameter") unless sql.kind_of?(String)
227
+
228
+ ensure_connection
229
+
230
+ Pod4.logger.debug(__FILE__){ "select: #{sql}" }
231
+
232
+ rows = []
233
+ @client.exec(sql) do |query|
234
+ oids = make_oid_hash(query)
235
+
236
+ query.each do |r|
237
+ row = cast_row_fudge(r, oids)
238
+
239
+ if block_given?
240
+ rows << yield(row)
241
+ else
242
+ rows << row
243
+ end
244
+
245
+ end
246
+ end
247
+
248
+ @client.cancel
249
+
250
+ rows
251
+
252
+ rescue => e
253
+ handle_error(e)
254
+ end
255
+
256
+
257
+ ##
258
+ # Run SQL code on the server; return true or false for success or failure
259
+ #
260
+ def execute(sql)
261
+ raise(ArgumentError, "Bad SQL parameter") unless sql.kind_of?(String)
262
+
263
+ ensure_connection
264
+
265
+ Pod4.logger.debug(__FILE__){ "execute: #{sql}" }
266
+ @client.exec(sql)
267
+
268
+ rescue => e
269
+ handle_error(e)
270
+ end
271
+
272
+
273
+ protected
274
+
275
+
276
+ ##
277
+ # Open the connection to the database.
278
+ #
279
+ # No parameters are needed: the option hash has everything we need.
280
+ #
281
+ def open
282
+ Pod4.logger.info(__FILE__){ "Connecting to DB" }
283
+
284
+ client = @test_Client || PG.connect(@connect_hash)
285
+ raise DataBaseError, "Bad Connection" \
286
+ unless client.status == PG::CONNECTION_OK
287
+
288
+ # This gives us type mapping for integers, floats, booleans, and dates
289
+ # -- but annoyingly the PostgreSQL types 'numeric' and 'money' remain as
290
+ # strings... we fudge that elsewhere.
291
+ #
292
+ # NOTE we now deal with ALL mapping elsewhere, since pg_jruby does
293
+ # not support type mapping. Also: no annoying error messages, and it
294
+ # seems to be a hell of a lot faster now...
295
+ #
296
+ # if defined?(PG::BasicTypeMapForQueries)
297
+ # client.type_map_for_queries = PG::BasicTypeMapForQueries.new(client)
298
+ # end
299
+ #
300
+ # if defined?(PG::BasicTypeMapForResults)
301
+ # client.type_map_for_results = PG::BasicTypeMapForResults.new(client)
302
+ # end
303
+
304
+ @client = client
305
+ self
306
+
307
+ rescue => e
308
+ handle_error(e)
309
+ end
310
+
311
+
312
+ ##
313
+ # Close the connection to the database.
314
+ # We don't actually use this, but it's here for completeness. Maybe a
315
+ # caller will find it useful.
316
+ #
317
+ def close
318
+ Pod4.logger.info(__FILE__){ "Closing connection to DB" }
319
+ @client.finish unless @client.nil?
320
+
321
+ rescue => e
322
+ handle_error(e)
323
+ end
324
+
325
+
326
+ ##
327
+ # True if we are connected to a database
328
+ #
329
+ def connected?
330
+ return false if @client.nil?
331
+ return false if @client.status != PG::CONNECTION_OK
332
+
333
+ # pg's own examples suggest we poke the database rather than trust
334
+ # @client.status, so...
335
+ @client.exec('select 1;')
336
+ true
337
+ rescue PG::Error
338
+ return false
339
+ end
340
+
341
+
342
+ ##
343
+ # Since pg gives us @client.reset to reconnect, we should use it rather
344
+ # than just call open
345
+ #
346
+ def ensure_connection
347
+
348
+ if @client.nil?
349
+ open
350
+ elsif ! connected?
351
+ @client.reset
352
+ end
353
+
354
+ end
355
+
356
+
357
+ def handle_error(err, kaller=nil)
358
+ kaller ||= caller[1..-1]
359
+
360
+ Pod4.logger.error(__FILE__){ err.message }
361
+
362
+ case err
363
+
364
+ when ArgumentError, Pod4::Pod4Error, Pod4::CantContinue
365
+ raise err.class, err.message, kaller
366
+
367
+ when PG::Error
368
+ raise Pod4::DatabaseError, err.message, kaller
369
+
370
+ else
371
+ raise Pod4::Pod4Error, err.message, kaller
372
+
373
+ end
374
+
375
+ end
376
+
377
+
378
+ def quote(fld)
379
+
380
+ case fld
381
+ when String, Date, Time, Symbol
382
+ "'#{fld}'"
383
+ when BigDecimal
384
+ fld.to_f
385
+ when nil
386
+ 'NULL'
387
+ else
388
+ fld
389
+ end
390
+
391
+ end
392
+
393
+
394
+ private
395
+
396
+
397
+ ##
398
+ # build a hash of column -> oid
399
+ #
400
+ def make_oid_hash(query)
401
+
402
+ query.fields.each_with_object({}) do |f,h|
403
+ h[f.to_sym] = query.ftype( query.fnumber(f) )
404
+ end
405
+
406
+ end
407
+
408
+
409
+ ##
410
+ # Cast a query row
411
+ # This is to step around problems with pg type mapping
412
+ # There is definitely a way to tell pg to cast money and numeric as
413
+ # BigDecimal, but, it's not documented and no one can tell me how to do it!
414
+ #
415
+ # Also, for the pg_jruby gem, type mapping doesn't work at all?
416
+ #
417
+ def cast_row_fudge(row, oids)
418
+ lBool =->(s) { s.to_i = 1 || s.upcase == 'TRUE' }
419
+ lFloat =->(s) { Float(s) rescue s }
420
+ lInt =->(s) { Integer(s,10) rescue s }
421
+ lTime =->(s) { Time.parse(s) rescue s }
422
+ lDate =->(s) { Date.parse(s) rescue s }
423
+ lBigDec =->(s) { BigDecimal.new(s) rescue s }
424
+
425
+ row.each_with_object({}) do |(k,v),h|
426
+ key = k.to_sym
427
+ oid = oids[key]
428
+
429
+ h[key] =
430
+ case
431
+ when v.class != String then v # assume already converted
432
+
433
+ when oid == 1700 then lBigDec.(v) # numeric
434
+ when oid == 790 then lBigDec.(v[1..-1]) # "£1.23"
435
+ when oid == 1082 then lDate.(v)
436
+
437
+ when [16, 1560].include?(oid) then lBool.(v)
438
+ when [700, 701].include?(oid) then lFloat.(v)
439
+ when [20, 21, 23].include?(oid) then lInt.(v)
440
+ when [1114, 1184].include?(oid) then lTime.(v)
441
+
442
+ else v
443
+ end
444
+
445
+ end
446
+
447
+ end
448
+
449
+
450
+ def read_or_die(id)
451
+ raise CantContinue, "'No record found with ID '#{id}'" \
452
+ if read(id).empty?
453
+
454
+ end
455
+
456
+
457
+
458
+ end
459
+
460
+ end