1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-15 09:04:04 +02:00
joplin/lib/database.js

501 lines
12 KiB
JavaScript
Raw Normal View History

2017-06-24 20:06:28 +02:00
import { uuid } from 'lib/uuid.js';
import { promiseChain } from 'lib/promise-utils.js';
import { Logger } from 'lib/logger.js'
2017-06-27 01:20:01 +02:00
import { time } from 'lib/time-utils.js'
2017-06-24 20:06:28 +02:00
import { _ } from 'lib/locale.js'
2017-06-27 01:20:01 +02:00
import { sprintf } from 'sprintf-js';
2017-05-07 23:02:17 +02:00
const structureSql = `
CREATE TABLE folders (
id TEXT PRIMARY KEY,
parent_id TEXT NOT NULL DEFAULT "",
title TEXT NOT NULL DEFAULT "",
created_time INT NOT NULL DEFAULT 0,
2017-06-04 15:30:12 +02:00
updated_time INT NOT NULL DEFAULT 0,
2017-06-25 09:52:25 +02:00
sync_time INT NOT NULL DEFAULT 0
2017-05-07 23:02:17 +02:00
);
CREATE TABLE notes (
id TEXT PRIMARY KEY,
parent_id TEXT NOT NULL DEFAULT "",
title TEXT NOT NULL DEFAULT "",
body TEXT NOT NULL DEFAULT "",
created_time INT NOT NULL DEFAULT 0,
updated_time INT NOT NULL DEFAULT 0,
2017-06-14 21:59:46 +02:00
sync_time INT NOT NULL DEFAULT 0,
2017-06-20 21:18:19 +02:00
is_conflict INT NOT NULL DEFAULT 0,
2017-05-07 23:02:17 +02:00
latitude NUMERIC NOT NULL DEFAULT 0,
longitude NUMERIC NOT NULL DEFAULT 0,
altitude NUMERIC NOT NULL DEFAULT 0,
author TEXT NOT NULL DEFAULT "",
source_url TEXT NOT NULL DEFAULT "",
2017-06-15 20:18:48 +02:00
is_todo INT NOT NULL DEFAULT 0,
2017-05-20 00:16:50 +02:00
todo_due INT NOT NULL DEFAULT 0,
2017-06-15 20:18:48 +02:00
todo_completed INT NOT NULL DEFAULT 0,
2017-06-25 01:19:11 +02:00
source TEXT NOT NULL DEFAULT "",
2017-05-07 23:02:17 +02:00
source_application TEXT NOT NULL DEFAULT "",
application_data TEXT NOT NULL DEFAULT "",
\`order\` INT NOT NULL DEFAULT 0
);
2017-06-20 00:18:24 +02:00
CREATE TABLE deleted_items (
2017-06-20 21:18:19 +02:00
id INTEGER PRIMARY KEY,
2017-06-20 00:18:24 +02:00
item_type INT NOT NULL,
item_id TEXT NOT NULL,
deleted_time INT NOT NULL
);
2017-05-07 23:02:17 +02:00
CREATE TABLE tags (
id TEXT PRIMARY KEY,
title TEXT,
created_time INT,
updated_time INT
);
CREATE TABLE note_tags (
id INTEGER PRIMARY KEY,
note_id TEXT,
tag_id TEXT
);
CREATE TABLE resources (
id TEXT PRIMARY KEY,
title TEXT,
mime TEXT,
filename TEXT,
created_time INT,
updated_time INT
);
CREATE TABLE note_resources (
id INTEGER PRIMARY KEY,
note_id TEXT,
resource_id TEXT
);
CREATE TABLE version (
version INT
);
CREATE TABLE changes (
2017-05-18 21:58:01 +02:00
id INTEGER PRIMARY KEY,
2017-05-07 23:02:17 +02:00
\`type\` INT,
item_id TEXT,
item_type INT,
item_field TEXT
);
CREATE TABLE settings (
2017-05-18 21:58:01 +02:00
\`key\` TEXT PRIMARY KEY,
2017-05-07 23:02:17 +02:00
\`value\` TEXT,
\`type\` INT
);
2017-05-18 21:58:01 +02:00
CREATE TABLE table_fields (
id INTEGER PRIMARY KEY,
table_name TEXT,
2017-05-20 00:16:50 +02:00
field_name TEXT,
field_type INT,
field_default TEXT
2017-05-18 21:58:01 +02:00
);
2017-06-14 21:59:46 +02:00
CREATE TABLE item_sync_times (
id INTEGER PRIMARY KEY,
item_id TEXT,
\`time\` INT
);
2017-05-07 23:02:17 +02:00
INSERT INTO version (version) VALUES (1);
`;
class Database {
2017-06-11 23:11:14 +02:00
constructor(driver) {
2017-05-11 22:14:01 +02:00
this.debugMode_ = false;
2017-05-12 22:17:23 +02:00
this.initialized_ = false;
2017-05-18 21:58:01 +02:00
this.tableFields_ = null;
2017-06-11 23:11:14 +02:00
this.driver_ = driver;
2017-06-15 01:14:15 +02:00
this.inTransaction_ = false;
2017-06-23 23:32:24 +02:00
this.logger_ = new Logger();
this.logger_.addTarget('console');
this.logger_.setLevel(Logger.LEVEL_DEBUG);
}
// Converts the SQLite error to a regular JS error
// so that it prints a stacktrace when passed to
// console.error()
sqliteErrorToJsError(error, sql, params = null) {
let msg = sql;
if (params) msg += ': ' + JSON.stringify(params);
msg += ': ' + error.toString();
let output = new Error(msg);
if (error.code) output.code = error.code;
return output;
}
setLogger(l) {
this.logger_ = l;
}
logger() {
return this.logger_;
2017-05-11 22:14:01 +02:00
}
2017-05-07 23:02:17 +02:00
2017-06-25 17:17:40 +02:00
// setDebugMode(v) {
// //this.driver_.setDebugMode(v);
// this.debugMode_ = v;
// }
2017-05-11 22:14:01 +02:00
2017-06-25 17:17:40 +02:00
// debugMode() {
// return this.debugMode_;
// }
2017-05-07 23:02:17 +02:00
2017-05-12 22:17:23 +02:00
initialized() {
return this.initialized_;
}
2017-06-11 23:11:14 +02:00
driver() {
return this.driver_;
}
open(options) {
return this.driver().open(options).then((db) => {
2017-06-23 23:32:24 +02:00
this.logger().info('Database was open successfully');
2017-06-11 23:11:14 +02:00
return this.initialize();
}).catch((error) => {
2017-06-25 01:19:11 +02:00
this.logger().error('Cannot open database:');
this.logger().error(error);
2017-05-07 23:02:17 +02:00
});
2017-06-11 23:11:14 +02:00
}
2017-06-25 14:49:46 +02:00
escapeField(field) {
return '`' + field + '`';
}
escapeFields(fields) {
let output = [];
for (let i = 0; i < fields.length; i++) {
output.push(this.escapeField(fields[i]));
}
return output;
}
2017-06-11 23:11:14 +02:00
selectOne(sql, params = null) {
this.logQuery(sql, params);
2017-06-23 23:32:24 +02:00
return this.driver().selectOne(sql, params).catch((error) => {
throw this.sqliteErrorToJsError(error, sql, params);
});
2017-06-11 23:11:14 +02:00
}
selectAll(sql, params = null) {
this.logQuery(sql, params);
2017-06-23 23:32:24 +02:00
return this.driver().selectAll(sql, params).catch((error) => {
throw this.sqliteErrorToJsError(error, sql, params);
});
2017-06-11 23:11:14 +02:00
}
2017-06-27 01:20:01 +02:00
async exec(sql, params = null) {
let result = null;
let waitTime = 50;
let totalWaitTime = 0;
while (true) {
try {
this.logQuery(sql, params);
let result = await this.driver().exec(sql, params);
return result;; // No exception was thrown
} catch (error) {
throw error;
if (error && error.code == 'SQLITE_IOERR') {
if (totalWaitTime >= 20000) throw error;
this.logger().warn(sprintf('SQLITE_IOERR: will retry in %s milliseconds', waitTime));
this.logger().warn('Error was: ' + error.toString());
await time.msleep(waitTime);
totalWaitTime += waitTime;
waitTime *= 1.5;
} else {
throw this.sqliteErrorToJsError(error, sql, params);
}
}
}
2017-06-11 23:11:14 +02:00
}
2017-05-07 23:02:17 +02:00
2017-06-11 23:11:14 +02:00
transactionExecBatch(queries) {
2017-06-15 01:14:15 +02:00
if (queries.length <= 0) return Promise.resolve();
if (queries.length == 1) {
2017-06-25 01:19:11 +02:00
let q = this.wrapQuery(queries[0]);
return this.exec(q.sql, q.params);
2017-06-15 01:14:15 +02:00
}
// There can be only one transaction running at a time so queue
// any new transaction here.
if (this.inTransaction_) {
return new Promise((resolve, reject) => {
let iid = setInterval(() => {
if (!this.inTransaction_) {
clearInterval(iid);
this.transactionExecBatch(queries).then(() => {
resolve();
}).catch((error) => {
reject(error);
});
}
}, 100);
});
}
this.inTransaction_ = true;
2017-06-14 21:59:46 +02:00
queries.splice(0, 0, 'BEGIN TRANSACTION');
queries.push('COMMIT'); // Note: ROLLBACK is currently not supported
2017-06-11 23:11:14 +02:00
let chain = [];
for (let i = 0; i < queries.length; i++) {
let query = this.wrapQuery(queries[i]);
chain.push(() => {
return this.exec(query.sql, query.params);
});
}
2017-06-14 21:59:46 +02:00
2017-06-15 01:14:15 +02:00
return promiseChain(chain).then(() => {
this.inTransaction_ = false;
});
2017-05-12 22:17:23 +02:00
}
2017-05-20 00:16:50 +02:00
static enumId(type, s) {
2017-05-12 22:17:23 +02:00
if (type == 'settings') {
if (s == 'int') return 1;
if (s == 'string') return 2;
}
2017-05-20 00:16:50 +02:00
if (type == 'fieldType') {
return this['TYPE_' + s];
}
2017-05-12 22:17:23 +02:00
throw new Error('Unknown enum type or value: ' + type + ', ' + s);
2017-05-07 23:02:17 +02:00
}
2017-05-18 21:58:01 +02:00
tableFieldNames(tableName) {
2017-05-20 00:16:50 +02:00
let tf = this.tableFields(tableName);
let output = [];
for (let i = 0; i < tf.length; i++) {
output.push(tf[i].name);
}
return output;
}
tableFields(tableName) {
2017-05-18 21:58:01 +02:00
if (!this.tableFields_) throw new Error('Fields have not been loaded yet');
if (!this.tableFields_[tableName]) throw new Error('Unknown table: ' + tableName);
return this.tableFields_[tableName];
}
2017-05-20 00:16:50 +02:00
static formatValue(type, value) {
if (value === null || value === undefined) return null;
if (type == this.TYPE_INT) return Number(value);
if (type == this.TYPE_TEXT) return value;
if (type == this.TYPE_NUMERIC) return Number(value);
throw new Error('Unknown type: ' + type);
}
2017-05-07 23:02:17 +02:00
sqlStringToLines(sql) {
let output = [];
let lines = sql.split("\n");
let statement = '';
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line == '') continue;
if (line.substr(0, 2) == "--") continue;
statement += line;
if (line[line.length - 1] == ';') {
output.push(statement);
statement = '';
}
}
return output;
}
2017-05-11 22:14:01 +02:00
logQuery(sql, params = null) {
2017-06-25 13:39:42 +02:00
this.logger().debug(sql);
2017-06-25 14:49:46 +02:00
if (params !== null && params.length) this.logger().debug(JSON.stringify(params));
2017-05-18 22:31:40 +02:00
}
2017-05-11 22:14:01 +02:00
static insertQuery(tableName, data) {
2017-06-18 01:49:52 +02:00
if (!data || !Object.keys(data).length) throw new Error('Data is empty');
2017-05-10 21:51:43 +02:00
let keySql= '';
let valueSql = '';
2017-05-11 22:14:01 +02:00
let params = [];
2017-05-10 21:51:43 +02:00
for (let key in data) {
2017-05-11 22:14:01 +02:00
if (!data.hasOwnProperty(key)) continue;
2017-06-20 21:18:19 +02:00
if (key[key.length - 1] == '_') continue;
2017-05-10 21:51:43 +02:00
if (keySql != '') keySql += ', ';
if (valueSql != '') valueSql += ', ';
2017-05-11 22:14:01 +02:00
keySql += '`' + key + '`';
2017-05-10 21:51:43 +02:00
valueSql += '?';
2017-05-11 22:14:01 +02:00
params.push(data[key]);
2017-05-10 21:51:43 +02:00
}
2017-05-11 22:14:01 +02:00
return {
sql: 'INSERT INTO `' + tableName + '` (' + keySql + ') VALUES (' + valueSql + ')',
params: params,
};
2017-05-10 21:51:43 +02:00
}
2017-05-12 21:54:06 +02:00
static updateQuery(tableName, data, where) {
2017-06-18 01:49:52 +02:00
if (!data || !Object.keys(data).length) throw new Error('Data is empty');
2017-05-12 21:54:06 +02:00
let sql = '';
let params = [];
for (let key in data) {
if (!data.hasOwnProperty(key)) continue;
2017-06-20 21:18:19 +02:00
if (key[key.length - 1] == '_') continue;
2017-05-12 21:54:06 +02:00
if (sql != '') sql += ', ';
2017-05-20 00:16:50 +02:00
sql += '`' + key + '`=?';
2017-05-12 21:54:06 +02:00
params.push(data[key]);
}
if (typeof where != 'string') {
params.push(where.id);
where = 'id=?';
}
return {
sql: 'UPDATE `' + tableName + '` SET ' + sql + ' WHERE ' + where,
params: params,
};
}
2017-06-14 21:59:46 +02:00
2017-06-11 23:11:14 +02:00
wrapQueries(queries) {
let output = [];
for (let i = 0; i < queries.length; i++) {
output.push(this.wrapQuery(queries[i]));
}
return output;
}
wrapQuery(sql, params = null) {
if (!sql) throw new Error('Cannot wrap empty string: ' + sql);
if (sql.constructor === Array) {
let output = {};
output.sql = sql[0];
output.params = sql.length >= 2 ? sql[1] : null;
return output;
} else if (typeof sql === 'string') {
return { sql: sql, params: params };
} else {
return sql; // Already wrapped
}
2017-05-12 22:17:23 +02:00
}
2017-05-18 21:58:01 +02:00
refreshTableFields() {
2017-06-23 23:32:24 +02:00
this.logger().info('Initializing tables...');
2017-06-11 23:11:14 +02:00
let queries = [];
queries.push(this.wrapQuery('DELETE FROM table_fields'));
return this.selectAll('SELECT name FROM sqlite_master WHERE type="table"').then((tableRows) => {
2017-05-18 21:58:01 +02:00
let chain = [];
2017-06-11 23:11:14 +02:00
for (let i = 0; i < tableRows.length; i++) {
let tableName = tableRows[i].name;
2017-05-18 21:58:01 +02:00
if (tableName == 'android_metadata') continue;
if (tableName == 'table_fields') continue;
2017-06-11 23:11:14 +02:00
chain.push(() => {
return this.selectAll('PRAGMA table_info("' + tableName + '")').then((pragmas) => {
for (let i = 0; i < pragmas.length; i++) {
let item = pragmas[i];
2017-05-20 00:16:50 +02:00
// In SQLite, if the default value is a string it has double quotes around it, so remove them here
let defaultValue = item.dflt_value;
if (typeof defaultValue == 'string' && defaultValue.length >= 2 && defaultValue[0] == '"' && defaultValue[defaultValue.length - 1] == '"') {
defaultValue = defaultValue.substr(1, defaultValue.length - 2);
}
2017-05-18 21:58:01 +02:00
let q = Database.insertQuery('table_fields', {
table_name: tableName,
2017-05-20 00:16:50 +02:00
field_name: item.name,
field_type: Database.enumId('fieldType', item.type),
field_default: defaultValue,
2017-05-18 21:58:01 +02:00
});
queries.push(q);
}
});
});
}
2017-05-07 23:02:17 +02:00
2017-05-18 22:31:40 +02:00
return promiseChain(chain);
2017-06-11 23:11:14 +02:00
}).then(() => {
return this.transactionExecBatch(queries);
2017-05-07 23:02:17 +02:00
});
}
2017-06-27 22:16:03 +02:00
static defaultFolderData() {
let now = time.unixMs();
return {
id: uuid.create(),
title: _('Notebook'),
created_time: now,
updated_time: now,
};
}
static defaultFolderQuery() {
return Database.insertQuery('folders', this.defaultFolderData());
}
2017-05-18 21:58:01 +02:00
initialize() {
2017-06-23 23:32:24 +02:00
this.logger().info('Checking for database schema update...');
2017-05-18 21:58:01 +02:00
return this.selectOne('SELECT * FROM version LIMIT 1').then((row) => {
2017-06-23 23:32:24 +02:00
this.logger().info('Current database version', row);
2017-05-18 21:58:01 +02:00
// TODO: version update logic
// TODO: only do this if db has been updated:
2017-06-11 23:11:14 +02:00
// return this.refreshTableFields();
2017-05-18 21:58:01 +02:00
}).then(() => {
2017-06-11 23:11:14 +02:00
this.tableFields_ = {};
return this.selectAll('SELECT * FROM table_fields').then((rows) => {
for (let i = 0; i < rows.length; i++) {
let row = rows[i];
2017-05-18 21:58:01 +02:00
if (!this.tableFields_[row.table_name]) this.tableFields_[row.table_name] = [];
2017-05-20 00:16:50 +02:00
this.tableFields_[row.table_name].push({
name: row.field_name,
type: row.field_type,
default: Database.formatValue(row.field_type, row.field_default),
});
2017-05-18 21:58:01 +02:00
}
});
}).catch((error) => {
2017-06-11 23:11:14 +02:00
if (error && error.code != 0 && error.code != 'SQLITE_ERROR') {
2017-06-23 23:32:24 +02:00
this.logger().error(error);
2017-05-20 00:16:50 +02:00
return;
}
2017-05-18 21:58:01 +02:00
// Assume that error was:
// { message: 'no such table: version (code 1): , while compiling: SELECT * FROM version', code: 0 }
// which means the database is empty and the tables need to be created.
// If it's any other error there's nothing we can do anyway.
2017-06-23 23:32:24 +02:00
this.logger().info('Database is new - creating the schema...');
2017-05-18 21:58:01 +02:00
2017-06-11 23:11:14 +02:00
let queries = this.wrapQueries(this.sqlStringToLines(structureSql));
queries.push(this.wrapQuery('INSERT INTO settings (`key`, `value`, `type`) VALUES ("clientId", "' + uuid.create() + '", "' + Database.enumId('settings', 'string') + '")'));
2017-06-27 22:16:03 +02:00
queries.push(Database.defaultFolderQuery());
2017-06-11 23:11:14 +02:00
return this.transactionExecBatch(queries).then(() => {
2017-06-23 23:32:24 +02:00
this.logger().info('Database schema created successfully');
2017-05-18 21:58:01 +02:00
// Calling initialize() now that the db has been created will make it go through
// the normal db update process (applying any additional patch).
2017-06-11 23:11:14 +02:00
return this.refreshTableFields();
}).then(() => {
2017-05-18 21:58:01 +02:00
return this.initialize();
2017-06-11 23:11:14 +02:00
});
2017-05-18 21:58:01 +02:00
});
}
2017-05-07 23:02:17 +02:00
}
Database.TYPE_INT = 1;
Database.TYPE_TEXT = 2;
2017-06-15 20:18:48 +02:00
Database.TYPE_NUMERIC = 3;
2017-05-07 23:02:17 +02:00
export { Database };