diff --git a/desktop/plugins/databases/UpdateQueryUtil.tsx b/desktop/plugins/databases/UpdateQueryUtil.tsx new file mode 100644 index 000000000..9a0f53028 --- /dev/null +++ b/desktop/plugins/databases/UpdateQueryUtil.tsx @@ -0,0 +1,73 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +import {Value} from 'flipper'; + +const INT_DATA_TYPE = ['INTEGER', 'LONG', 'INT', 'BIGINT']; +const FLOAT_DATA_TYPE = ['REAL', 'DOUBLE']; +const BLOB_DATA_TYPE = ['BLOB']; + +export function convertStringToValue( + types: {[key: string]: {type: string; nullable: boolean}}, + key: string, + value: string | null, +): Value { + if (value !== null && types.hasOwnProperty(key)) { + const {type, nullable} = types[key]; + if (value.length <= 0 && nullable) { + return {type: 'null', value: null}; + } + if (INT_DATA_TYPE.indexOf(type) >= 0) { + return {type: 'integer', value: parseInt(value, 10)}; + } else if (FLOAT_DATA_TYPE.indexOf(type) >= 0) { + return {type: 'float', value: parseFloat(value)}; + } else if (BLOB_DATA_TYPE.indexOf(type) >= 0) { + return {type: 'blob', value}; + } + } + // if no type found assume type is nullable string + if (value === null) { + return {type: 'null', value: null}; + } else { + return {type: 'string', value}; + } +} + +function constructQueryClause( + values: {[key: string]: Value}, + connector: string, +): string { + return Object.entries(values).reduce( + (clauses, [key, val]: [string, Value], idx) => { + const {type, value} = val; + const valueString = + type === 'null' + ? 'NULL' + : type === 'string' || type === 'blob' + ? `'${value}'` + : `${value}`; + if (idx <= 0) { + return `${key}=${valueString}`; + } else { + return `${clauses} ${connector} ${key}=${valueString}`; + } + }, + '', + ); +} + +export function constructUpdateQuery( + table: string, + where: {[key: string]: Value}, + change: {[key: string]: Value}, +): string { + return `UPDATE ${table} + SET ${constructQueryClause(change, ',')} + WHERE ${constructQueryClause(where, 'AND')}`; +} diff --git a/desktop/plugins/databases/index.tsx b/desktop/plugins/databases/index.tsx index daf1459f6..60243231c 100644 --- a/desktop/plugins/databases/index.tsx +++ b/desktop/plugins/databases/index.tsx @@ -37,6 +37,7 @@ import {DatabaseClient} from './ClientProtocol'; import ButtonNavigation from './ButtonNavigation'; import DatabaseDetailSidebar from './DatabaseDetailSidebar'; import DatabaseStructure from './DatabaseStructure'; +import {convertStringToValue, constructUpdateQuery} from './UpdateQueryUtil'; import sqlFormatter from 'sql-formatter'; import dateFormat from 'dateformat'; @@ -977,6 +978,106 @@ export default class DatabasesPlugin extends FlipperPlugin< }); }; + onRowEdited(change: {[key: string]: string | null}) { + const { + selectedDatabaseTable, + currentStructure, + viewMode, + currentPage, + } = this.state; + const highlightedRowIdx = currentPage?.highlightedRows[0] ?? -1; + const row = + highlightedRowIdx >= 0 + ? currentPage?.rows[currentPage?.highlightedRows[0]] + : undefined; + const columns = currentPage?.columns; + // currently only allow to edit data shown in Data tab + if ( + viewMode !== 'data' || + selectedDatabaseTable === null || + currentStructure === null || + currentPage === null || + row === undefined || + columns === undefined || + // only trigger when there is change + Object.keys(change).length <= 0 + ) { + return; + } + // check if the table has primary key to use for query + // This is assumed data are in the same format as in SqliteDatabaseDriver.java + const primaryKeyIdx = currentStructure.columns.indexOf('primary_key'); + const nameKeyIdx = currentStructure.columns.indexOf('column_name'); + const typeIdx = currentStructure.columns.indexOf('data_type'); + const nullableIdx = currentStructure.columns.indexOf('nullable'); + if (primaryKeyIdx < 0 && nameKeyIdx < 0 && typeIdx < 0) { + console.error( + 'primary_key, column_name, and/or data_type cannot be empty', + ); + return; + } + const primaryColumnIndexes = currentStructure.rows + .reduce((acc, row) => { + const primary = row[primaryKeyIdx]; + if (primary.type === 'boolean' && primary.value) { + const name = row[nameKeyIdx]; + return name.type === 'string' ? acc.concat(name.value) : acc; + } else { + return acc; + } + }, [] as Array) + .map((name) => columns.indexOf(name)) + .filter((idx) => idx >= 0); + // stop if no primary key to distinguish unique query + if (primaryColumnIndexes.length <= 0) { + return; + } + + const types = currentStructure.rows.reduce((acc, row) => { + const nameValue = row[nameKeyIdx]; + const name = nameValue.type === 'string' ? nameValue.value : null; + const typeValue = row[typeIdx]; + const type = typeValue.type === 'string' ? typeValue.value : null; + const nullableValue = + nullableIdx < 0 ? {type: 'null', value: null} : row[nullableIdx]; + const nullable = nullableValue.value !== false; + if (name !== null && type !== null) { + acc[name] = {type, nullable}; + } + return acc; + }, {} as {[key: string]: {type: string; nullable: boolean}}); + + const changeValue = Object.entries(change).reduce( + (acc, [key, value]: [string, string | null]) => { + acc[key] = convertStringToValue(types, key, value); + return acc; + }, + {} as {[key: string]: Value}, + ); + this.dispatchAction({ + type: 'Execute', + query: constructUpdateQuery( + selectedDatabaseTable, + primaryColumnIndexes.reduce((acc, idx) => { + acc[columns[idx]] = row[idx]; + return acc; + }, {} as {[key: string]: Value}), + changeValue, + ), + }); + this.dispatchAction({ + type: 'UpdatePage', + ...produce(currentPage, (draft) => + Object.entries(changeValue).forEach(([key, value]: [string, Value]) => { + const columnIdx = draft.columns.indexOf(key); + if (columnIdx >= 0) { + draft.rows[highlightedRowIdx][columnIdx] = value; + } + }), + ), + }); + } + renderTable(page: Page | null) { if (!page) { return null;