diff --git a/SQLiteDB/LibSQLite.ts b/SQLiteDB/LibSQLite.ts index 506394d..1c8a7f6 100644 --- a/SQLiteDB/LibSQLite.ts +++ b/SQLiteDB/LibSQLite.ts @@ -1,25 +1,301 @@ namespace OS { export namespace API { - export class SQLiteDBBase { + /** + * Generate SQL expression from input object + * + * Example of input object + * ```ts + * { + * where: { + * id$gte: 10, + * user: "dany'", + * $or: { + * 'user.email': "test@mail.com", + * age$lte: 30, + * $and: { + * 'user.birth$ne': 1986, + * age$not_between: [20,30], + * name$not_like: "%LE" + * } + * } + * }, + * fields: ['name as n', 'id', 'email'], + * order: ['user.name$asc', "id$desc"], + * joins: { + * cid: 'Category.id', + * did: 'Country.id' + * } + *} + * ``` + * This will generate the followings expressions: + * - `( self.name as n,self.id,self.email )` for fields + * - condition: + * ``` + * ( + * ( self.id >= 10 ) AND + * ( self.user = 'dany''' ) AND + * ( + * ( user.email = 'test@mail.com' ) OR + * ( self.age <= 30 ) OR + * ( + * ( user.birth != 1986 ) AND + * ( self.age NOT BETWEEN 20 AND 30 ) AND + * ( self.name NOT LIKE '%LE' ) + * ) + * ) + * ) + * ``` + * - order: `user.name ASC,self.id DESC` + * - joining: + * ``` + * INNER JOIN Category ON self.cid = Category.id + * INNER JOIN Country ON self.did = Country.id + * ``` + * + */ + class SQLiteQueryGenerator { + private _where: string; + private _fields: string; + private _order: string; + private _joins: string; + private _is_joining: boolean; + constructor(obj: GenericObject) + { + this._where = undefined; + this._fields = undefined; + this._order = undefined; + this._joins = undefined; + this._is_joining = false; + if(obj.joins) + { + this._is_joining = true; + this._joins = this.joins(obj.joins); + } + + if(obj.where) + { + this._where = this.where("$and", obj.where); + } + if(obj.fields) + { + this._fields = `( ${obj.fields.map(v=>this.infer_field(v)).join(",")} )`; + } + if(obj.order) + { + this._order = this.order_by(obj.order); + } + } + + private infer_field(k: string) : string + { + if(!this._is_joining || k.indexOf(".") > 0) return k; + return `self.${k}`; + } + + private joins(data: GenericObject): string + { + let joins_arr = []; + for(let k in data) + { + let v = data[k]; + let arr = v.split('.') + if(arr.length != 2) + { + throw new Error(__("Other table name parsing error: {0}", v).__()); + } + joins_arr.push(`INNER JOIN ${arr[0]} ON ${this.infer_field(k)} = ${v}`); + } + return joins_arr.join(" "); + } + + print() + { + console.log(this._fields); + console.log(this._where); + console.log(this._order); + console.log(this._joins); + } + + private order_by(order: string[]): string + { + if(! Array.isArray(order)) + { + throw new Error(__("Invalid type: expect array get {0}", typeof(order)).__()); + + } + return order.map((v,_) => { + const arr = v.split('$'); + if(arr.length != 2) + { + throw new Error(__("Invalid field order format {0}", v).__()); + } + switch(arr[1]) + { + case 'asc': return `${this.infer_field(arr[0])} ASC`; + case 'desc': return `${this.infer_field(arr[0])} DESC`; + default: throw new Error(__("Invalid field order type {0}", v).__()); + } + }).join(","); + } + + private escape_string(s: string) + { + let regex = /[']/g; + var chunkIndex = regex.lastIndex = 0; + var escapedVal = ''; + var match; + + while ((match = regex.exec(s))) { + escapedVal += s.slice(chunkIndex, match.index) + {'\'': '\'\''}[match[0]]; + chunkIndex = regex.lastIndex; + } + + if (chunkIndex === 0) { + // Nothing was escaped + return "'" + s + "'"; + } + + if (chunkIndex < s.length) { + return "'" + escapedVal + s.slice(chunkIndex) + "'"; + } + + return "'" + escapedVal + "'"; + } + + private parse_value(v:any, t: string[]): string + { + if(! (t as any).includes(typeof(v))) + { + throw new Error(__("Invalid type: expect [{0}] get {1}", t.join(","), typeof(v)).__()); + } + switch(typeof(v)) + { + case 'number': return JSON.stringify(v); + case 'string': return this.escape_string(v); + default: throw new Error(__("Un supported value {0} of type {1}", v, typeof(v)).__()); + } + } + + private binary(k: string,v :any) + { + const arr = k.split("$"); + if(arr.length > 2) + { + throw new Error(__("Invalid left hand side format: {0}", k).__()); + } + if(arr.length == 2) + { + switch(arr[1]) + { + case "gt": + return `( ${this.infer_field(arr[0])} > ${this.parse_value(v, ['number'])} )`; + case "gte": + return `( ${this.infer_field(arr[0])} >= ${this.parse_value(v, ['number'])} )`; + case "lt": + return `( ${this.infer_field(arr[0])} < ${this.parse_value(v, ['number'])} )`; + case "lte": + return `( ${this.infer_field(arr[0])} <= ${this.parse_value(v, ['number'])} )`; + case "ne": + return `( ${this.infer_field(arr[0])} != ${this.parse_value(v, ['number', 'string'])} )`; + case "between": + return `( ${this.infer_field(arr[0])} BETWEEN ${this.parse_value(v[0],['number'])} AND ${this.parse_value(v[1],['number'])} )`; + case "not_between": + return `( ${this.infer_field(arr[0])} NOT BETWEEN ${this.parse_value(v[0],['number'])} AND ${this.parse_value(v[1],['number'])} )`; + case "in": + return `( ${this.infer_field(arr[0])} IN [${this.parse_value(v[0],['number'])}, ${this.parse_value(v[1],['number'])}] )`; + case "not_in": + return `( ${this.infer_field(arr[0])} NOT IN [${this.parse_value(v[0],['number'])}, ${this.parse_value(v[1],['number'])}] )`; + case "like": + return `( ${this.infer_field(arr[0])} LIKE ${this.parse_value(v,['string'])} )`; + case "not_like": + return `( ${this.infer_field(arr[0])} NOT LIKE ${this.parse_value(v,['string'])} )`; + default: throw new Error(__("Unsupported operator `{0}`", arr[1]).__()); + } + } + else + { + return `( ${this.infer_field(arr[0])} = ${this.parse_value(v, ['number', 'string'])} )`; + } + + } + + private where(op:string, obj: GenericObject): string + { + let join_op = undefined; + switch(op) + { + case "$and": + join_op = " AND "; + break; + case "$or": + join_op = " OR "; + break; + default: + throw new Error(__("Invalid operator {0}", op).__()); + } + + if(typeof obj !== "object") + { + throw new Error(__("Invalid input data for operator {0}", op).__()); + } + + let arr = []; + for(let k in obj){ + if(k == "$and" || k=="$or") + { + arr.push(this.where(k, obj[k])); + } + else + { + arr.push(this.binary(k, obj[k])); + } + } + return `( ${arr.join(join_op)} )`; + } + } + class SQLiteDBCore { + static REGISTY: GenericObject; + private db_file: VFS.BaseFileHandle; constructor(path: VFS.BaseFileHandle | string) { + if(!SQLiteDBCore.REGISTY) + { + SQLiteDBCore.REGISTY = {}; + } this.db_file = path.asFileHandle(); + if(SQLiteDBCore.REGISTY[this.db_file.path]) + { + this.db_file = SQLiteDBCore.REGISTY[this.db_file.path]; + } + else + { + SQLiteDBCore.REGISTY[this.db_file.path] = this.db_file; + } } private pwd(): VFS.BaseFileHandle { return "pkg://SQLiteDB/".asFileHandle(); } + + fileinfo(): FileInfoType + { + return this.db_file.info; + } /** * init and create the db file if it does not exist */ - private init(): Promise + init(): Promise { return new Promise(async (ok, reject) => { try{ + if(this.db_file.ready) + { + return ok(true); + } let request = { action: 'init', args: { @@ -77,43 +353,92 @@ namespace OS { } }); } - query(sql: string): Promise - { - let rq = { - action: 'query', - args: { - query: sql - } - } - return this.request(rq); - } - select(table: string, fields: string[], condition: string): Promise[]> + select(filter: GenericObject): Promise { let rq = { action: 'select', args: { - table: table, - fields: fields.join(","), - cond: condition + filter } } return this.request(rq); } - list_tables(): Promise + delete_records(filter: GenericObject): Promise { - return new Promise(async (ok, reject) => { - try { - let result = await this.select( - "sqlite_master", ["name"], "type ='table'"); - return ok(result.map((e) => e.name)) + let rq = { + action: 'delete_records', + args: { + filter } - catch(e) - { - reject(__e(e)) + } + return this.request(rq); + } + + drop_table(table_name: string): Promise + { + let rq = { + action: 'drop_table', + args:{table_name} + } + return this.request(rq); + } + + list_tables(): Promise + { + let rq = { + action: 'list_table', + args: {} + } + return this.request(rq); + } + + create_table(table, scheme): Promise + { + let rq = { + action: 'create_table', + args: { + table_name: table, + scheme } - }); + } + return this.request(rq); + } + + get_table_scheme(table_name:string): Promise + { + let rq = { + action: 'table_scheme', + args: { + table_name + } + } + return this.request(rq); + } + + insert(table_name:string, record: GenericObject): Promise + { + let rq = { + action: 'insert', + args: { + table_name, + record + } + } + return this.request(rq); + } + + update(table_name:string, record: GenericObject): Promise + { + let rq = { + action: 'update', + args: { + table_name, + record + } + } + return this.request(rq); } last_insert_id(): Promise @@ -126,5 +451,260 @@ namespace OS { return this.request(rq); } } + export namespace VFS { + /** + * SQLite VFS handle for database accessing + * + * A Sqlite file handle shall be in the following formats: + * * `sqlite://remote/path/to/file.db` refers to the entire databale (`remote/path/to/file.db` is relative to the home folder) + * - read operation, will list all available tables + * - write operations will create table + * - rm operation will delete table + * - meta operation will return file info + * - other operations are not supported + * * `sqlite://remote/path/to/file.db@table_name` refers to the table `table_name` in the database + * - meta operation will return fileinfo with table scheme information + * - read operation will read all records by filter defined by the filter operation + * - write operations will insert a new record + * - rm operation will delete records by filter defined by the filter operation + * - filter operation sets the filter for the table + * - other operations are not supported + * - `sqlite://remote/path/to/file.db@table_name@id` refers to a records in `table_name` with ID `id` + * - read operation will read the current record + * - write operation will update current record + * - rm operation will delete current record + * - other operations are not supported + * + * Some example of filters: + * ```ts + * handle.filter = (filter) => { + * filter.fields() + * } + * ``` + * + * @class SqliteFileHandle + * @extends {BaseFileHandle} + */ + class SqliteFileHandle extends BaseFileHandle + { + private _handle: SQLiteDBCore; + private _table_name: string; + private _id: number; + + /** + * Set a file path to the current file handle + * + * + * @param {string} p + * @returns {void} + * @memberof SqliteFileHandle + */ + setPath(p: string): void { + let arr = p.split("@"); + super.setPath(arr[0]); + if(arr.length > 3) + { + throw new Error(__("Invalid file path").__()); + } + this.path = p; + this._table_name = arr[1]; + this._id = arr[2] ? parseInt(arr[2]) : undefined; + this._handle = new SQLiteDBCore(`home://${this.genealogy.join("/")}`); + } + + /** + * Read database file meta-data + * + * Return file info on the target database file, if the table_name is specified + * return also the table scheme + * + * @returns {Promise} + * @memberof SqliteFileHandle + */ + meta(): Promise { + return new Promise(async (resolve, reject) => { + try { + await this._handle.init(); + + const d = {result: this._handle.fileinfo(), error: false}; + if(this._table_name) + { + const data = await this._handle.get_table_scheme(this._table_name); + if(data.length == 0) + { + d.result.scheme = undefined + } + else + { + d.result.scheme = {} + for(let v of data) + { + d.result.scheme[v.name] = v.type; + } + } + } + return resolve(d); + } catch (e) { + return reject(__e(e)); + } + }); + } + + /** + * Query the database based on the provided info + * + * If no table is provided, return list of tables in the + * data base. + * If the current table is specified: + * - if the record id is specfied return the record + * - otherwise, return the records in the table using the specified filter + * + * @protected + * @param {any} t filter type + * @returns {Promise} + * @memberof SqliteFileHandle + */ + protected _rd(user_data: any): Promise { + return new Promise(async (resolve, reject) => { + try{ + if(this._table_name && ! this.info.scheme) + { + throw new Error(__("Table `{0}` does not exists in database: {1}", this._table_name, this.path).__()); + } + if(!this._table_name) + { + // return list of tables in form of data base file handles in ready mode + let list = await this._handle.list_tables(); + const map = {} as GenericObject; + for(let v of list) + { + map[v.name] = `${this.path}@${v.name}`.asFileHandle(); + } + this.cache = map; + resolve(map); + } + else + { + // return all the data in the table set by the filter + // if this is a table, return the filtered records + // otherwise, it is a record, fetch only that record + let filter = user_data; + if(!filter || this._id) + { + filter = {}; + } + filter.table_name = this._table_name; + if(this._id) + { + filter.where = { id: this._id}; + } + let data = await this._handle.select(filter); + if(this._id) + { + this.cache = data[0]; + } + else + { + this.cache = data; + } + resolve(this.cache) + } + } + catch (e) { + return reject(__e(e)); + } + }); + } + + /** + * Write commit file cache to the remote database + * + * @protected + * @param {string} t is table name, used only when create table + * @returns {Promise} + * @memberof SqliteFileHandle + */ + protected _wr(t: string): Promise { + return new Promise(async (resolve, reject) => { + try{ + if(!this.cache) + { + throw new Error(__("No data to submit to remote database, please check the `cache` field").__()); + } + if(this._id && this._table_name) + { + this.cache.id = this._id; + const ret = await this._handle.update(this._table_name, this.cache); + resolve({result:ret, error: false}); + return + } + + if(this._table_name) + { + const ret = await this._handle.insert(this._table_name, this.cache); + resolve({result:ret, error: false}); + return + } + // create a new table with the scheme provided in the cache + let r = await this._handle.create_table(t, this.cache); + resolve({result: r, error: false}); + + } + catch (e) { + return reject(__e(e)); + } + }); + } + + /** + * Delete data from remote database + * + * @protected + * @param {any} user_data is table name, for delete table, otherwise, filter object for deleting records + * @returns {Promise} + * @memberof SqliteFileHandle + */ + protected _rm(user_data: any): Promise { + return new Promise(async (resolve, reject) => { + try { + if(this._table_name && ! this.info.scheme) + { + throw new Error(__("Table `{0}` does not exists in database: {1}", this._table_name, this.path).__()); + } + if(!this._table_name) + { + let table_name = user_data as string; + if (!table_name) + { + throw new Error(__("No table specified for dropping").__()); + } + let ret = await this._handle.drop_table(table_name); + resolve({result: ret, error: false}); + // delete the table + } + else + { + let filter = user_data as GenericObject; + // delete the records in the table using the filter + if(!filter || this._id) + { + filter = {}; + } + filter.table_name = this._table_name; + if(this._id) + { + filter.where = { id: this._id}; + } + let ret = await this._handle.delete_records(filter); + resolve({result: ret, error: false}); + } + } catch (e) { + return reject(__e(e)); + } + }); + } + } + register("^sqlite$", SqliteFileHandle); + } } } \ No newline at end of file diff --git a/SQLiteDB/README.md b/SQLiteDB/README.md index 76e9d49..819ff91 100644 --- a/SQLiteDB/README.md +++ b/SQLiteDB/README.md @@ -1,15 +1,3 @@ # SQLiteDB -This is an example project, generated by AntOS Development Kit -## Howto -Use the Antedit command palette to access to the SDK functionalities: - -1. Create new project -2. Init the project from the current folder located in side bar -3. Build and run the project -4. Release the project in zip package - -## Set up build target - -Open the `build.json` file from the current project tree and add/remove -build target entries and jobs. Save the file \ No newline at end of file +"mimes":["application/vnd.sqlite3"], diff --git a/SQLiteDB/api/api.lua b/SQLiteDB/api/api.lua index 3b581ff..d623f64 100644 --- a/SQLiteDB/api/api.lua +++ b/SQLiteDB/api/api.lua @@ -3,15 +3,15 @@ local args=... -- require libs local vfs = require("vfs") -local sqlite = modules.sqlite() -- helper functions local result = function(data) return { error = false, result = data } end -local error = function(msg) - return {error = msg, result = false} +local error = function(msg,...) + local err_msg = string.format(msg or "ERROR",...) + return {error = err_msg, result = false} end -- handler object @@ -21,49 +21,204 @@ local handle = {} handle.init = function(data) local os_path = vfs.ospath(data.db_source) - local db = sqlite._getdb(os_path) + local db = sqlite.db(os_path) if not db then - return error("Unable to open sqlite db file") + return error("init: Unable to open sqlite db file") end sqlite.dbclose(db) return result(true) end -handle.query = function(data) +handle.update = function(data) + if not data.table_name or not data.record or not data.db_source then + return error("update: Invalid request data") + end + if not data.record.id then + return error("update: unknown record id for record") + end local os_path = vfs.ospath(data.db_source) - local db = sqlite._getdb(os_path) + local db = sqlite.db(os_path) if not db then - return error("Unable to open sqlite db file") + return error("update: Unable to open sqlite db file") end - local ret = sqlite.query(db, data.query) + local tb = {} + local gen = SQLQueryGenerator:new({}) + for k,v in pairs(data.record) do + if k ~= "id" then + table.insert(tb, string.format("%s=%s", k, gen:parse_value(v, {[type(v)] = true}))) + end + end + local sql = string.format("UPDATE %s SET %s WHERE id = %d", data.table_name, table.concat(tb,","), data.record.id) + LOG_DEBUG("Execute query: [%s]", sql) + local ret, err = sqlite.exec(db, sql); sqlite.dbclose(db) - if ret ~= 1 then - return error("error executing query") + if not ret then + return error("insert: Unable to insert to %s: %s", data.table_name, err) + else + return result(ret) + end +end + +handle.drop_table = function(data) + if not data.table_name or not data.db_source then + return error("drop_table: Invalid request data") + end + local os_path = vfs.ospath(data.db_source) + local db = sqlite.db(os_path) + if not db then + return error("drop_table: Unable to open sqlite db file") + end + local sql = string.format("DROP TABLE IF EXISTS %s;", data.table_name) + LOG_DEBUG("Execute query: [%s]", sql) + local ret, err = sqlite.exec(db, sql); + sqlite.dbclose(db) + if not ret then + return error("drop_table: Unable to drop table %s: %s", data.table_name, err) + else + return result(ret) + end +end + +handle.insert = function(data) + if not data.table_name or not data.record or not data.db_source then + return error("insert: Invalid request data") + end + local os_path = vfs.ospath(data.db_source) + local db = sqlite.db(os_path) + if not db then + return error("insert: Unable to open sqlite db file") + end + local keys = {} + local vals = {} + local gen = SQLQueryGenerator:new({}) + for k,v in pairs(data.record) do + if k ~= "id" then + table.insert(keys,k) + table.insert(vals,gen:parse_value(v, {[type(v)] = true})) + end + end + local sql = string.format("INSERT INTO %s (%s) VALUES(%s)", data.table_name, table.concat(keys,","), table.concat(vals,",")) + LOG_DEBUG("Execute query: [%s]", sql) + local ret, err = sqlite.exec(db, sql); + sqlite.dbclose(db) + if not ret then + return error("insert: Unable to insert to %s: %s", data.table_name, err) + else + return result(ret) + end +end + +handle.create_table = function(data) + if not data.table_name or not data.scheme or not data.db_source then + return error("create_table: Invalid request data") + end + local os_path = vfs.ospath(data.db_source) + local db = sqlite.db(os_path) + if not db then + return error("create_table: Unable to open sqlite db file") + end + local tb = {} + for k,v in pairs(data.scheme) do + table.insert(tb, k.." "..v) + end + local sql = string.format("CREATE TABLE IF NOT EXISTS %s(id INTEGER PRIMARY KEY,%s)", data.table_name, table.concat(tb,",")) + LOG_DEBUG("Execute query: [%s]", sql) + local ret,err = sqlite.exec(db, sql); + sqlite.dbclose(db) + if not ret then + return error("create_table: Unable to create table %s with the provided scheme: %s", data.table_name, err) + else + return result(ret) end - return result(true) end handle.select = function(data) - local os_path = vfs.ospath(data.db_source) - local db = sqlite._getdb(os_path) - if not db then - return error("Unable to open sqlite db file") + if not data.filter then + return error("select: No filter provided") end - local ret = sqlite.select(db, data.table, data.fields, data.cond); + local os_path = vfs.ospath(data.db_source) + local db = sqlite.db(os_path) + if not db then + return error("select: Unable to open sqlite db file") + end + local generator = SQLQueryGenerator:new(data.filter) + local r,sql = generator:sql_select() + if not r then + return error(sql) + end + LOG_DEBUG("Execute query: %s", sql); + local ret, err = sqlite.query(db, sql); sqlite.dbclose(db) if not ret then - return error("error executing select statement") + return error("select: error executing query statement: %s ", err) + end + return result(ret) +end + + +handle.delete_records = function(data) + if not data.filter then + return error("delete_records: No filter provided") + end + local os_path = vfs.ospath(data.db_source) + local db = sqlite.db(os_path) + if not db then + return error("delete_records: Unable to open sqlite db file") + end + local generator = SQLQueryGenerator:new(data.filter) + local r,sql = generator:sql_delete() + if not r then + return error(sql) + end + LOG_DEBUG("Execute query: %s", sql); + local ret, err = sqlite.exec(db, sql); + sqlite.dbclose(db) + if not ret then + return error("delete_records: error executing query statement: %s ", err) + end + return result(ret) +end + + +handle.table_scheme = function(data) + local os_path = vfs.ospath(data.db_source) + local db = sqlite.db(os_path) + if not db then + return error("table_scheme: Unable to open sqlite db file") + end + local sql = string.format("SELECT p.name, p.type FROM sqlite_master AS m JOIN pragma_table_info(m.name) AS p WHERE m.type ='table' AND m.name = '%s'", data.table_name) + local ret, err = sqlite.query(db, sql); + sqlite.dbclose(db) + if not ret then + return error("table_scheme: error executing query statement: %s", err) + end + return result(ret) +end + + +handle.list_table = function(data) + local os_path = vfs.ospath(data.db_source) + local db = sqlite.db(os_path) + if not db then + return error("list_table: Unable to open sqlite db file") + end + local sql = "SELECT name FROM sqlite_master WHERE type ='table'" + local ret, err = sqlite.query(db, sql) + + sqlite.dbclose(db) + if not ret then + return error("table_scheme: error executing query statement: %s", err) end return result(ret) end handle.last_insert_id = function(data) local os_path = vfs.ospath(data.db_source) - local db = sqlite._getdb(os_path) + local db = sqlite.db(os_path) if not db then - return error("Unable to open sqlite db file") + return error("last_insert_id: Unable to open sqlite db file") end - local ret = sqlite.lastInsertID(db) + local ret = sqlite.last_insert_id(db) sqlite.dbclose(db) return result(ret) end diff --git a/SQLiteDB/build/debug/README.md b/SQLiteDB/build/debug/README.md index 76e9d49..819ff91 100644 --- a/SQLiteDB/build/debug/README.md +++ b/SQLiteDB/build/debug/README.md @@ -1,15 +1,3 @@ # SQLiteDB -This is an example project, generated by AntOS Development Kit -## Howto -Use the Antedit command palette to access to the SDK functionalities: - -1. Create new project -2. Init the project from the current folder located in side bar -3. Build and run the project -4. Release the project in zip package - -## Set up build target - -Open the `build.json` file from the current project tree and add/remove -build target entries and jobs. Save the file \ No newline at end of file +"mimes":["application/vnd.sqlite3"], diff --git a/SQLiteDB/build/debug/api/api.lua b/SQLiteDB/build/debug/api/api.lua index 3b581ff..d623f64 100644 --- a/SQLiteDB/build/debug/api/api.lua +++ b/SQLiteDB/build/debug/api/api.lua @@ -3,15 +3,15 @@ local args=... -- require libs local vfs = require("vfs") -local sqlite = modules.sqlite() -- helper functions local result = function(data) return { error = false, result = data } end -local error = function(msg) - return {error = msg, result = false} +local error = function(msg,...) + local err_msg = string.format(msg or "ERROR",...) + return {error = err_msg, result = false} end -- handler object @@ -21,49 +21,204 @@ local handle = {} handle.init = function(data) local os_path = vfs.ospath(data.db_source) - local db = sqlite._getdb(os_path) + local db = sqlite.db(os_path) if not db then - return error("Unable to open sqlite db file") + return error("init: Unable to open sqlite db file") end sqlite.dbclose(db) return result(true) end -handle.query = function(data) +handle.update = function(data) + if not data.table_name or not data.record or not data.db_source then + return error("update: Invalid request data") + end + if not data.record.id then + return error("update: unknown record id for record") + end local os_path = vfs.ospath(data.db_source) - local db = sqlite._getdb(os_path) + local db = sqlite.db(os_path) if not db then - return error("Unable to open sqlite db file") + return error("update: Unable to open sqlite db file") end - local ret = sqlite.query(db, data.query) + local tb = {} + local gen = SQLQueryGenerator:new({}) + for k,v in pairs(data.record) do + if k ~= "id" then + table.insert(tb, string.format("%s=%s", k, gen:parse_value(v, {[type(v)] = true}))) + end + end + local sql = string.format("UPDATE %s SET %s WHERE id = %d", data.table_name, table.concat(tb,","), data.record.id) + LOG_DEBUG("Execute query: [%s]", sql) + local ret, err = sqlite.exec(db, sql); sqlite.dbclose(db) - if ret ~= 1 then - return error("error executing query") + if not ret then + return error("insert: Unable to insert to %s: %s", data.table_name, err) + else + return result(ret) + end +end + +handle.drop_table = function(data) + if not data.table_name or not data.db_source then + return error("drop_table: Invalid request data") + end + local os_path = vfs.ospath(data.db_source) + local db = sqlite.db(os_path) + if not db then + return error("drop_table: Unable to open sqlite db file") + end + local sql = string.format("DROP TABLE IF EXISTS %s;", data.table_name) + LOG_DEBUG("Execute query: [%s]", sql) + local ret, err = sqlite.exec(db, sql); + sqlite.dbclose(db) + if not ret then + return error("drop_table: Unable to drop table %s: %s", data.table_name, err) + else + return result(ret) + end +end + +handle.insert = function(data) + if not data.table_name or not data.record or not data.db_source then + return error("insert: Invalid request data") + end + local os_path = vfs.ospath(data.db_source) + local db = sqlite.db(os_path) + if not db then + return error("insert: Unable to open sqlite db file") + end + local keys = {} + local vals = {} + local gen = SQLQueryGenerator:new({}) + for k,v in pairs(data.record) do + if k ~= "id" then + table.insert(keys,k) + table.insert(vals,gen:parse_value(v, {[type(v)] = true})) + end + end + local sql = string.format("INSERT INTO %s (%s) VALUES(%s)", data.table_name, table.concat(keys,","), table.concat(vals,",")) + LOG_DEBUG("Execute query: [%s]", sql) + local ret, err = sqlite.exec(db, sql); + sqlite.dbclose(db) + if not ret then + return error("insert: Unable to insert to %s: %s", data.table_name, err) + else + return result(ret) + end +end + +handle.create_table = function(data) + if not data.table_name or not data.scheme or not data.db_source then + return error("create_table: Invalid request data") + end + local os_path = vfs.ospath(data.db_source) + local db = sqlite.db(os_path) + if not db then + return error("create_table: Unable to open sqlite db file") + end + local tb = {} + for k,v in pairs(data.scheme) do + table.insert(tb, k.." "..v) + end + local sql = string.format("CREATE TABLE IF NOT EXISTS %s(id INTEGER PRIMARY KEY,%s)", data.table_name, table.concat(tb,",")) + LOG_DEBUG("Execute query: [%s]", sql) + local ret,err = sqlite.exec(db, sql); + sqlite.dbclose(db) + if not ret then + return error("create_table: Unable to create table %s with the provided scheme: %s", data.table_name, err) + else + return result(ret) end - return result(true) end handle.select = function(data) - local os_path = vfs.ospath(data.db_source) - local db = sqlite._getdb(os_path) - if not db then - return error("Unable to open sqlite db file") + if not data.filter then + return error("select: No filter provided") end - local ret = sqlite.select(db, data.table, data.fields, data.cond); + local os_path = vfs.ospath(data.db_source) + local db = sqlite.db(os_path) + if not db then + return error("select: Unable to open sqlite db file") + end + local generator = SQLQueryGenerator:new(data.filter) + local r,sql = generator:sql_select() + if not r then + return error(sql) + end + LOG_DEBUG("Execute query: %s", sql); + local ret, err = sqlite.query(db, sql); sqlite.dbclose(db) if not ret then - return error("error executing select statement") + return error("select: error executing query statement: %s ", err) + end + return result(ret) +end + + +handle.delete_records = function(data) + if not data.filter then + return error("delete_records: No filter provided") + end + local os_path = vfs.ospath(data.db_source) + local db = sqlite.db(os_path) + if not db then + return error("delete_records: Unable to open sqlite db file") + end + local generator = SQLQueryGenerator:new(data.filter) + local r,sql = generator:sql_delete() + if not r then + return error(sql) + end + LOG_DEBUG("Execute query: %s", sql); + local ret, err = sqlite.exec(db, sql); + sqlite.dbclose(db) + if not ret then + return error("delete_records: error executing query statement: %s ", err) + end + return result(ret) +end + + +handle.table_scheme = function(data) + local os_path = vfs.ospath(data.db_source) + local db = sqlite.db(os_path) + if not db then + return error("table_scheme: Unable to open sqlite db file") + end + local sql = string.format("SELECT p.name, p.type FROM sqlite_master AS m JOIN pragma_table_info(m.name) AS p WHERE m.type ='table' AND m.name = '%s'", data.table_name) + local ret, err = sqlite.query(db, sql); + sqlite.dbclose(db) + if not ret then + return error("table_scheme: error executing query statement: %s", err) + end + return result(ret) +end + + +handle.list_table = function(data) + local os_path = vfs.ospath(data.db_source) + local db = sqlite.db(os_path) + if not db then + return error("list_table: Unable to open sqlite db file") + end + local sql = "SELECT name FROM sqlite_master WHERE type ='table'" + local ret, err = sqlite.query(db, sql) + + sqlite.dbclose(db) + if not ret then + return error("table_scheme: error executing query statement: %s", err) end return result(ret) end handle.last_insert_id = function(data) local os_path = vfs.ospath(data.db_source) - local db = sqlite._getdb(os_path) + local db = sqlite.db(os_path) if not db then - return error("Unable to open sqlite db file") + return error("last_insert_id: Unable to open sqlite db file") end - local ret = sqlite.lastInsertID(db) + local ret = sqlite.last_insert_id(db) sqlite.dbclose(db) return result(ret) end diff --git a/SQLiteDB/build/debug/libsqlite.js b/SQLiteDB/build/debug/libsqlite.js index 57bded6..7095df6 100644 --- a/SQLiteDB/build/debug/libsqlite.js +++ b/SQLiteDB/build/debug/libsqlite.js @@ -3,19 +3,238 @@ var OS; (function (OS) { let API; (function (API) { - class SQLiteDBBase { + /** + * Generate SQL expression from input object + * + * Example of input object + * ```ts + * { + * where: { + * id$gte: 10, + * user: "dany'", + * $or: { + * 'user.email': "test@mail.com", + * age$lte: 30, + * $and: { + * 'user.birth$ne': 1986, + * age$not_between: [20,30], + * name$not_like: "%LE" + * } + * } + * }, + * fields: ['name as n', 'id', 'email'], + * order: ['user.name$asc', "id$desc"], + * joins: { + * cid: 'Category.id', + * did: 'Country.id' + * } + *} + * ``` + * This will generate the followings expressions: + * - `( self.name as n,self.id,self.email )` for fields + * - condition: + * ``` + * ( + * ( self.id >= 10 ) AND + * ( self.user = 'dany''' ) AND + * ( + * ( user.email = 'test@mail.com' ) OR + * ( self.age <= 30 ) OR + * ( + * ( user.birth != 1986 ) AND + * ( self.age NOT BETWEEN 20 AND 30 ) AND + * ( self.name NOT LIKE '%LE' ) + * ) + * ) + * ) + * ``` + * - order: `user.name ASC,self.id DESC` + * - joining: + * ``` + * INNER JOIN Category ON self.cid = Category.id + * INNER JOIN Country ON self.did = Country.id + * ``` + * + */ + class SQLiteQueryGenerator { + constructor(obj) { + this._where = undefined; + this._fields = undefined; + this._order = undefined; + this._joins = undefined; + this._is_joining = false; + if (obj.joins) { + this._is_joining = true; + this._joins = this.joins(obj.joins); + } + if (obj.where) { + this._where = this.where("$and", obj.where); + } + if (obj.fields) { + this._fields = `( ${obj.fields.map(v => this.infer_field(v)).join(",")} )`; + } + if (obj.order) { + this._order = this.order_by(obj.order); + } + } + infer_field(k) { + if (!this._is_joining || k.indexOf(".") > 0) + return k; + return `self.${k}`; + } + joins(data) { + let joins_arr = []; + for (let k in data) { + let v = data[k]; + let arr = v.split('.'); + if (arr.length != 2) { + throw new Error(__("Other table name parsing error: {0}", v).__()); + } + joins_arr.push(`INNER JOIN ${arr[0]} ON ${this.infer_field(k)} = ${v}`); + } + return joins_arr.join(" "); + } + print() { + console.log(this._fields); + console.log(this._where); + console.log(this._order); + console.log(this._joins); + } + order_by(order) { + if (!Array.isArray(order)) { + throw new Error(__("Invalid type: expect array get {0}", typeof (order)).__()); + } + return order.map((v, _) => { + const arr = v.split('$'); + if (arr.length != 2) { + throw new Error(__("Invalid field order format {0}", v).__()); + } + switch (arr[1]) { + case 'asc': return `${this.infer_field(arr[0])} ASC`; + case 'desc': return `${this.infer_field(arr[0])} DESC`; + default: throw new Error(__("Invalid field order type {0}", v).__()); + } + }).join(","); + } + escape_string(s) { + let regex = /[']/g; + var chunkIndex = regex.lastIndex = 0; + var escapedVal = ''; + var match; + while ((match = regex.exec(s))) { + escapedVal += s.slice(chunkIndex, match.index) + { '\'': '\'\'' }[match[0]]; + chunkIndex = regex.lastIndex; + } + if (chunkIndex === 0) { + // Nothing was escaped + return "'" + s + "'"; + } + if (chunkIndex < s.length) { + return "'" + escapedVal + s.slice(chunkIndex) + "'"; + } + return "'" + escapedVal + "'"; + } + parse_value(v, t) { + if (!t.includes(typeof (v))) { + throw new Error(__("Invalid type: expect [{0}] get {1}", t.join(","), typeof (v)).__()); + } + switch (typeof (v)) { + case 'number': return JSON.stringify(v); + case 'string': return this.escape_string(v); + default: throw new Error(__("Un supported value {0} of type {1}", v, typeof (v)).__()); + } + } + binary(k, v) { + const arr = k.split("$"); + if (arr.length > 2) { + throw new Error(__("Invalid left hand side format: {0}", k).__()); + } + if (arr.length == 2) { + switch (arr[1]) { + case "gt": + return `( ${this.infer_field(arr[0])} > ${this.parse_value(v, ['number'])} )`; + case "gte": + return `( ${this.infer_field(arr[0])} >= ${this.parse_value(v, ['number'])} )`; + case "lt": + return `( ${this.infer_field(arr[0])} < ${this.parse_value(v, ['number'])} )`; + case "lte": + return `( ${this.infer_field(arr[0])} <= ${this.parse_value(v, ['number'])} )`; + case "ne": + return `( ${this.infer_field(arr[0])} != ${this.parse_value(v, ['number', 'string'])} )`; + case "between": + return `( ${this.infer_field(arr[0])} BETWEEN ${this.parse_value(v[0], ['number'])} AND ${this.parse_value(v[1], ['number'])} )`; + case "not_between": + return `( ${this.infer_field(arr[0])} NOT BETWEEN ${this.parse_value(v[0], ['number'])} AND ${this.parse_value(v[1], ['number'])} )`; + case "in": + return `( ${this.infer_field(arr[0])} IN [${this.parse_value(v[0], ['number'])}, ${this.parse_value(v[1], ['number'])}] )`; + case "not_in": + return `( ${this.infer_field(arr[0])} NOT IN [${this.parse_value(v[0], ['number'])}, ${this.parse_value(v[1], ['number'])}] )`; + case "like": + return `( ${this.infer_field(arr[0])} LIKE ${this.parse_value(v, ['string'])} )`; + case "not_like": + return `( ${this.infer_field(arr[0])} NOT LIKE ${this.parse_value(v, ['string'])} )`; + default: throw new Error(__("Unsupported operator `{0}`", arr[1]).__()); + } + } + else { + return `( ${this.infer_field(arr[0])} = ${this.parse_value(v, ['number', 'string'])} )`; + } + } + where(op, obj) { + let join_op = undefined; + switch (op) { + case "$and": + join_op = " AND "; + break; + case "$or": + join_op = " OR "; + break; + default: + throw new Error(__("Invalid operator {0}", op).__()); + } + if (typeof obj !== "object") { + throw new Error(__("Invalid input data for operator {0}", op).__()); + } + let arr = []; + for (let k in obj) { + if (k == "$and" || k == "$or") { + arr.push(this.where(k, obj[k])); + } + else { + arr.push(this.binary(k, obj[k])); + } + } + return `( ${arr.join(join_op)} )`; + } + } + class SQLiteDBCore { constructor(path) { + if (!SQLiteDBCore.REGISTY) { + SQLiteDBCore.REGISTY = {}; + } this.db_file = path.asFileHandle(); + if (SQLiteDBCore.REGISTY[this.db_file.path]) { + this.db_file = SQLiteDBCore.REGISTY[this.db_file.path]; + } + else { + SQLiteDBCore.REGISTY[this.db_file.path] = this.db_file; + } } pwd() { return "pkg://SQLiteDB/".asFileHandle(); } + fileinfo() { + return this.db_file.info; + } /** - * init and create the db file if it doesnot exist + * init and create the db file if it does not exist */ init() { return new Promise(async (ok, reject) => { try { + if (this.db_file.ready) { + return ok(true); + } let request = { action: 'init', args: { @@ -64,36 +283,76 @@ var OS; } }); } - query(sql) { + select(filter) { let rq = { - action: 'query', + action: 'select', args: { - query: sql + filter } }; return this.request(rq); } - select(table, fields, condition) { + delete_records(filter) { let rq = { - action: 'select', + action: 'delete_records', args: { - table: table, - fields: fields.join(","), - cond: condition + filter } }; return this.request(rq); } + drop_table(table_name) { + let rq = { + action: 'drop_table', + args: { table_name } + }; + return this.request(rq); + } list_tables() { - return new Promise(async (ok, reject) => { - try { - let result = await this.select("sqlite_master", ["name"], "type ='table'"); - return ok(result.map((e) => e.name)); + let rq = { + action: 'list_table', + args: {} + }; + return this.request(rq); + } + create_table(table, scheme) { + let rq = { + action: 'create_table', + args: { + table_name: table, + scheme } - catch (e) { - reject(__e(e)); + }; + return this.request(rq); + } + get_table_scheme(table_name) { + let rq = { + action: 'table_scheme', + args: { + table_name } - }); + }; + return this.request(rq); + } + insert(table_name, record) { + let rq = { + action: 'insert', + args: { + table_name, + record + } + }; + return this.request(rq); + } + update(table_name, record) { + let rq = { + action: 'update', + args: { + table_name, + record + } + }; + return this.request(rq); } last_insert_id() { let rq = { @@ -103,6 +362,229 @@ var OS; return this.request(rq); } } - API.SQLiteDBBase = SQLiteDBBase; + let VFS; + (function (VFS) { + /** + * SQLite VFS handle for database accessing + * + * A Sqlite file handle shall be in the following formats: + * * `sqlite://remote/path/to/file.db` refers to the entire databale (`remote/path/to/file.db` is relative to the home folder) + * - read operation, will list all available tables + * - write operations will create table + * - rm operation will delete table + * - meta operation will return file info + * - other operations are not supported + * * `sqlite://remote/path/to/file.db@table_name` refers to the table `table_name` in the database + * - meta operation will return fileinfo with table scheme information + * - read operation will read all records by filter defined by the filter operation + * - write operations will insert a new record + * - rm operation will delete records by filter defined by the filter operation + * - filter operation sets the filter for the table + * - other operations are not supported + * - `sqlite://remote/path/to/file.db@table_name@id` refers to a records in `table_name` with ID `id` + * - read operation will read the current record + * - write operation will update current record + * - rm operation will delete current record + * - other operations are not supported + * + * Some example of filters: + * ```ts + * handle.filter = (filter) => { + * filter.fields() + * } + * ``` + * + * @class SqliteFileHandle + * @extends {BaseFileHandle} + */ + class SqliteFileHandle extends VFS.BaseFileHandle { + /** + * Set a file path to the current file handle + * + * + * @param {string} p + * @returns {void} + * @memberof SqliteFileHandle + */ + setPath(p) { + let arr = p.split("@"); + super.setPath(arr[0]); + if (arr.length > 3) { + throw new Error(__("Invalid file path").__()); + } + this.path = p; + this._table_name = arr[1]; + this._id = arr[2] ? parseInt(arr[2]) : undefined; + this._handle = new SQLiteDBCore(`home://${this.genealogy.join("/")}`); + } + /** + * Read database file meta-data + * + * Return file info on the target database file, if the table_name is specified + * return also the table scheme + * + * @returns {Promise} + * @memberof SqliteFileHandle + */ + meta() { + return new Promise(async (resolve, reject) => { + try { + await this._handle.init(); + const d = { result: this._handle.fileinfo(), error: false }; + if (this._table_name) { + const data = await this._handle.get_table_scheme(this._table_name); + if (data.length == 0) { + d.result.scheme = undefined; + } + else { + d.result.scheme = {}; + for (let v of data) { + d.result.scheme[v.name] = v.type; + } + } + } + return resolve(d); + } + catch (e) { + return reject(__e(e)); + } + }); + } + /** + * Query the database based on the provided info + * + * If no table is provided, return list of tables in the + * data base. + * If the current table is specified: + * - if the record id is specfied return the record + * - otherwise, return the records in the table using the specified filter + * + * @protected + * @param {any} t filter type + * @returns {Promise} + * @memberof SqliteFileHandle + */ + _rd(user_data) { + return new Promise(async (resolve, reject) => { + try { + if (this._table_name && !this.info.scheme) { + throw new Error(__("Table `{0}` does not exists in database: {1}", this._table_name, this.path).__()); + } + if (!this._table_name) { + // return list of tables in form of data base file handles in ready mode + let list = await this._handle.list_tables(); + const map = {}; + for (let v of list) { + map[v.name] = `${this.path}@${v.name}`.asFileHandle(); + } + this.cache = map; + resolve(map); + } + else { + // return all the data in the table set by the filter + // if this is a table, return the filtered records + // otherwise, it is a record, fetch only that record + let filter = user_data; + if (!filter || this._id) { + filter = {}; + } + filter.table_name = this._table_name; + if (this._id) { + filter.where = { id: this._id }; + } + let data = await this._handle.select(filter); + if (this._id) { + this.cache = data[0]; + } + else { + this.cache = data; + } + resolve(this.cache); + } + } + catch (e) { + return reject(__e(e)); + } + }); + } + /** + * Write commit file cache to the remote database + * + * @protected + * @param {string} t is table name, used only when create table + * @returns {Promise} + * @memberof SqliteFileHandle + */ + _wr(t) { + return new Promise(async (resolve, reject) => { + try { + if (!this.cache) { + throw new Error(__("No data to submit to remote database, please check the `cache` field").__()); + } + if (this._id && this._table_name) { + this.cache.id = this._id; + const ret = await this._handle.update(this._table_name, this.cache); + resolve({ result: ret, error: false }); + return; + } + if (this._table_name) { + const ret = await this._handle.insert(this._table_name, this.cache); + resolve({ result: ret, error: false }); + return; + } + // create a new table with the scheme provided in the cache + let r = await this._handle.create_table(t, this.cache); + resolve({ result: r, error: false }); + } + catch (e) { + return reject(__e(e)); + } + }); + } + /** + * Delete data from remote database + * + * @protected + * @param {any} user_data is table name, for delete table, otherwise, filter object for deleting records + * @returns {Promise} + * @memberof SqliteFileHandle + */ + _rm(user_data) { + return new Promise(async (resolve, reject) => { + try { + if (this._table_name && !this.info.scheme) { + throw new Error(__("Table `{0}` does not exists in database: {1}", this._table_name, this.path).__()); + } + if (!this._table_name) { + let table_name = user_data; + if (!table_name) { + throw new Error(__("No table specified for dropping").__()); + } + let ret = await this._handle.drop_table(table_name); + resolve({ result: ret, error: false }); + // delete the table + } + else { + let filter = user_data; + // delete the records in the table using the filter + if (!filter || this._id) { + filter = {}; + } + filter.table_name = this._table_name; + if (this._id) { + filter.where = { id: this._id }; + } + let ret = await this._handle.delete_records(filter); + resolve({ result: ret, error: false }); + } + } + catch (e) { + return reject(__e(e)); + } + }); + } + } + VFS.register("^sqlite$", SqliteFileHandle); + })(VFS = API.VFS || (API.VFS = {})); })(API = OS.API || (OS.API = {})); })(OS || (OS = {})); diff --git a/SQLiteDB/build/debug/main.js b/SQLiteDB/build/debug/main.js index 49d58d6..317e6cf 100644 --- a/SQLiteDB/build/debug/main.js +++ b/SQLiteDB/build/debug/main.js @@ -4,8 +4,6 @@ var OS; (function (OS) { let application; (function (application) { - ; - ; /** * * @class SQLiteDB @@ -15,19 +13,200 @@ var OS; constructor(args) { super("SQLiteDB", args); } - main() { - // YOUR CODE HERE - let handle = new OS.API.SQLiteDBBase("home://tmp/test.db"); - handle.list_tables().then((list) => { - console.log(list); - if (list.indexOf("contacts") < 0) { - handle.query("CREATE TABLE contacts (id INTEGER PRIMARY KEY,first_name TEXT NOT NULL,last_name TEXT NOT NULL,email TEXT NOT NULL UNIQUE,phone TEXT NOT NULL UNIQUE)"); + menu() { + return [ + { + text: "__(File)", + nodes: [ + { + text: "__(New)", + dataid: "new", + shortcut: 'A-N' + }, + { + text: "__(Open)", + dataid: "open", + shortcut: 'A-O' + }, + ], + onchildselect: (e) => { + switch (e.data.item.data.dataid) { + case "new": + return this.newFile(); + case "open": + return this.openFile(); + } + } + } + ]; + } + list_tables() { + this.filehandle.read() + .then((data) => { + const list = []; + for (let k in data) { + list.push({ + text: k, + name: k, + handle: data[k] + }); + } + this.tbl_list.data = list; + if (list.length > 0) { + this.tbl_list.selected = 0; } }); - handle.last_insert_id().then(o => console.log(o)); + } + openFile() { + return this.openDialog("FileDialog", { + title: __("Open file"), + mimes: this.meta().mimes + }).then(async (d) => { + this.filehandle = `sqlite://${d.file.path.asFileHandle().genealogy.join("/")}`.asFileHandle(); + await this.filehandle.onready(); + this.list_tables(); + }) + .catch((e) => { + this.error(__("Unable to open database file: {0}", e.toString()), e); + }); + ; + } + newFile() { + return this.openDialog("FileDialog", { + title: __("Save as"), + file: "Untitled.db" + }).then(async (f) => { + var d; + d = f.file.path.asFileHandle(); + if (f.file.type === "file") { + d = d.parent(); + } + const target = `${d.path}/${f.name}`.asFileHandle(); + this.filehandle = `sqlite://${target.genealogy.join("/")}`.asFileHandle(); + await this.filehandle.onready(); + this.list_tables(); + }) + .catch((e) => { + this.error(__("Unable to init database file: {0}", e.toString()), e); + }); + } + main() { + this.filehandle = undefined; + this.tbl_list = this.find("tbl-list"); + this.find("bt-add-table").onbtclick = (e) => { + this.openDialog(new NewTableDialog(), { + title: __("Create new table") + }); + }; } } application.SQLiteDB = SQLiteDB; + class NewTableDialog extends OS.GUI.BasicDialog { + /** + * Creates an instance of NewTableDialog. + * @memberof NewTableDialog + */ + constructor() { + super("NewTableDialog"); + } + init() { + console.log(this.constructor.scheme); + } + /** + * Main entry point + * + * @memberof NewTableDialog + */ + main() { + super.main(); + this.container = this.find("container"); + this.find("btnCancel").onbtclick = (e) => this.quit(); + this.find("btnAdd").onbtclick = (e) => this.addField("", "", true); + $(this.find("wrapper")); + $(this.container) + .css("overflow-y", "auto"); + this.find("btnOk").onbtclick = (e) => { + const inputs = $("input", this.scheme); + let cdata = {}; + for (let i = 0; i < inputs.length; i += 2) { + const key = inputs[i].value.trim(); + if (key === "") { + return this.notify(__("Key cannot be empty")); + } + if (cdata[key]) { + return this.notify(__("Duplicate key: {0}", key)); + } + cdata[key] = inputs[i + 1].value.trim(); + } + if (this.handle) + this.handle(cdata); + this.quit(); + }; + } + /** + * Add new input key-value field to the dialog + * + * @private + * @memberof NewTableDialog + */ + addField(key, value, removable) { + const div = $("
") + .css("width", "100%") + .css("display", "flex") + .css("flex-direction", "row") + .appendTo(this.container); + $("") + .attr("type", "text") + .css("width", "50%") + .css("height", "25px") + .val(key) + .appendTo(div); + $("") + .css("width", "50%") + .css("height", "25px") + .appendTo(div); + if (removable) { + const btn = $(""); + btn[0].uify(undefined); + $("button", btn) + .css("width", "25px") + .css("height", "25px"); + btn[0].iconclass = "fa fa-minus"; + btn + .on("click", () => { + div.remove(); + }) + .appendTo(div); + } + else { + $("
") + .css("width", "25px") + .appendTo(div); + } + } + } + /** + * Scheme definition + */ + NewTableDialog.scheme = `\ + + +
+ +
+ +
+ + +
+ + +
+
+
+
+
+
`; })(application = OS.application || (OS.application = {})); })(OS || (OS = {})); @@ -36,19 +215,238 @@ var OS; (function (OS) { let API; (function (API) { - class SQLiteDBBase { + /** + * Generate SQL expression from input object + * + * Example of input object + * ```ts + * { + * where: { + * id$gte: 10, + * user: "dany'", + * $or: { + * 'user.email': "test@mail.com", + * age$lte: 30, + * $and: { + * 'user.birth$ne': 1986, + * age$not_between: [20,30], + * name$not_like: "%LE" + * } + * } + * }, + * fields: ['name as n', 'id', 'email'], + * order: ['user.name$asc', "id$desc"], + * joins: { + * cid: 'Category.id', + * did: 'Country.id' + * } + *} + * ``` + * This will generate the followings expressions: + * - `( self.name as n,self.id,self.email )` for fields + * - condition: + * ``` + * ( + * ( self.id >= 10 ) AND + * ( self.user = 'dany''' ) AND + * ( + * ( user.email = 'test@mail.com' ) OR + * ( self.age <= 30 ) OR + * ( + * ( user.birth != 1986 ) AND + * ( self.age NOT BETWEEN 20 AND 30 ) AND + * ( self.name NOT LIKE '%LE' ) + * ) + * ) + * ) + * ``` + * - order: `user.name ASC,self.id DESC` + * - joining: + * ``` + * INNER JOIN Category ON self.cid = Category.id + * INNER JOIN Country ON self.did = Country.id + * ``` + * + */ + class SQLiteQueryGenerator { + constructor(obj) { + this._where = undefined; + this._fields = undefined; + this._order = undefined; + this._joins = undefined; + this._is_joining = false; + if (obj.joins) { + this._is_joining = true; + this._joins = this.joins(obj.joins); + } + if (obj.where) { + this._where = this.where("$and", obj.where); + } + if (obj.fields) { + this._fields = `( ${obj.fields.map(v => this.infer_field(v)).join(",")} )`; + } + if (obj.order) { + this._order = this.order_by(obj.order); + } + } + infer_field(k) { + if (!this._is_joining || k.indexOf(".") > 0) + return k; + return `self.${k}`; + } + joins(data) { + let joins_arr = []; + for (let k in data) { + let v = data[k]; + let arr = v.split('.'); + if (arr.length != 2) { + throw new Error(__("Other table name parsing error: {0}", v).__()); + } + joins_arr.push(`INNER JOIN ${arr[0]} ON ${this.infer_field(k)} = ${v}`); + } + return joins_arr.join(" "); + } + print() { + console.log(this._fields); + console.log(this._where); + console.log(this._order); + console.log(this._joins); + } + order_by(order) { + if (!Array.isArray(order)) { + throw new Error(__("Invalid type: expect array get {0}", typeof (order)).__()); + } + return order.map((v, _) => { + const arr = v.split('$'); + if (arr.length != 2) { + throw new Error(__("Invalid field order format {0}", v).__()); + } + switch (arr[1]) { + case 'asc': return `${this.infer_field(arr[0])} ASC`; + case 'desc': return `${this.infer_field(arr[0])} DESC`; + default: throw new Error(__("Invalid field order type {0}", v).__()); + } + }).join(","); + } + escape_string(s) { + let regex = /[']/g; + var chunkIndex = regex.lastIndex = 0; + var escapedVal = ''; + var match; + while ((match = regex.exec(s))) { + escapedVal += s.slice(chunkIndex, match.index) + { '\'': '\'\'' }[match[0]]; + chunkIndex = regex.lastIndex; + } + if (chunkIndex === 0) { + // Nothing was escaped + return "'" + s + "'"; + } + if (chunkIndex < s.length) { + return "'" + escapedVal + s.slice(chunkIndex) + "'"; + } + return "'" + escapedVal + "'"; + } + parse_value(v, t) { + if (!t.includes(typeof (v))) { + throw new Error(__("Invalid type: expect [{0}] get {1}", t.join(","), typeof (v)).__()); + } + switch (typeof (v)) { + case 'number': return JSON.stringify(v); + case 'string': return this.escape_string(v); + default: throw new Error(__("Un supported value {0} of type {1}", v, typeof (v)).__()); + } + } + binary(k, v) { + const arr = k.split("$"); + if (arr.length > 2) { + throw new Error(__("Invalid left hand side format: {0}", k).__()); + } + if (arr.length == 2) { + switch (arr[1]) { + case "gt": + return `( ${this.infer_field(arr[0])} > ${this.parse_value(v, ['number'])} )`; + case "gte": + return `( ${this.infer_field(arr[0])} >= ${this.parse_value(v, ['number'])} )`; + case "lt": + return `( ${this.infer_field(arr[0])} < ${this.parse_value(v, ['number'])} )`; + case "lte": + return `( ${this.infer_field(arr[0])} <= ${this.parse_value(v, ['number'])} )`; + case "ne": + return `( ${this.infer_field(arr[0])} != ${this.parse_value(v, ['number', 'string'])} )`; + case "between": + return `( ${this.infer_field(arr[0])} BETWEEN ${this.parse_value(v[0], ['number'])} AND ${this.parse_value(v[1], ['number'])} )`; + case "not_between": + return `( ${this.infer_field(arr[0])} NOT BETWEEN ${this.parse_value(v[0], ['number'])} AND ${this.parse_value(v[1], ['number'])} )`; + case "in": + return `( ${this.infer_field(arr[0])} IN [${this.parse_value(v[0], ['number'])}, ${this.parse_value(v[1], ['number'])}] )`; + case "not_in": + return `( ${this.infer_field(arr[0])} NOT IN [${this.parse_value(v[0], ['number'])}, ${this.parse_value(v[1], ['number'])}] )`; + case "like": + return `( ${this.infer_field(arr[0])} LIKE ${this.parse_value(v, ['string'])} )`; + case "not_like": + return `( ${this.infer_field(arr[0])} NOT LIKE ${this.parse_value(v, ['string'])} )`; + default: throw new Error(__("Unsupported operator `{0}`", arr[1]).__()); + } + } + else { + return `( ${this.infer_field(arr[0])} = ${this.parse_value(v, ['number', 'string'])} )`; + } + } + where(op, obj) { + let join_op = undefined; + switch (op) { + case "$and": + join_op = " AND "; + break; + case "$or": + join_op = " OR "; + break; + default: + throw new Error(__("Invalid operator {0}", op).__()); + } + if (typeof obj !== "object") { + throw new Error(__("Invalid input data for operator {0}", op).__()); + } + let arr = []; + for (let k in obj) { + if (k == "$and" || k == "$or") { + arr.push(this.where(k, obj[k])); + } + else { + arr.push(this.binary(k, obj[k])); + } + } + return `( ${arr.join(join_op)} )`; + } + } + class SQLiteDBCore { constructor(path) { + if (!SQLiteDBCore.REGISTY) { + SQLiteDBCore.REGISTY = {}; + } this.db_file = path.asFileHandle(); + if (SQLiteDBCore.REGISTY[this.db_file.path]) { + this.db_file = SQLiteDBCore.REGISTY[this.db_file.path]; + } + else { + SQLiteDBCore.REGISTY[this.db_file.path] = this.db_file; + } } pwd() { return "pkg://SQLiteDB/".asFileHandle(); } + fileinfo() { + return this.db_file.info; + } /** - * init and create the db file if it doesnot exist + * init and create the db file if it does not exist */ init() { return new Promise(async (ok, reject) => { try { + if (this.db_file.ready) { + return ok(true); + } let request = { action: 'init', args: { @@ -97,36 +495,76 @@ var OS; } }); } - query(sql) { + select(filter) { let rq = { - action: 'query', + action: 'select', args: { - query: sql + filter } }; return this.request(rq); } - select(table, fields, condition) { + delete_records(filter) { let rq = { - action: 'select', + action: 'delete_records', args: { - table: table, - fields: fields.join(","), - cond: condition + filter } }; return this.request(rq); } + drop_table(table_name) { + let rq = { + action: 'drop_table', + args: { table_name } + }; + return this.request(rq); + } list_tables() { - return new Promise(async (ok, reject) => { - try { - let result = await this.select("sqlite_master", ["name"], "type ='table'"); - return ok(result.map((e) => e.name)); + let rq = { + action: 'list_table', + args: {} + }; + return this.request(rq); + } + create_table(table, scheme) { + let rq = { + action: 'create_table', + args: { + table_name: table, + scheme } - catch (e) { - reject(__e(e)); + }; + return this.request(rq); + } + get_table_scheme(table_name) { + let rq = { + action: 'table_scheme', + args: { + table_name } - }); + }; + return this.request(rq); + } + insert(table_name, record) { + let rq = { + action: 'insert', + args: { + table_name, + record + } + }; + return this.request(rq); + } + update(table_name, record) { + let rq = { + action: 'update', + args: { + table_name, + record + } + }; + return this.request(rq); } last_insert_id() { let rq = { @@ -136,6 +574,229 @@ var OS; return this.request(rq); } } - API.SQLiteDBBase = SQLiteDBBase; + let VFS; + (function (VFS) { + /** + * SQLite VFS handle for database accessing + * + * A Sqlite file handle shall be in the following formats: + * * `sqlite://remote/path/to/file.db` refers to the entire databale (`remote/path/to/file.db` is relative to the home folder) + * - read operation, will list all available tables + * - write operations will create table + * - rm operation will delete table + * - meta operation will return file info + * - other operations are not supported + * * `sqlite://remote/path/to/file.db@table_name` refers to the table `table_name` in the database + * - meta operation will return fileinfo with table scheme information + * - read operation will read all records by filter defined by the filter operation + * - write operations will insert a new record + * - rm operation will delete records by filter defined by the filter operation + * - filter operation sets the filter for the table + * - other operations are not supported + * - `sqlite://remote/path/to/file.db@table_name@id` refers to a records in `table_name` with ID `id` + * - read operation will read the current record + * - write operation will update current record + * - rm operation will delete current record + * - other operations are not supported + * + * Some example of filters: + * ```ts + * handle.filter = (filter) => { + * filter.fields() + * } + * ``` + * + * @class SqliteFileHandle + * @extends {BaseFileHandle} + */ + class SqliteFileHandle extends VFS.BaseFileHandle { + /** + * Set a file path to the current file handle + * + * + * @param {string} p + * @returns {void} + * @memberof SqliteFileHandle + */ + setPath(p) { + let arr = p.split("@"); + super.setPath(arr[0]); + if (arr.length > 3) { + throw new Error(__("Invalid file path").__()); + } + this.path = p; + this._table_name = arr[1]; + this._id = arr[2] ? parseInt(arr[2]) : undefined; + this._handle = new SQLiteDBCore(`home://${this.genealogy.join("/")}`); + } + /** + * Read database file meta-data + * + * Return file info on the target database file, if the table_name is specified + * return also the table scheme + * + * @returns {Promise} + * @memberof SqliteFileHandle + */ + meta() { + return new Promise(async (resolve, reject) => { + try { + await this._handle.init(); + const d = { result: this._handle.fileinfo(), error: false }; + if (this._table_name) { + const data = await this._handle.get_table_scheme(this._table_name); + if (data.length == 0) { + d.result.scheme = undefined; + } + else { + d.result.scheme = {}; + for (let v of data) { + d.result.scheme[v.name] = v.type; + } + } + } + return resolve(d); + } + catch (e) { + return reject(__e(e)); + } + }); + } + /** + * Query the database based on the provided info + * + * If no table is provided, return list of tables in the + * data base. + * If the current table is specified: + * - if the record id is specfied return the record + * - otherwise, return the records in the table using the specified filter + * + * @protected + * @param {any} t filter type + * @returns {Promise} + * @memberof SqliteFileHandle + */ + _rd(user_data) { + return new Promise(async (resolve, reject) => { + try { + if (this._table_name && !this.info.scheme) { + throw new Error(__("Table `{0}` does not exists in database: {1}", this._table_name, this.path).__()); + } + if (!this._table_name) { + // return list of tables in form of data base file handles in ready mode + let list = await this._handle.list_tables(); + const map = {}; + for (let v of list) { + map[v.name] = `${this.path}@${v.name}`.asFileHandle(); + } + this.cache = map; + resolve(map); + } + else { + // return all the data in the table set by the filter + // if this is a table, return the filtered records + // otherwise, it is a record, fetch only that record + let filter = user_data; + if (!filter || this._id) { + filter = {}; + } + filter.table_name = this._table_name; + if (this._id) { + filter.where = { id: this._id }; + } + let data = await this._handle.select(filter); + if (this._id) { + this.cache = data[0]; + } + else { + this.cache = data; + } + resolve(this.cache); + } + } + catch (e) { + return reject(__e(e)); + } + }); + } + /** + * Write commit file cache to the remote database + * + * @protected + * @param {string} t is table name, used only when create table + * @returns {Promise} + * @memberof SqliteFileHandle + */ + _wr(t) { + return new Promise(async (resolve, reject) => { + try { + if (!this.cache) { + throw new Error(__("No data to submit to remote database, please check the `cache` field").__()); + } + if (this._id && this._table_name) { + this.cache.id = this._id; + const ret = await this._handle.update(this._table_name, this.cache); + resolve({ result: ret, error: false }); + return; + } + if (this._table_name) { + const ret = await this._handle.insert(this._table_name, this.cache); + resolve({ result: ret, error: false }); + return; + } + // create a new table with the scheme provided in the cache + let r = await this._handle.create_table(t, this.cache); + resolve({ result: r, error: false }); + } + catch (e) { + return reject(__e(e)); + } + }); + } + /** + * Delete data from remote database + * + * @protected + * @param {any} user_data is table name, for delete table, otherwise, filter object for deleting records + * @returns {Promise} + * @memberof SqliteFileHandle + */ + _rm(user_data) { + return new Promise(async (resolve, reject) => { + try { + if (this._table_name && !this.info.scheme) { + throw new Error(__("Table `{0}` does not exists in database: {1}", this._table_name, this.path).__()); + } + if (!this._table_name) { + let table_name = user_data; + if (!table_name) { + throw new Error(__("No table specified for dropping").__()); + } + let ret = await this._handle.drop_table(table_name); + resolve({ result: ret, error: false }); + // delete the table + } + else { + let filter = user_data; + // delete the records in the table using the filter + if (!filter || this._id) { + filter = {}; + } + filter.table_name = this._table_name; + if (this._id) { + filter.where = { id: this._id }; + } + let ret = await this._handle.delete_records(filter); + resolve({ result: ret, error: false }); + } + } + catch (e) { + return reject(__e(e)); + } + }); + } + } + VFS.register("^sqlite$", SqliteFileHandle); + })(VFS = API.VFS || (API.VFS = {})); })(API = OS.API || (OS.API = {})); })(OS || (OS = {})); diff --git a/SQLiteDB/build/debug/package.json b/SQLiteDB/build/debug/package.json index 063d3ce..ce6fcaa 100644 --- a/SQLiteDB/build/debug/package.json +++ b/SQLiteDB/build/debug/package.json @@ -1,16 +1,16 @@ { "pkgname": "SQLiteDB", "app":"SQLiteDB", - "name":"SQLiteDB", - "description":"SQLiteDB", + "name":"SQLite3 VFS API", + "description":"API for manipulate SQLite database as VFS handle", "info":{ "author": "", "email": "" }, - "version":"0.0.1-a", + "version":"0.1.0-a", "category":"Other", "iconclass":"fa fa-adn", - "mimes":["none"], + "mimes":[".*"], "dependencies":[], "locale": {} } \ No newline at end of file diff --git a/SQLiteDB/build/debug/scheme.html b/SQLiteDB/build/debug/scheme.html index 6f6c454..c0197cc 100644 --- a/SQLiteDB/build/debug/scheme.html +++ b/SQLiteDB/build/debug/scheme.html @@ -1,3 +1,19 @@ - - + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/SQLiteDB/main.ts b/SQLiteDB/main.ts index ac31084..7dc8e65 100644 --- a/SQLiteDB/main.ts +++ b/SQLiteDB/main.ts @@ -1,34 +1,230 @@ namespace OS { export namespace application { - interface SQLiteDBBaseConstructor{ - new(pqth: API.VFS.BaseFileHandle| string): SQLiteDBBase; - }; - interface SQLiteDBBase{ - list_tables(): Promise>, - last_insert_id(): Promise, - query(sql): Promise - }; + /** * * @class SQLiteDB * @extends {BaseApplication} */ export class SQLiteDB extends BaseApplication { + + private filehandle: API.VFS.BaseFileHandle; + private tbl_list: GUI.tag.ListViewTag; + constructor(args: AppArgumentsType[]) { super("SQLiteDB", args); } - main(): void { - // YOUR CODE HERE - let handle = new ((OS.API as any).SQLiteDBBase as SQLiteDBBaseConstructor)("home://tmp/test.db"); - handle.list_tables().then((list) => { - console.log(list); - if(list.indexOf("contacts") < 0) + + menu() { + return [ { - handle.query("CREATE TABLE contacts (id INTEGER PRIMARY KEY,first_name TEXT NOT NULL,last_name TEXT NOT NULL,email TEXT NOT NULL UNIQUE,phone TEXT NOT NULL UNIQUE)"); + text: "__(File)", + nodes: [ + { + text: "__(New)", + dataid: "new", + shortcut: 'A-N' + }, + { + text: "__(Open)", + dataid: "open", + shortcut: 'A-O' + }, + ], + onchildselect: (e) => { + switch (e.data.item.data.dataid) { + case "new": + return this.newFile(); + case "open": + return this.openFile(); + } + } } + ]; + } + private list_tables() + { + this.filehandle.read() + .then((data) => { + const list = []; + for(let k in data) + { + list.push({ + text: k, + name: k, + handle: data[k] + }); + } + this.tbl_list.data = list; + if(list.length > 0) + { + this.tbl_list.selected = 0; + } + }) + } + private openFile() { + return this.openDialog("FileDialog", { + title: __("Open file"), + mimes: this.meta().mimes + }).then(async (d) => { + this.filehandle = `sqlite://${d.file.path.asFileHandle().genealogy.join("/")}`.asFileHandle(); + await this.filehandle.onready(); + this.list_tables(); + }) + .catch((e) => { + this.error(__("Unable to open database file: {0}", e.toString()), e); + });; + } + + private newFile() { + return this.openDialog("FileDialog", { + title: __("Save as"), + file: "Untitled.db" + }).then(async (f) => { + var d; + d = f.file.path.asFileHandle(); + if (f.file.type === "file") { + d = d.parent(); + } + const target = `${d.path}/${f.name}`.asFileHandle(); + this.filehandle = `sqlite://${target.genealogy.join("/")}`.asFileHandle(); + await this.filehandle.onready(); + this.list_tables(); + }) + .catch((e) => { + this.error(__("Unable to init database file: {0}", e.toString()), e); }); - handle.last_insert_id().then(o => console.log(o)); + } + + main(): void { + this.filehandle = undefined; + this.tbl_list = this.find("tbl-list") as GUI.tag.ListViewTag; + (this.find("bt-add-table") as GUI.tag.ButtonTag).onbtclick = (e) => { + this.openDialog(new NewTableDialog(), { + title: __("Create new table") + }); + } } } + + + class NewTableDialog extends GUI.BasicDialog { + /** + * Reference to the form container + * + * @private + * @type {HTMLDivElement} + * @memberof NewTableDialog + */ + private container: HTMLDivElement; + + /** + * Creates an instance of NewTableDialog. + * @memberof NewTableDialog + */ + constructor() { + super("NewTableDialog"); + } + + /** + * Main entry point + * + * @memberof NewTableDialog + */ + main(): void { + super.main(); + this.container = this.find("container") as HTMLDivElement; + (this.find("btnCancel") as GUI.tag.ButtonTag).onbtclick = (e) => this.quit(); + (this.find("btnAdd") as GUI.tag.ButtonTag).onbtclick = (e) => this.addField("", "", true); + $(this.find("wrapper")) + $(this.container) + .css("overflow-y", "auto"); + (this.find("btnOk") as GUI.tag.ButtonTag).onbtclick = (e) => { + const inputs = $("input", this.scheme) as JQuery; + let cdata: GenericObject = {}; + for (let i = 0; i < inputs.length; i += 2) { + const key = inputs[i].value.trim(); + if (key === "") { + return this.notify(__("Key cannot be empty")); + } + if (cdata[key]) { + return this.notify(__("Duplicate key: {0}", key)); + } + cdata[key] = inputs[i + 1].value.trim(); + } + if (this.handle) + this.handle(cdata); + this.quit(); + } + } + + + /** + * Add new input key-value field to the dialog + * + * @private + * @memberof NewTableDialog + */ + private addField(key: string, value: string, removable: boolean): void { + const div = $("
") + .css("width", "100%") + .css("display", "flex") + .css("flex-direction", "row") + .appendTo(this.container); + $("") + .attr("type", "text") + .css("width", "50%") + .css("height", "25px") + .val(key) + .appendTo(div); + $("") + .css("width", "50%") + .css("height", "25px") + .appendTo(div); + if (removable) { + const btn = $(""); + btn[0].uify(undefined); + $("button", btn) + .css("width", "25px") + .css("height", "25px"); + (btn[0] as GUI.tag.ButtonTag).iconclass = "fa fa-minus"; + btn + .on("click", () => { + div.remove(); + }) + .appendTo(div); + } + else { + $("
") + .css("width", "25px") + .appendTo(div); + } + + } + + } + + /** + * Scheme definition + */ + NewTableDialog.scheme = `\ + + +
+ +
+ +
+ + +
+ + +
+
+
+
+
+
`; } } \ No newline at end of file diff --git a/SQLiteDB/package.json b/SQLiteDB/package.json index 063d3ce..ce6fcaa 100644 --- a/SQLiteDB/package.json +++ b/SQLiteDB/package.json @@ -1,16 +1,16 @@ { "pkgname": "SQLiteDB", "app":"SQLiteDB", - "name":"SQLiteDB", - "description":"SQLiteDB", + "name":"SQLite3 VFS API", + "description":"API for manipulate SQLite database as VFS handle", "info":{ "author": "", "email": "" }, - "version":"0.0.1-a", + "version":"0.1.0-a", "category":"Other", "iconclass":"fa fa-adn", - "mimes":["none"], + "mimes":[".*"], "dependencies":[], "locale": {} } \ No newline at end of file diff --git a/SQLiteDB/scheme.html b/SQLiteDB/scheme.html index 6f6c454..c0197cc 100644 --- a/SQLiteDB/scheme.html +++ b/SQLiteDB/scheme.html @@ -1,3 +1,19 @@ - - + + + + + + + + + + + + + + + + + + \ No newline at end of file