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