Housekeeping: remove commented-out code.
[quanlib.git] / store.rb
1
2 require 'csv'
3 require 'fileutils'
4 require 'inifile'
5 require 'pg'
6
7 require_relative 'series'
8 require_relative 'tconn'
9
10 class Store
11   def unclassified_csv
12     @basePath + '/csv/unclassified.csv'
13   end
14
15   def initialize(config_file)
16     @conn = nil
17
18     config = IniFile.load(config_file)
19     if nil == config
20       puts 'FATAL:  Failed to load config file "' + config_file + '".  Aborting initialization.'
21       return
22     end
23
24     section = config['database']
25     @dbhost = section['host']
26     @dbport = 5432
27     @dbname = section['name']
28     @dbuser = section['user']
29     @dbpass = section['pass']
30
31     section = config['filesystem']
32     @basePath = section['basePath']
33   end
34
35   def connect
36     @conn = TimedConn.new(PG.connect(@dbhost, @dbport, '', '', @dbname, @dbuser, @dbpass))
37     return @conn
38   end
39
40   def disconnect
41     @conn.close()
42   end
43
44   def construct_efs_path(efs_id)
45     id_str = sprintf('%010d', efs_id)
46     path = sprintf('%s/%s/%s/%s', id_str[0,2], id_str[2,2], id_str[4,2], id_str[6,2])
47     name = id_str + '.dat'
48     return path, name
49   end
50
51   def create_schema(skip_class)
52     create_authors = 
53 <<EOS
54       CREATE TABLE Authors (
55         id          INTEGER PRIMARY KEY,
56         grouping    VARCHAR(64),
57         reading     VARCHAR(256),
58         sort        VARCHAR(256)
59       );
60 EOS
61
62     create_books = 
63 <<EOS
64       CREATE TABLE Books (
65         id             INTEGER PRIMARY KEY,
66         author         INTEGER REFERENCES Authors(id),
67         classification INTEGER REFERENCES Classifications(id),
68         cover          INTEGER,
69         description    TEXT,
70         path           VARCHAR(256),
71         series         INTEGER REFERENCES Series(id),
72         title          VARCHAR(256),
73         volume         VARCHAR(16)
74       );
75 EOS
76
77     create_classification =
78 <<EOS
79       CREATE TABLE Classifications (
80         id              INTEGER PRIMARY KEY,
81         ddc             VARCHAR(32),
82         lcc             VARCHAR(32),
83         author_grouping VARCHAR(64),
84         author_sort     VARCHAR(128),
85         title_grouping  VARCHAR(256),
86         title           VARCHAR(256)
87       );
88 EOS
89
90     create_efs = 
91 <<EOS
92       CREATE TABLE EFS (
93         id          INTEGER PRIMARY KEY,
94         mimetype    VARCHAR(64)
95       );
96 EOS
97
98     create_fast = 
99 <<EOS
100       CREATE TABLE FAST (
101         id          VARCHAR(32) PRIMARY KEY,
102         descr       VARCHAR(128)
103       );
104 EOS
105
106     # Associative entity, linking FAST and Classifications tables
107     # in a 0..n to 0..m relationship
108     create_fast_classifications =
109 <<EOS
110       CREATE TABLE FAST_Classifications (
111         fast           VARCHAR(32) REFERENCES FAST(id),
112         classification INTEGER REFERENCES Classifications(id)
113       );
114 EOS
115
116     create_series = 
117 <<EOS
118       CREATE TABLE Series (
119         id          INTEGER PRIMARY KEY,
120         age         VARCHAR(32),
121         genre       VARCHAR(32),
122         grouping    VARCHAR(64),
123         code        VARCHAR(16),
124         descr       VARCHAR(128)
125       )
126 EOS
127
128     stmts = [
129       create_authors,
130       create_classification,
131       create_efs,
132       create_fast,
133       create_series,
134       create_books,
135       create_fast_classifications,
136       'CREATE SEQUENCE author_id;',
137       'CREATE SEQUENCE book_id;',
138       'CREATE SEQUENCE classification_id;',
139       'CREATE SEQUENCE efs_id;',
140       'CREATE SEQUENCE series_id;'
141     ]
142
143     for stmt in stmts
144       @conn.exec(stmt)
145     end
146
147     if skip_class == false
148       populate_fast_table()
149       populate_classifications_table()
150     end
151
152     populate_series_table()
153
154   end
155
156   def dropSchema
157     stmts = [
158       'DROP TABLE Books;',
159       'DROP TABLE FAST_Classifications;',
160       'DROP TABLE Authors;',
161       'DROP TABLE Classifications;',
162       'DROP TABLE EFS;',
163       'DROP TABLE FAST;',
164       'DROP TABLE Series;',
165       'DROP SEQUENCE author_id;',
166       'DROP SEQUENCE book_id;',
167       'DROP SEQUENCE classification_id;',
168       'DROP SEQUENCE efs_id;',
169       'DROP SEQUENCE series_id;'
170     ]
171
172     for stmt in stmts do
173       begin
174         @conn.exec(stmt)
175       rescue Exception => exc
176         puts 'WARNING:  "' + stmt + '" failed:  ' + exc.to_s
177       end
178     end
179   end
180
181   def find_author(author)
182     sqlSelect = "SELECT id FROM Authors WHERE grouping=$1 AND reading=$2 AND sort=$3;"
183     args = [author.grouping, author.reading_order, author.sort_order]
184     @conn.exec_params(sqlSelect, args) do |rs|
185       if rs.ntuples > 0
186         return rs[0]['id']
187       end
188     end
189     return nil
190   end
191
192   def init_db(skip_class)
193     sql = "SELECT 1 FROM pg_tables WHERE tableowner='quanlib' AND tablename='books'"
194     found = false
195     @conn.exec(sql).each do |row|
196       found = true
197     end
198
199     if ! found
200       create_schema(skip_class)
201     end
202   end
203
204   def load_author(id)
205     sqlSelect = "SELECT grouping, reading, sort FROM Authors WHERE id=$1"
206     args = [id]
207     @conn.exec_params(sqlSelect, args) do |rs|
208       if rs.ntuples != 1
209         raise "Expected 1 row for " + id + " but got " + rs.ntuples + ":  " + sqlSelect
210       end
211       row = rs[0]
212       author = Author.new(row['grouping'], row['reading'], row['sort'])
213       return author
214     end
215     return nil
216   end
217
218   def store_author(author)
219     id = find_author(author)
220     if nil == id
221       id = next_id('author_id')
222       sqlInsert = "INSERT INTO Authors(id, grouping, reading, sort) VALUES ($1, $2, $3, $4);"
223       args = [id, author.grouping, author.reading_order, author.sort_order]
224       begin 
225         rs = @conn.exec_params(sqlInsert, args)
226       rescue Exception => e
227         puts sqlInsert + ":  " + args.inspect()
228         puts e.message
229         puts $@
230       ensure
231         rs.clear if rs
232       end
233     end
234     return id
235   end
236
237   def load_book(id)
238     sql = "SELECT author, classification, cover, description, path, series, title, volume FROM Books WHERE id=$1;"
239     book = nil
240
241     begin
242       @conn.exec_params(sql, [id]) do |rs|
243         if 1 != rs.ntuples
244           raise 'Expected one row in Books for id ' + id + ', but found ' + rs.length + '.'
245           return nil
246         end
247         row = rs[0]
248
249         book = Book.new(self)
250         book.author = load_author(row['author'])
251         book.classification_id = row['classification']
252         book.cover = load_cover(row['cover'])
253         book.description = row['description']
254         book.path = row['path']
255         book.series_id = row['series']
256         book.title = row['title']
257         book.volume = row['volume']
258       end    
259     rescue Exception => e
260       puts sql + ": " + id
261       puts e.message
262       puts $@
263     end
264
265     return book
266   end
267
268   def store_book(book)
269     sql = "INSERT INTO Books (id, author, classification, cover, description, path, series, title, volume) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9);"
270
271     book_id = next_id('book_id')
272
273     author_id = store_author(book.author)
274     (efs_id, mime_type) = store_cover(book)
275
276     args = [book_id, author_id, book.classification_id, efs_id, book.description(), book.path(), book.series_id(), book.title(), book.volume()]
277
278     begin
279       rs = @conn.exec_params(sql, args)
280     rescue Exception => e
281       puts sql + ": " + args.inspect()
282       puts e.message 
283       puts $@
284     ensure
285       rs.clear if rs
286     end
287
288     return book_id
289   end
290
291   def find_classification(author_grouping, title_grouping)
292     sql = "SELECT id FROM Classifications WHERE author_grouping = $1 AND title_grouping = $2;"
293     @conn.exec_params(sql, [author_grouping, title_grouping]) do |rs|
294       if rs.ntuples > 0
295         return rs[0]['id']
296       end
297     end
298     return nil
299   end
300
301   def load_classification(id)
302     sql  = "SELECT ddc, lcc, author_grouping, author_sort, title_grouping, title "
303     sql += " FROM Classifications WHERE id=$1"
304     @conn.exec_params(sql, [id]) do |rs|
305       if rs.ntuples > 0
306         row = rs[0]
307         ddc = row['ddc']
308         lcc = row['lcc']
309         author_grouping = row['author_grouping']
310         author = row['author_sort']
311         title_grouping = row['title_grouping']
312         title = row['title']
313
314         result = Classification.new(ddc, lcc, author_grouping, author, title_grouping, title)
315         result.id = id
316         return result
317       end
318     end
319
320     return nil
321   end
322
323   def load_cover(id)
324     if nil == id
325       return nil
326     end
327
328     mime_type = 'application/octet-stream'
329
330     sql = "SELECT mimeType FROM Efs WHERE id=$1"
331     @conn.exec_params(sql, [id]) do |rs|
332       if rs.ntuples != 1
333         raise "Expected one row but got " + rs.ntuples + ": " + sql + ": " + id
334       end
335       mime_type = rs[0]['mimeType']
336     end
337
338     (efspath, efsname) = construct_efs_path(id)
339
340     fullpath = @basePath + '/efs/' + efspath + '/' + efsname
341
342     return Cover.new(nil, fullpath, mime_type)
343   end
344
345   def store_cover(book)
346     efs_id = nil
347     cover = book.cover()
348
349     if nil == cover
350       return nil
351     end
352
353     @conn.exec("SELECT nextval('efs_id')") do |rs|
354       efs_id = rs[0]['nextval']
355     end
356
357     if nil == efs_id
358       return nil
359     end
360
361     (efspath, efsname) = construct_efs_path(efs_id)
362
363     efspath = @basePath + '/efs/' + efspath
364
365     FileUtils.mkdir_p(efspath)
366
367     (filepath, mimetype) = cover.write_image(efspath, efsname)
368
369     sql = "INSERT INTO efs VALUES ($1, $2)"
370     begin
371       rs = @conn.exec_params(sql, [efs_id, mimetype])
372     rescue Exception => e
373       puts sql + ": " + efs_id + ", " + mimetype
374       puts e.message
375       puts $@
376     ensure
377       rs.clear if rs
378     end
379     
380     return efs_id, mimetype
381   end
382
383   def exec_id_query(sql, args)
384     ids = []
385     @conn.exec_params(sql, args) do |rs|
386       rs.each do |row|
387         ids.push(row['id'])
388       end
389     end
390     return ids
391   end
392
393   def exec_update(sql, args)
394     begin
395       rs = @conn.exec_params(sql, args)
396     rescue Exception => e
397       puts sql + ": " + args.inspect()
398       puts e.message
399       puts $@
400     ensure
401       rs.clear if rs
402     end
403   end
404
405   def next_id(seq_name)
406     id = nil
407     @conn.exec("SELECT nextval('" + seq_name + "');") do |rs|
408       id = rs[0]['nextval']
409     end 
410     return id
411   end
412
413   def get_series(grouping, code)
414     if nil == code
415       return nil
416     end
417
418     sql = "SELECT id FROM Series WHERE grouping=$1 AND code=$2;"
419     args = [grouping, code]
420     @conn.exec_params(sql, args).each do |row|
421       return row['id']
422     end
423
424     # TODO:  Create a new series object here?
425     puts 'WARNING:  series("' + grouping + '", "' + code + '") not found.'
426     return nil
427   end
428
429   def load_series(id)
430     sql = "SELECT age,genre,grouping,code,descr FROM Series WHERE id=$1;"
431     args = [id]
432     @conn.exec_params(sql, args) do |rs|
433       if rs.ntuples > 0
434         row = rs[0]
435         series = Series.new(id)
436         series.age = row['age']
437         series.genre = row['genre']
438         series.grouping = row['grouping']
439         series.code = row['code']
440         series.descr = row['descr']
441         return series
442       end
443     end
444     return nil
445   end
446
447   def populate_classifications_table
448     puts "Populating the Classifications table..."
449     first = true
450     CSV.foreach(@basePath + '/csv/class.csv') do |row|
451       if first
452         # skip the header row
453         first = false
454       else
455
456         # First, add a row to the Classifications table
457
458         id = next_id('classification_id')
459         ddc = row[0]
460         lcc = row[1]
461         author_grouping = row[2]
462         author_sort = row[3]
463         title_grouping = row[4]
464         title = row[5]
465         
466         sqlInsert = "INSERT INTO Classifications (id, ddc, lcc, author_grouping, author_sort, title_grouping, title) VALUES ($1, $2, $3, $4, $5, $6, $7);"
467         args = [id, ddc, lcc, author_grouping, author_sort, title_grouping, title]
468         exec_update(sqlInsert, args)
469
470         # Second, link up with the appropriate FAST table entries
471
472         fast = []
473         input = row[6]
474         if input.length > 0
475           fast = input.split(';')
476         end 
477
478         fast.each do |fast_id|
479           sqlInsert = "INSERT INTO FAST_Classifications (fast, classification) VALUES ($1, $2);"
480           args = [fast_id, id]
481           exec_update(sqlInsert, args)
482         end
483       end
484     end
485   end
486
487   def populate_fast_table
488     puts "Populating the FAST table..."
489     first = true
490     CSV.foreach(@basePath + '/csv/fast.csv') do |row|
491       if first
492         first = false   # skip the header row
493       else
494         id = row[0]
495         descr = row[1]
496         sqlInsert = "INSERT INTO FAST (id, descr) VALUES ($1, $2);"
497         exec_update(sqlInsert, [id, descr])
498       end
499     end
500   end
501
502   def populate_series_table
503     puts "Populating the Series table..."
504     CSV.foreach(@basePath + '/csv/series.csv') do |row|
505       id = next_id('series_id')
506       sqlInsert = "INSERT INTO Series (id, age, genre, grouping, code, descr) VALUES ($1, $2, $3, $4, $5, $6);"
507       args = [id] + row
508       exec_update(sqlInsert, args)
509     end
510   end
511
512   def query_books_by_author(pattern)
513     sql = 
514 <<EOS
515       SELECT b.id FROM Authors a 
516       INNER JOIN Books b ON b.author=a.id 
517       LEFT OUTER JOIN Series s on s.id=b.series
518       WHERE upper(a.grouping) LIKE $1 
519       ORDER BY a.grouping, b.series, b.volume, b.title
520 EOS
521     return exec_id_query(sql, [pattern])
522   end
523
524   def query_books_by_ddc
525     sql = 
526 <<EOS
527       SELECT b.id FROM Classifications c 
528       INNER JOIN Books b ON b.classification=c.id
529       ORDER BY c.ddc
530 EOS
531     return exec_id_query(sql, [])
532   end
533
534   def query_books_by_series_id(id)
535     sql = 
536 <<EOS
537       SELECT b.id FROM Books b
538       WHERE b.series = $1
539       ORDER BY b.volume,b.title
540 EOS
541     return exec_id_query(sql, [id])
542   end
543
544   def query_series_by_age(pattern)
545     sql = 
546 <<EOS
547       SELECT s.id 
548       FROM Series s
549       WHERE s.age LIKE $1
550       ORDER BY s.grouping,s.descr
551 EOS
552     return exec_id_query(sql, [pattern])
553   end
554 end
555