/* Copyright (C) 2013-2018 Christian Dywan This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. See the file COPYING for the full license text. */ namespace Midori { public errordomain DatabaseError { OPEN, NAMING, FILENAME, EXECUTE, COMPILE, TYPE, } public delegate bool DatabaseCallback () throws DatabaseError; public class DatabaseStatement : Object, Initable { Sqlite.Statement stmt = null; int64 last_row_id = -1; public Database? database { get; set construct; } public string? query { get; set construct; } public DatabaseStatement (Database database, string query) throws DatabaseError { Object (database: database, query: query); init (); } public virtual bool init (Cancellable? cancellable = null) throws DatabaseError { int result = database.db.prepare_v2 (query, -1, out stmt, null); if (result != Sqlite.OK) throw new DatabaseError.COMPILE ("Failed to compile statement '%s': %s".printf (query, database.errmsg)); return true; } /* * Bind values to named parameters. * SQL: "SELECT foo FROM bar WHERE id = :session_id" * Vala: statement.bind(":session_id", typeof (int64), 12345); * Supported types: string, int64, double */ public void bind (string pname, ...) throws DatabaseError { int pindex = stmt.bind_parameter_index (pname); if (pindex <= 0) throw new DatabaseError.TYPE ("No such parameter '%s' in statement: %s".printf (pname, query)); var args = va_list (); Type ptype = args.arg (); if (ptype == typeof (string)) { string text = args.arg (); stmt.bind_text (pindex, text); database.debug ("%s=%s", pname, text); } else if (ptype == typeof (int64)) { int64 integer = args.arg (); stmt.bind_int64 (pindex, integer); database.debug ("%s=%s", pname, integer.to_string ()); } else if (ptype == typeof (double)) { double stuntman = args.arg (); stmt.bind_double (pindex, stuntman); database.debug ("%s=%s", pname, stuntman.to_string ()); } else { throw new DatabaseError.TYPE ("Invalid type '%s' for '%s' in statement: %s".printf (ptype.name (), pname, query)); } } /* * Execute the statement, it's an error if there are more rows. */ public bool exec () throws DatabaseError { if (step ()) throw new DatabaseError.EXECUTE ("More rows available - use step instead of exec"); return true; } /* * Proceed to the next row, returns false when the end is nigh. */ public bool step () throws DatabaseError { int result = stmt.step (); if (result != Sqlite.DONE && result != Sqlite.ROW) throw new DatabaseError.EXECUTE (database.errmsg); last_row_id = database.last_row_id; return result == Sqlite.ROW; } /* * Returns the id of the last inserted row. * It is an error to ask for an id without having inserted a row. */ public int64 row_id () throws DatabaseError { if (last_row_id == -1) throw new DatabaseError.EXECUTE ("No row id"); return last_row_id; } int column_index (string name) throws DatabaseError { for (int i = 0; i < stmt.column_count (); i++) { if (name == stmt.column_name (i)) return i; } throw new DatabaseError.TYPE ("No such column '%s' in row: %s".printf (name, query)); } /* * Get a string value by its named parameter, for example ":uri". * Returns null if not found. */ public string? get_string (string name) throws DatabaseError { int index = column_index (name); int type = stmt.column_type (index); if (stmt.column_type (index) != Sqlite.TEXT && type != Sqlite.NULL) throw new DatabaseError.TYPE ("Getting '%s' with wrong type in row: %s".printf (name, query)); return stmt.column_text (index); } /* * Get an integer value by its named parameter, for example ":day". * Returns 0 if not found. */ public int64 get_int64 (string name) throws DatabaseError { int index = column_index (name); int type = stmt.column_type (index); if (type != Sqlite.INTEGER && type != Sqlite.NULL) throw new DatabaseError.TYPE ("Getting '%s' with value '%s' of wrong type %d in row: %s".printf ( name, stmt.column_text (index), type, query)); return stmt.column_int64 (index); } /* * Get a double value by its named parameter, for example ":session_id". * Returns double.NAN if not found. */ public double get_double (string name) throws DatabaseError { int index = column_index (name); int type = stmt.column_type (index); if (type != Sqlite.FLOAT && type != Sqlite.NULL) throw new DatabaseError.TYPE ("Getting '%s' with wrong type in row: %s".printf (name, query)); return type == Sqlite.NULL ? double.NAN : stmt.column_double (index); } } public class DatabaseItem : Object { public Database? database { get; set; } public int64 id { get; set; } public string uri { get; set; } public string? title { get; set; } public int64 date { get; set; } public DatabaseItem (string uri, string? title, int64 date=0) { Object (uri: uri, title: title, date: date); notify["title"].connect ((pspec) => { if (database != null) { database.update.begin (this); } }); } /* * Delete the item, or no-op if it can't be deleted. */ public async bool delete () { if (database != null) { try { return yield database.delete (this); } catch (DatabaseError error) { critical ("Failed to delete %s: %s", uri, error.message); } } return false; } } public class Database : Object, Initable, ListModel, Loggable { internal Sqlite.Database? db = null; string? _key = null; Cancellable? populate_cancellable = null; public string? table { get; protected set; default = null; } public string path { get; protected set; default = ":memory:"; } public string? key { get { return _key; } set { _key = value; if (populate_cancellable != null) { populate_cancellable.cancel (); } populate_cancellable = new Cancellable (); populate.begin (populate_cancellable); } } internal int64 maximum_age { get; protected set; default = -1; } /* * A read-only database will fail on insert, update and delete. */ public bool readonly { get; construct set; default = false; } /* * A new database successfully opened for the first time. * Old or additional data should be opened if this is true. */ public bool first_use { get; protected set; default = false; } /* * The ID of the last inserted row. */ public int64 last_row_id { get { return db.last_insert_rowid (); } } /* * The error message of the last failed operation. */ public string errmsg { get { return db.errmsg (); } } /* * If a filename is passed it's assumed to be in the config folder. * Otherwise the database is in memory only (useful for private browsing). */ public Database (string path=":memory:") throws DatabaseError { Object (path: path); init (); } string resolve_path (string path) { if (path.has_prefix (":memory:")) return ":memory:"; else if (!Path.is_absolute (path)) return Path.build_filename (Environment.get_user_config_dir (), Config.PROJECT_NAME, path); return path; } public virtual bool init (Cancellable? cancellable = null) throws DatabaseError { if (table == null) { string basename = Path.get_basename (path); string[] parts = basename.split ("."); if (parts != null && parts[0] != null && parts[1] != null) { table = parts[0]; } else if (path == ":memory:") { table = "memory"; } else { throw new DatabaseError.NAMING ("Failed to deduce table from %s".printf (path)); } } string real_path = resolve_path (path); bool exists = exists (real_path); int flags = 0; if (readonly) { flags |= Sqlite.OPEN_READONLY; } else { flags |= Sqlite.OPEN_CREATE; flags |= Sqlite.OPEN_READWRITE; } if (Sqlite.Database.open_v2 (real_path, out db, flags) != Sqlite.OK) { throw new DatabaseError.OPEN ("Failed to open database %s".printf (real_path)); } set_data ("db", db); if (logging) { debug ("Tracing %s", path); db.profile ((sql, nanoseconds) => { /* sqlite as of this writing isn't more precise than ms */ string milliseconds = (nanoseconds / 1000000).to_string (); debug ("%s (%sms)", sql, milliseconds); }); } if (db.exec ("PRAGMA journal_mode = WAL; PRAGMA cache_size = 32100;") != Sqlite.OK) db.exec ("PRAGMA synchronous = NORMAL; PRAGMA temp_store = MEMORY;"); db.exec ("PRAGMA count_changes = OFF;"); if (real_path == ":memory:") { return exec ("CREATE TABLE %s (uri text, title text, date integer)".printf (table)); } int64 user_version; Sqlite.Statement stmt; if (db.prepare_v2 ("PRAGMA user_version;", -1, out stmt, null) != Sqlite.OK) throw new DatabaseError.EXECUTE ("Failed to compile statement %s".printf (errmsg)); if (stmt.step () != Sqlite.ROW) throw new DatabaseError.EXECUTE ("Failed to get row %s".printf (errmsg)); user_version = stmt.column_int64 (0); if (user_version == 0) { exec_script ("Create"); user_version = 1; exec ("PRAGMA user_version = " + user_version.to_string ()); } while (true) { try { exec_script ("Update" + user_version.to_string ()); } catch (DatabaseError error) { if (error is DatabaseError.FILENAME) break; throw error; } user_version = user_version + 1; exec ("PRAGMA user_version = " + user_version.to_string ()); } first_use = !exists; if (!readonly) { // Periodically delete local user data exceeding a maximum timespan Timeout.add_seconds (60 * 60, () => { cap.begin (maximum_age); return Source.CONTINUE; }); } return true; } public bool exists (string path) { return FileUtils.test (path, FileTest.EXISTS); } public bool exec_script (string filename) throws DatabaseError { string basename = Path.get_basename (path).split (".")[0]; string schema_path = "/data/%s/%s.sql".printf (basename, filename); try { var schema = resources_lookup_data (schema_path, ResourceLookupFlags.NONE); transaction (()=> { return exec ((string)schema.get_data ()); }); } catch (Error error) { throw new DatabaseError.FILENAME ("Failed to open schema: %s".printf (schema_path)); } return true; } public bool transaction (DatabaseCallback callback) throws DatabaseError { exec ("BEGIN TRANSACTION;"); callback (); exec ("COMMIT;"); return true; } public bool exec (string query) throws DatabaseError { if (db.exec (query) != Sqlite.OK) throw new DatabaseError.EXECUTE (errmsg); return true; } /* * Prepare a statement with optionally binding parameters by name. * See also DatabaseStatement.bind(). */ public DatabaseStatement prepare (string query, ...) throws DatabaseError { var statement = new DatabaseStatement (this, query); var args = va_list (); unowned string? pname = args.arg (); while (pname != null) { Type ptype = args.arg (); if (ptype == typeof (string)) { string pvalue = args.arg (); statement.bind (pname, ptype, pvalue); } else if (ptype == typeof (int64)) { int64 pvalue = args.arg (); statement.bind (pname, ptype, pvalue); } else if (ptype == typeof (double)) { double pvalue = args.arg (); statement.bind (pname, ptype, pvalue); } else throw new DatabaseError.TYPE ("Invalid type '%s' in statement: %s".printf (ptype.name (), query)); pname = args.arg (); } return statement; } /* * Delete an item from the database. */ public async virtual bool delete (DatabaseItem item) throws DatabaseError { string sqlcmd = """ DELETE FROM %s WHERE rowid = :id """.printf (table); DatabaseStatement statement; try { statement = prepare (sqlcmd, ":id", typeof (int64), item.id); if (statement.exec ()) { if (_items != null) { int index = _items.index (item); _items.remove (item); items_changed (index, 1, 0); } return true; } } catch (Error error) { critical (_("Failed to delete from %s: %s"), table, error.message); } return false; } /* * Lookup a specific item by its URI. */ public async virtual DatabaseItem? lookup (string uri) throws DatabaseError { string sqlcmd = """ SELECT rowid, title, date FROM %s WHERE uri = :uri LIMIT 1 """.printf (table); var statement = prepare (sqlcmd, ":uri", typeof (string), uri); if (statement.step ()) { string title = statement.get_string ("title"); int64 date = statement.get_int64 ("date"); var item = new DatabaseItem (uri, title, date); item.database = this; item.id = statement.get_int64 ("rowid"); return item; } return null; } /* * Determine if the item is in the database, where the URI matches. */ public bool contains (DatabaseItem item) throws DatabaseError { string sqlcmd = """ SELECT uri FROM %s WHERE uri = :uri LIMIT 1 """.printf (table); DatabaseStatement statement; try { statement = prepare (sqlcmd, ":uri", typeof (string), item.uri); return statement.step (); } catch (Error error) { critical (_("Failed to select from %s: %s"), table, error.message); } return false; } /* * Query items from the database, matching filter if given. */ public async virtual List? query (string? filter=null, int64 max_items=15, Cancellable? cancellable=null) throws DatabaseError { string where = filter != null ? "WHERE uri LIKE :filter OR title LIKE :filter" : ""; string sqlcmd = """ SELECT rowid, uri, title, date, count (uri) AS ct FROM %s %s GROUP BY uri ORDER BY ct DESC LIMIT :limit """.printf (table, where); DatabaseStatement statement; try { statement = prepare (sqlcmd, ":limit", typeof (int64), max_items); if (filter != null) { string real_filter = "%" + filter.replace (" ", "%") + "%"; statement.bind (":filter", typeof (string), real_filter); } } catch (Error error) { critical (_("Failed to select from %s: %s"), table, error.message); return null; } var items = new List (); try { while (statement.step ()) { string uri = statement.get_string ("uri"); string title = statement.get_string ("title"); int64 date = statement.get_int64 ("date"); var item = new DatabaseItem (uri, title, date); item.database = this; item.id = statement.get_int64 ("rowid"); items.append (item); uint src = Idle.add (query.callback); yield; Source.remove (src); if (cancellable != null && cancellable.is_cancelled ()) return null; } } catch (Error error) { critical (_("Failed to select from %s: %s"), table, error.message); } if (cancellable != null && cancellable.is_cancelled ()) return null; return items; } /* * Update an existing item. */ public virtual async bool update (DatabaseItem item) throws DatabaseError { string sqlcmd = """ UPDATE %s SET uri=:uri, title=:title, date=:date WHERE rowid = :id """.printf (table); DatabaseStatement statement; try { statement = prepare (sqlcmd, ":id", typeof (int64), item.id, ":uri", typeof (string), item.uri, ":title", typeof (string), item.title, ":date", typeof (int64), item.date); if (statement.exec ()) { if (_items != null) { items_changed (_items.index (item), 0, 0); } return true; } } catch (Error error) { critical (_("Failed to update %s: %s"), table, error.message); } return false; } /* * Insert an item into the database. */ public virtual async bool insert (DatabaseItem item) throws DatabaseError { item.database = this; string sqlcmd = """ INSERT INTO %s (uri, title, date) VALUES (:uri, :title, :date) """.printf (table); var statement = prepare (sqlcmd, ":uri", typeof (string), item.uri, ":title", typeof (string), item.title, ":date", typeof (int64), item.date); if (statement.exec ()) { item.id = statement.row_id (); if (_items != null) { _items.append (item); items_changed (_items.index (item), 0, 1); } return true; } return false; } /* * Clear recent data produced in the given amount of time. */ public async virtual bool clear (TimeSpan timespan) throws DatabaseError { // Note: TimeSpan is defined in microseconds int64 maximum_age = new DateTime.now_local ().to_unix () - timespan / 1000000; string sqlcmd = """ DELETE FROM %s WHERE date <= :maximum_age """.printf (table); var statement = prepare (sqlcmd, ":maximum_age", typeof (int64), maximum_age); return statement.exec (); } /* * Delete data exceeding a maximum age (expiry date). */ internal async bool cap (int64 maximum_age) throws DatabaseError { unowned string sqlcmd = """ DELETE FROM %s WHERE date >= :maximum_age; """; var statement = prepare (sqlcmd, ":maximum_age", typeof (int64), maximum_age); return statement.exec (); } public Type get_item_type () { return typeof (DatabaseItem); } List? _items = null; public Object? get_item (uint position) { return _items.nth_data (position); } public uint get_n_items () { if (_items == null) { if (populate_cancellable != null) { populate_cancellable.cancel (); } populate_cancellable = new Cancellable (); populate.begin (populate_cancellable); return 0; } return _items.length (); } async void populate (Cancellable? cancellable) { try { uint old_length = _items.length (); _items = yield query (key); if (cancellable.is_cancelled ()) { _items = null; } else { items_changed (0, old_length, _items.length ()); } } catch (DatabaseError error) { debug ("Failed to populate: %s", error.message); } } } }