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