diff --git a/src/calibre/db/backend.py b/src/calibre/db/backend.py index 13e8b80ff5..fc7d556dc0 100644 --- a/src/calibre/db/backend.py +++ b/src/calibre/db/backend.py @@ -42,6 +42,8 @@ Differences in semantics from pysqlite: 3. There is no executescript ''' +CUSTOM_DATA_TYPES = frozenset(['rating', 'text', 'comments', 'datetime', + 'int', 'float', 'bool', 'series', 'composite', 'enumeration']) class DynamicFilter(object): # {{{ @@ -797,6 +799,184 @@ class DB(object): return self.custom_column_label_map[label] return self.custom_column_num_map[num] + def set_custom_column_metadata(self, num, name=None, label=None, is_editable=None, display=None): + changed = False + if name is not None: + self.conn.execute('UPDATE custom_columns SET name=? WHERE id=?', (name, num)) + changed = True + if label is not None: + self.conn.execute('UPDATE custom_columns SET label=? WHERE id=?', (label, num)) + changed = True + if is_editable is not None: + self.conn.execute('UPDATE custom_columns SET editable=? WHERE id=?', (bool(is_editable), num)) + self.custom_column_num_map[num]['is_editable'] = bool(is_editable) + changed = True + if display is not None: + self.conn.execute('UPDATE custom_columns SET display=? WHERE id=?', (json.dumps(display), num)) + changed = True + return changed + + def create_custom_column(self, label, name, datatype, is_multiple, editable=True, display={}): # {{{ + import re + if not label: + raise ValueError(_('No label was provided')) + if re.match('^\w*$', label) is None or not label[0].isalpha() or label.lower() != label: + raise ValueError(_('The label must contain only lower case letters, digits and underscores, and start with a letter')) + if datatype not in CUSTOM_DATA_TYPES: + raise ValueError('%r is not a supported data type'%datatype) + normalized = datatype not in ('datetime', 'comments', 'int', 'bool', + 'float', 'composite') + is_multiple = is_multiple and datatype in ('text', 'composite') + self.conn.execute( + ('INSERT INTO ' + 'custom_columns(label,name,datatype,is_multiple,editable,display,normalized)' + 'VALUES (?,?,?,?,?,?,?)'), + (label, name, datatype, is_multiple, editable, json.dumps(display), normalized)) + num = self.conn.last_insert_rowid() + + if datatype in ('rating', 'int'): + dt = 'INT' + elif datatype in ('text', 'comments', 'series', 'composite', 'enumeration'): + dt = 'TEXT' + elif datatype in ('float',): + dt = 'REAL' + elif datatype == 'datetime': + dt = 'timestamp' + elif datatype == 'bool': + dt = 'BOOL' + collate = 'COLLATE NOCASE' if dt == 'TEXT' else '' + table, lt = self.custom_table_names(num) + if normalized: + if datatype == 'series': + s_index = 'extra REAL,' + else: + s_index = '' + lines = [ + '''\ + CREATE TABLE %s( + id INTEGER PRIMARY KEY AUTOINCREMENT, + value %s NOT NULL %s, + UNIQUE(value)); + '''%(table, dt, collate), + + 'CREATE INDEX %s_idx ON %s (value %s);'%(table, table, collate), + + '''\ + CREATE TABLE %s( + id INTEGER PRIMARY KEY AUTOINCREMENT, + book INTEGER NOT NULL, + value INTEGER NOT NULL, + %s + UNIQUE(book, value) + );'''%(lt, s_index), + + 'CREATE INDEX %s_aidx ON %s (value);'%(lt,lt), + 'CREATE INDEX %s_bidx ON %s (book);'%(lt,lt), + + '''\ + CREATE TRIGGER fkc_update_{lt}_a + BEFORE UPDATE OF book ON {lt} + BEGIN + SELECT CASE + WHEN (SELECT id from books WHERE id=NEW.book) IS NULL + THEN RAISE(ABORT, 'Foreign key violation: book not in books') + END; + END; + CREATE TRIGGER fkc_update_{lt}_b + BEFORE UPDATE OF author ON {lt} + BEGIN + SELECT CASE + WHEN (SELECT id from {table} WHERE id=NEW.value) IS NULL + THEN RAISE(ABORT, 'Foreign key violation: value not in {table}') + END; + END; + CREATE TRIGGER fkc_insert_{lt} + BEFORE INSERT ON {lt} + BEGIN + SELECT CASE + WHEN (SELECT id from books WHERE id=NEW.book) IS NULL + THEN RAISE(ABORT, 'Foreign key violation: book not in books') + WHEN (SELECT id from {table} WHERE id=NEW.value) IS NULL + THEN RAISE(ABORT, 'Foreign key violation: value not in {table}') + END; + END; + CREATE TRIGGER fkc_delete_{lt} + AFTER DELETE ON {table} + BEGIN + DELETE FROM {lt} WHERE value=OLD.id; + END; + + CREATE VIEW tag_browser_{table} AS SELECT + id, + value, + (SELECT COUNT(id) FROM {lt} WHERE value={table}.id) count, + (SELECT AVG(r.rating) + FROM {lt}, + books_ratings_link as bl, + ratings as r + WHERE {lt}.value={table}.id and bl.book={lt}.book and + r.id = bl.rating and r.rating <> 0) avg_rating, + value AS sort + FROM {table}; + + CREATE VIEW tag_browser_filtered_{table} AS SELECT + id, + value, + (SELECT COUNT({lt}.id) FROM {lt} WHERE value={table}.id AND + books_list_filter(book)) count, + (SELECT AVG(r.rating) + FROM {lt}, + books_ratings_link as bl, + ratings as r + WHERE {lt}.value={table}.id AND bl.book={lt}.book AND + r.id = bl.rating AND r.rating <> 0 AND + books_list_filter(bl.book)) avg_rating, + value AS sort + FROM {table}; + + '''.format(lt=lt, table=table), + + ] + else: + lines = [ + '''\ + CREATE TABLE %s( + id INTEGER PRIMARY KEY AUTOINCREMENT, + book INTEGER, + value %s NOT NULL %s, + UNIQUE(book)); + '''%(table, dt, collate), + + 'CREATE INDEX %s_idx ON %s (book);'%(table, table), + + '''\ + CREATE TRIGGER fkc_insert_{table} + BEFORE INSERT ON {table} + BEGIN + SELECT CASE + WHEN (SELECT id from books WHERE id=NEW.book) IS NULL + THEN RAISE(ABORT, 'Foreign key violation: book not in books') + END; + END; + CREATE TRIGGER fkc_update_{table} + BEFORE UPDATE OF book ON {table} + BEGIN + SELECT CASE + WHEN (SELECT id from books WHERE id=NEW.book) IS NULL + THEN RAISE(ABORT, 'Foreign key violation: book not in books') + END; + END; + '''.format(table=table), + ] + script = ' \n'.join(lines) + self.conn.execute(script) + return num + # }}} + + def delete_custom_column(self, label=None, num=None): + data = self.custom_field_metadata(label, num) + self.conn.execute('UPDATE custom_columns SET mark_for_delete=1 WHERE id=?', (data['num'],)) + def close(self): if self._conn is not None: self._conn.close() @@ -1274,3 +1454,4 @@ class DB(object): # }}} + diff --git a/src/calibre/db/cache.py b/src/calibre/db/cache.py index e7c3114f0d..d9dda41aa3 100644 --- a/src/calibre/db/cache.py +++ b/src/calibre/db/cache.py @@ -1401,6 +1401,17 @@ class Cache(object): def lookup_by_uuid(self, uuid): return self.fields['uuid'].table.lookup_by_uuid(uuid) + @write_api + def delete_custom_column(self, label=None, num=None): + self.backend.delete_custom_column(label, num) + + @write_api + def create_custom_column(self, label, name, datatype, is_multiple, editable=True, display={}): + self.backend.create_custom_column(label, name, datatype, is_multiple, editable=editable, display=display) + + @write_api + def set_custom_column_metadata(self, num, name=None, label=None, is_editable=None, display=None): + return self.backend.set_custom_column_metadata(num, name=name, label=label, is_editable=is_editable, display=display) # }}} diff --git a/src/calibre/db/legacy.py b/src/calibre/db/legacy.py index b814b1e23e..8f7a1c577e 100644 --- a/src/calibre/db/legacy.py +++ b/src/calibre/db/legacy.py @@ -653,6 +653,17 @@ class LibraryDatabase(object): if notify: self.notify('metadata', list(ids)) + def delete_custom_column(self, label=None, num=None): + self.new_api.delete_custom_column(label, num) + + def create_custom_column(self, label, name, datatype, is_multiple, editable=True, display={}): + self.new_api.create_custom_column(label, name, datatype, is_multiple, editable=editable, display=display) + + def set_custom_column_metadata(self, num, name=None, label=None, is_editable=None, display=None, notify=True): + changed = self.new_api.set_custom_column_metadata(num, name=name, label=label, is_editable=is_editable, display=display) + if changed and notify: + self.notify('metadata', []) + # Private interface {{{ def __iter__(self): for row in self.data.iterall(): @@ -843,3 +854,4 @@ del MT + diff --git a/src/calibre/db/tests/legacy.py b/src/calibre/db/tests/legacy.py index 6765392638..6e50c164e3 100644 --- a/src/calibre/db/tests/legacy.py +++ b/src/calibre/db/tests/legacy.py @@ -689,5 +689,25 @@ class LegacyTest(BaseTest): ('get_custom', 0, 'tags'), ('get_custom', 1, 'tags'), ('get_custom', 2, 'tags'), )) db.close() + + o = self.cloned_library + n = self.cloned_library + ndb, db = self.init_legacy(n), self.init_old(o) + ndb.create_custom_column('created', 'Created', 'text', True, True, {'moose':'cat'}) + db.create_custom_column('created', 'Created', 'text', True, True, {'moose':'cat'}) + db.close() + ndb, db = self.init_legacy(n), self.init_old(o) + self.assertEqual(db.custom_column_label_map['created'], ndb.backend.custom_field_metadata('created')) + num = db.custom_column_label_map['created']['num'] + ndb.set_custom_column_metadata(num, is_editable=False, name='Crikey', display={}) + db.set_custom_column_metadata(num, is_editable=False, name='Crikey', display={}) + db.close() + ndb, db = self.init_legacy(n), self.init_old(o) + self.assertEqual(db.custom_column_label_map['created'], ndb.backend.custom_field_metadata('created')) + db.close() + ndb = self.init_legacy(n) + ndb.delete_custom_column('created') + ndb = self.init_legacy(n) + self.assertRaises(KeyError, ndb.custom_field_name, num=num) # }}}