1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-24 10:27:10 +02:00
joplin/ReactNativeClient/lib/BaseModel.js

622 lines
17 KiB
JavaScript
Raw Normal View History

const { Database } = require('lib/database.js');
const uuid = require('lib/uuid').default;
const { time } = require('lib/time-utils.js');
const Mutex = require('async-mutex').Mutex;
2017-05-10 21:51:43 +02:00
2017-05-08 00:20:34 +02:00
class BaseModel {
2017-07-03 22:38:26 +02:00
static modelType() {
throw new Error('Must be overriden');
2017-07-03 22:38:26 +02:00
}
static tableName() {
throw new Error('Must be overriden');
2017-07-03 22:38:26 +02:00
}
static setDb(db) {
this.db_ = db;
}
2017-06-17 20:40:08 +02:00
static addModelMd(model) {
if (!model) return model;
2019-07-29 15:43:53 +02:00
2017-06-17 20:40:08 +02:00
if (Array.isArray(model)) {
const output = [];
2017-06-17 20:40:08 +02:00
for (let i = 0; i < model.length; i++) {
output.push(this.addModelMd(model[i]));
}
return output;
} else {
model = Object.assign({}, model);
2017-07-03 21:50:45 +02:00
model.type_ = this.modelType();
2017-06-17 20:40:08 +02:00
return model;
}
}
2017-06-25 12:41:03 +02:00
static logger() {
return this.db().logger();
}
2017-05-12 21:54:06 +02:00
static useUuid() {
return false;
}
2017-05-15 21:10:00 +02:00
static byId(items, id) {
for (let i = 0; i < items.length; i++) {
if (items[i].id == id) return items[i];
}
return null;
}
static defaultValues(fieldNames) {
const output = {};
for (const n of fieldNames) {
output[n] = this.db().fieldDefaultValue(this.tableName(), n);
}
return output;
}
static modelIndexById(items, id) {
for (let i = 0; i < items.length; i++) {
if (items[i].id == id) return i;
}
return -1;
}
static modelsByIds(items, ids) {
const output = [];
for (let i = 0; i < items.length; i++) {
if (ids.indexOf(items[i].id) >= 0) {
output.push(items[i]);
}
}
return output;
}
// Prefer the use of this function to compare IDs as it handles the case where
// one ID is null and the other is "", in which case they are actually considered to be the same.
static idsEqual(id1, id2) {
if (!id1 && !id2) return true;
if (!id1 && !!id2) return false;
if (!!id1 && !id2) return false;
return id1 === id2;
}
2018-02-27 22:51:07 +02:00
static modelTypeToName(type) {
for (let i = 0; i < BaseModel.typeEnum_.length; i++) {
const e = BaseModel.typeEnum_[i];
if (e[1] === type) return e[0].substr(5).toLowerCase();
}
2019-09-19 23:51:18 +02:00
throw new Error(`Unknown model type: ${type}`);
2018-02-27 22:51:07 +02:00
}
static modelNameToType(name) {
for (let i = 0; i < BaseModel.typeEnum_.length; i++) {
const e = BaseModel.typeEnum_[i];
const eName = e[0].substr(5).toLowerCase();
if (eName === name) return e[1];
}
throw new Error(`Unknown model name: ${name}`);
}
2017-05-19 21:32:49 +02:00
static hasField(name) {
const fields = this.fieldNames();
2017-05-19 21:32:49 +02:00
return fields.indexOf(name) >= 0;
}
static fieldNames(withPrefix = false) {
const output = this.db().tableFieldNames(this.tableName());
if (!withPrefix) return output;
2017-07-19 21:15:55 +02:00
const p = withPrefix === true ? this.tableName() : withPrefix;
const temp = [];
for (let i = 0; i < output.length; i++) {
2019-09-19 23:51:18 +02:00
temp.push(`${p}.${output[i]}`);
}
2017-07-19 21:15:55 +02:00
return temp;
2017-05-18 21:58:01 +02:00
}
static fieldType(name, defaultValue = null) {
const fields = this.fields();
2017-06-15 20:18:48 +02:00
for (let i = 0; i < fields.length; i++) {
if (fields[i].name == name) return fields[i].type;
}
if (defaultValue !== null) return defaultValue;
2019-09-19 23:51:18 +02:00
throw new Error(`Unknown field: ${name}`);
2017-06-15 20:18:48 +02:00
}
2017-05-20 00:16:50 +02:00
static fields() {
return this.db().tableFields(this.tableName());
}
static removeUnknownFields(model) {
const newModel = {};
for (const n in model) {
if (!model.hasOwnProperty(n)) continue;
if (!this.hasField(n) && n !== 'type_') continue;
newModel[n] = model[n];
}
return newModel;
}
2017-05-20 00:16:50 +02:00
static new() {
const fields = this.fields();
const output = {};
2017-05-20 00:16:50 +02:00
for (let i = 0; i < fields.length; i++) {
const f = fields[i];
2017-05-20 00:16:50 +02:00
output[f.name] = f.default;
}
return output;
}
2017-05-18 22:31:40 +02:00
static modOptions(options) {
if (!options) {
options = {};
} else {
options = Object.assign({}, options);
}
if (!('isNew' in options)) options.isNew = 'auto';
if (!('autoTimestamp' in options)) options.autoTimestamp = true;
if (!('userSideValidation' in options)) options.userSideValidation = false;
2017-05-18 22:31:40 +02:00
return options;
}
static count(options = null) {
if (!options) options = {};
2019-09-19 23:51:18 +02:00
let sql = `SELECT count(*) as total FROM \`${this.tableName()}\``;
if (options.where) sql += ` WHERE ${options.where}`;
2019-07-29 15:43:53 +02:00
return this.db()
.selectOne(sql)
.then(r => {
2019-07-29 15:43:53 +02:00
return r ? r['total'] : 0;
});
2017-06-24 19:40:03 +02:00
}
static load(id, options = null) {
return this.loadByField('id', id, options);
2017-06-11 23:11:14 +02:00
}
2017-07-17 20:46:09 +02:00
static shortId(id) {
2017-08-21 19:56:40 +02:00
return id.substr(0, 5);
2017-07-17 20:46:09 +02:00
}
static loadByPartialId(partialId) {
2019-09-19 23:51:18 +02:00
return this.modelSelectAll(`SELECT * FROM \`${this.tableName()}\` WHERE \`id\` LIKE ?`, [`${partialId}%`]);
}
2017-06-25 11:00:54 +02:00
static applySqlOptions(options, sql, params = null) {
if (!options) options = {};
if (options.order && options.order.length) {
const items = [];
for (let i = 0; i < options.order.length; i++) {
const o = options.order[i];
let item = `\`${o.by}\``;
if (options.caseInsensitive === true) item += ' COLLATE NOCASE';
2019-09-19 23:51:18 +02:00
if (o.dir) item += ` ${o.dir}`;
items.push(item);
}
2019-09-19 23:51:18 +02:00
sql += ` ORDER BY ${items.join(', ')}`;
2017-06-25 11:00:54 +02:00
}
2019-07-29 15:43:53 +02:00
2019-09-19 23:51:18 +02:00
if (options.limit) sql += ` LIMIT ${options.limit}`;
2017-06-25 11:00:54 +02:00
return { sql: sql, params: params };
}
2017-08-20 16:29:18 +02:00
static async allIds(options = null) {
const q = this.applySqlOptions(options, `SELECT id FROM \`${this.tableName()}\``);
2017-08-20 16:29:18 +02:00
const rows = await this.db().selectAll(q.sql, q.params);
return rows.map(r => r.id);
2017-08-20 16:29:18 +02:00
}
2017-07-03 20:58:01 +02:00
static async all(options = null) {
if (!options) options = {};
if (!options.fields) options.fields = '*';
let sql = `SELECT ${this.db().escapeFields(options.fields)} FROM \`${this.tableName()}\``;
let params = [];
if (options.where) {
sql += ` WHERE ${options.where}`;
if (options.whereParams) params = params.concat(options.whereParams);
}
const q = this.applySqlOptions(options, sql, params);
return this.modelSelectAll(q.sql, q.params);
2017-06-25 11:00:54 +02:00
}
static async byIds(ids, options = null) {
if (!ids.length) return [];
if (!options) options = {};
if (!options.fields) options.fields = '*';
2019-09-19 23:51:18 +02:00
let sql = `SELECT ${this.db().escapeFields(options.fields)} FROM \`${this.tableName()}\``;
sql += ` WHERE id IN ("${ids.join('","')}")`;
const q = this.applySqlOptions(options, sql);
return this.modelSelectAll(q.sql);
}
2017-07-03 20:58:01 +02:00
static async search(options = null) {
if (!options) options = {};
if (!options.fields) options.fields = '*';
2017-07-03 20:58:01 +02:00
const conditions = options.conditions ? options.conditions.slice(0) : [];
const params = options.conditionsParams ? options.conditionsParams.slice(0) : [];
2017-07-03 20:58:01 +02:00
if (options.titlePattern) {
const pattern = options.titlePattern.replace(/\*/g, '%');
conditions.push('title LIKE ?');
2017-07-03 20:58:01 +02:00
params.push(pattern);
}
if ('limit' in options && options.limit <= 0) return [];
2017-08-20 10:16:31 +02:00
2019-09-19 23:51:18 +02:00
let sql = `SELECT ${this.db().escapeFields(options.fields)} FROM \`${this.tableName()}\``;
if (conditions.length) sql += ` WHERE ${conditions.join(' AND ')}`;
2017-07-03 20:58:01 +02:00
const query = this.applySqlOptions(options, sql, params);
2017-07-03 20:58:01 +02:00
return this.modelSelectAll(query.sql, query.params);
}
2017-06-17 20:12:09 +02:00
static modelSelectOne(sql, params = null) {
if (params === null) params = [];
2019-07-29 15:43:53 +02:00
return this.db()
.selectOne(sql, params)
.then(model => {
2019-07-29 15:43:53 +02:00
return this.filter(this.addModelMd(model));
});
2017-06-17 20:12:09 +02:00
}
static modelSelectAll(sql, params = null) {
if (params === null) params = [];
2019-07-29 15:43:53 +02:00
return this.db()
.selectAll(sql, params)
.then(models => {
2019-07-29 15:43:53 +02:00
return this.filterArray(this.addModelMd(models));
});
2017-06-17 20:12:09 +02:00
}
static loadByField(fieldName, fieldValue, options = null) {
if (!options) options = {};
if (!('caseInsensitive' in options)) options.caseInsensitive = false;
if (!options.fields) options.fields = '*';
let sql = `SELECT ${this.db().escapeFields(options.fields)} FROM \`${this.tableName()}\` WHERE \`${fieldName}\` = ?`;
if (options.caseInsensitive) sql += ' COLLATE NOCASE';
return this.modelSelectOne(sql, [fieldValue]);
2017-05-19 21:12:09 +02:00
}
static loadByFields(fields, options = null) {
if (!options) options = {};
if (!('caseInsensitive' in options)) options.caseInsensitive = false;
if (!options.fields) options.fields = '*';
const whereSql = [];
const params = [];
for (const fieldName in fields) {
whereSql.push(`\`${fieldName}\` = ?`);
params.push(fields[fieldName]);
}
let sql = `SELECT ${this.db().escapeFields(options.fields)} FROM \`${this.tableName()}\` WHERE ${whereSql.join(' AND ')}`;
if (options.caseInsensitive) sql += ' COLLATE NOCASE';
return this.modelSelectOne(sql, params);
}
2017-07-02 17:46:03 +02:00
static loadByTitle(fieldValue) {
2019-09-19 23:51:18 +02:00
return this.modelSelectOne(`SELECT * FROM \`${this.tableName()}\` WHERE \`title\` = ?`, [fieldValue]);
2017-07-02 17:46:03 +02:00
}
static diffObjects(oldModel, newModel) {
const output = {};
const fields = this.diffObjectsFields(oldModel, newModel);
for (let i = 0; i < fields.length; i++) {
output[fields[i]] = newModel[fields[i]];
}
if ('type_' in newModel) output.type_ = newModel.type_;
return output;
}
static diffObjectsFields(oldModel, newModel) {
const output = [];
for (const n in newModel) {
2017-12-04 01:06:02 +02:00
if (!newModel.hasOwnProperty(n)) continue;
if (n == 'type_') continue;
if (!(n in oldModel) || newModel[n] !== oldModel[n]) {
output.push(n);
}
}
return output;
}
2017-12-04 01:06:02 +02:00
static modelsAreSame(oldModel, newModel) {
const diff = this.diffObjects(oldModel, newModel);
delete diff.type_;
return !Object.getOwnPropertyNames(diff).length;
}
static saveMutex(modelOrId) {
const noLockMutex = {
2019-07-29 15:43:53 +02:00
acquire: function() {
return null;
},
};
if (!modelOrId) return noLockMutex;
const modelId = typeof modelOrId === 'string' ? modelOrId : modelOrId.id;
if (!modelId) return noLockMutex;
let mutex = BaseModel.saveMutexes_[modelId];
if (mutex) return mutex;
mutex = new Mutex();
BaseModel.saveMutexes_[modelId] = mutex;
return mutex;
}
static releaseSaveMutex(modelOrId, release) {
if (!release) return;
if (!modelOrId) return release();
const modelId = typeof modelOrId === 'string' ? modelOrId : modelOrId.id;
if (!modelId) return release();
const mutex = BaseModel.saveMutexes_[modelId];
if (!mutex) return release();
delete BaseModel.saveMutexes_[modelId];
release();
}
2017-06-18 01:49:52 +02:00
static saveQuery(o, options) {
2019-07-29 15:43:53 +02:00
let temp = {};
const fieldNames = this.fieldNames();
2017-05-20 00:16:50 +02:00
for (let i = 0; i < fieldNames.length; i++) {
const n = fieldNames[i];
2017-05-20 00:16:50 +02:00
if (n in o) temp[n] = o[n];
}
// Remove fields that are not in the `fields` list, if provided.
// Note that things like update_time, user_updated_time will still
// be part of the final list of fields if autoTimestamp is on.
// id also will stay.
if (!options.isNew && options.fields) {
const filtered = {};
for (const k in temp) {
if (!temp.hasOwnProperty(k)) continue;
if (k !== 'id' && options.fields.indexOf(k) < 0) continue;
filtered[k] = temp[k];
}
temp = filtered;
}
2017-05-20 00:16:50 +02:00
o = temp;
2018-01-14 19:11:44 +02:00
let modelId = temp.id;
2017-06-18 01:49:52 +02:00
let query = {};
2017-05-12 21:54:06 +02:00
2017-08-20 22:11:32 +02:00
const timeNow = time.unixMs();
if (options.autoTimestamp && this.hasField('updated_time')) {
2017-08-20 22:11:32 +02:00
o.updated_time = timeNow;
}
// The purpose of user_updated_time is to allow the user to manually set the time of a note (in which case
// options.autoTimestamp will be `false`). However note that if the item is later changed, this timestamp
// will be set again to the current time.
//
// The technique to modify user_updated_time while keeping updated_time current (so that sync can happen) is to
// manually set updated_time when saving and to set autoTimestamp to false, for example:
// Note.save({ id: "...", updated_time: Date.now(), user_updated_time: 1436342618000 }, { autoTimestamp: false })
if (options.autoTimestamp && this.hasField('user_updated_time')) {
2017-08-20 22:11:32 +02:00
o.user_updated_time = timeNow;
2017-05-19 21:32:49 +02:00
}
2017-06-18 01:49:52 +02:00
if (options.isNew) {
2017-05-19 21:12:09 +02:00
if (this.useUuid() && !o.id) {
2017-06-25 01:19:11 +02:00
modelId = uuid.create();
o.id = modelId;
2017-05-18 21:58:01 +02:00
}
2017-05-19 21:32:49 +02:00
if (!o.created_time && this.hasField('created_time')) {
2017-08-20 22:11:32 +02:00
o.created_time = timeNow;
}
if (!o.user_created_time && this.hasField('user_created_time')) {
2017-10-22 19:12:16 +02:00
o.user_created_time = o.created_time ? o.created_time : timeNow;
}
if (!o.user_updated_time && this.hasField('user_updated_time')) {
2017-10-22 19:12:16 +02:00
o.user_updated_time = o.updated_time ? o.updated_time : timeNow;
2017-05-19 21:32:49 +02:00
}
2017-05-12 21:54:06 +02:00
query = Database.insertQuery(this.tableName(), o);
2017-05-11 22:14:01 +02:00
} else {
const where = { id: o.id };
const temp = Object.assign({}, o);
2017-05-12 21:54:06 +02:00
delete temp.id;
2017-05-12 21:54:06 +02:00
query = Database.updateQuery(this.tableName(), temp, where);
2017-05-11 22:14:01 +02:00
}
2017-05-12 21:54:06 +02:00
2017-06-25 01:19:11 +02:00
query.id = modelId;
2017-07-16 18:06:05 +02:00
query.modObject = o;
2017-05-18 21:58:01 +02:00
return query;
}
static userSideValidation(o) {
if (o.id && !o.id.match(/^[a-f0-9]{32}$/)) {
throw new Error('Validation error: ID must a 32-characters lowercase hexadecimal string');
}
const timestamps = ['user_updated_time', 'user_created_time'];
for (const k of timestamps) {
if ((k in o) && (typeof o[k] !== 'number' || isNaN(o[k]) || o[k] < 0)) throw new Error('Validation error: user_updated_time and user_created_time must be numbers greater than 0');
}
}
static async save(o, options = null) {
// When saving, there's a mutex per model ID. This is because the model returned from this function
// is basically its input `o` (instead of being read from the database, for performance reasons).
// This works well in general except if that model is saved simultaneously in two places. In that
// case, the output won't be up-to-date and would cause for example display issues with out-dated
// notes being displayed. This was an issue when notes were being synchronised while being decrypted
// at the same time.
const mutexRelease = await this.saveMutex(o).acquire();
2017-05-18 22:31:40 +02:00
options = this.modOptions(options);
2017-06-29 22:52:52 +02:00
options.isNew = this.isNew(o, options);
2017-05-18 21:58:01 +02:00
// Diff saving is an optimisation which takes a new version of the item and an old one,
// do a diff and save only this diff. IMPORTANT: When using this make sure that both
// models have been normalised using ItemClass.filter()
const isDiffSaving = options && options.oldItem && !options.isNew;
if (isDiffSaving) {
const newObject = BaseModel.diffObjects(options.oldItem, o);
newObject.type_ = o.type_;
newObject.id = o.id;
o = newObject;
}
2017-06-24 19:40:03 +02:00
o = this.filter(o);
if (options.userSideValidation) {
this.userSideValidation(o);
}
2017-06-11 23:11:14 +02:00
let queries = [];
const saveQuery = this.saveQuery(o, options);
const modelId = saveQuery.id;
2017-05-18 21:58:01 +02:00
2017-06-11 23:11:14 +02:00
queries.push(saveQuery);
if (options.nextQueries && options.nextQueries.length) {
queries = queries.concat(options.nextQueries);
}
let output = null;
try {
await this.db().transactionExecBatch(queries);
2017-05-18 21:58:01 +02:00
o = Object.assign({}, o);
if (modelId) o.id = modelId;
if ('updated_time' in saveQuery.modObject) o.updated_time = saveQuery.modObject.updated_time;
if ('created_time' in saveQuery.modObject) o.created_time = saveQuery.modObject.created_time;
if ('user_updated_time' in saveQuery.modObject) o.user_updated_time = saveQuery.modObject.user_updated_time;
if ('user_created_time' in saveQuery.modObject) o.user_created_time = saveQuery.modObject.user_created_time;
2017-06-17 20:40:08 +02:00
o = this.addModelMd(o);
if (isDiffSaving) {
for (const n in options.oldItem) {
if (!options.oldItem.hasOwnProperty(n)) continue;
if (n in o) continue;
o[n] = options.oldItem[n];
}
}
output = this.filter(o);
} finally {
this.releaseSaveMutex(o, mutexRelease);
}
return output;
2017-05-10 21:51:43 +02:00
}
2017-06-29 22:52:52 +02:00
static isNew(object, options) {
2019-07-29 15:43:53 +02:00
if (options && 'isNew' in options) {
2017-06-29 22:52:52 +02:00
// options.isNew can be "auto" too
if (options.isNew === true) return true;
if (options.isNew === false) return false;
}
return !object.id;
}
2017-06-24 19:40:03 +02:00
static filterArray(models) {
const output = [];
2017-06-24 19:40:03 +02:00
for (let i = 0; i < models.length; i++) {
output.push(this.filter(models[i]));
}
return output;
}
static filter(model) {
2017-06-27 01:20:01 +02:00
if (!model) return model;
const output = Object.assign({}, model);
for (const n in output) {
2017-06-27 01:20:01 +02:00
if (!output.hasOwnProperty(n)) continue;
2017-06-27 01:20:01 +02:00
// The SQLite database doesn't have booleans so cast everything to int
if (output[n] === true) {
output[n] = 1;
} else if (output[n] === false) {
2019-07-29 15:43:53 +02:00
output[n] = 0;
} else {
const t = this.fieldType(n, Database.TYPE_UNKNOWN);
if (t === Database.TYPE_INT) {
output[n] = !n ? 0 : parseInt(output[n], 10);
}
}
2017-06-27 01:20:01 +02:00
}
2019-07-29 15:43:53 +02:00
2017-06-27 01:20:01 +02:00
return output;
2017-06-24 19:40:03 +02:00
}
static delete(id) {
if (!id) throw new Error('Cannot delete object without an ID');
2019-09-19 23:51:18 +02:00
return this.db().exec(`DELETE FROM ${this.tableName()} WHERE id = ?`, [id]);
2017-05-16 22:25:19 +02:00
}
2017-07-11 20:17:23 +02:00
static batchDelete(ids, options = null) {
if (!ids.length) return;
2017-07-11 20:17:23 +02:00
options = this.modOptions(options);
const idFieldName = options.idFieldName ? options.idFieldName : 'id';
2019-09-19 23:51:18 +02:00
const sql = `DELETE FROM ${this.tableName()} WHERE ${idFieldName} IN ("${ids.join('","')}")`;
return this.db().exec(sql);
2019-07-29 15:43:53 +02:00
}
2017-07-11 20:17:23 +02:00
2017-05-10 21:51:43 +02:00
static db() {
if (!this.db_) throw new Error('Accessing database before it has been initialised');
2019-07-29 15:43:53 +02:00
return this.db_;
2017-05-10 21:51:43 +02:00
}
2017-07-25 20:36:52 +02:00
static isReady() {
return !!this.db_;
}
2017-05-08 00:20:34 +02:00
}
BaseModel.typeEnum_ = [
['TYPE_NOTE', 1],
['TYPE_FOLDER', 2],
['TYPE_SETTING', 3],
['TYPE_RESOURCE', 4],
['TYPE_TAG', 5],
['TYPE_NOTE_TAG', 6],
['TYPE_SEARCH', 7],
['TYPE_ALARM', 8],
['TYPE_MASTER_KEY', 9],
['TYPE_ITEM_CHANGE', 10],
['TYPE_NOTE_RESOURCE', 11],
['TYPE_RESOURCE_LOCAL_STATE', 12],
['TYPE_REVISION', 13],
['TYPE_MIGRATION', 14],
['TYPE_SMART_FILTER', 15],
['TYPE_COMMAND', 16],
];
2018-02-27 22:51:07 +02:00
for (let i = 0; i < BaseModel.typeEnum_.length; i++) {
const e = BaseModel.typeEnum_[i];
BaseModel[e[0]] = e[1];
}
2017-06-06 22:01:43 +02:00
BaseModel.db_ = null;
BaseModel.dispatch = function() {};
BaseModel.saveMutexes_ = {};
2017-06-06 22:01:43 +02:00
2019-07-29 15:43:53 +02:00
module.exports = BaseModel;