/** * 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 { styled, produce, Toolbar, Select, FlexColumn, FlexRow, ManagedTable, Text, Button, ButtonGroup, Input, colors, getStringFromErrorLike, Spacer, Textarea, TableBodyColumn, TableRows, Props as FlipperPluginProps, TableBodyRow, TableRowSortOrder, FlipperPlugin, Value, renderValue, } from 'flipper'; import React, {Component, KeyboardEvent, ChangeEvent} from 'react'; import {DatabaseClient} from './ClientProtocol'; import ButtonNavigation from './ButtonNavigation'; import DatabaseDetailSidebar from './DatabaseDetailSidebar'; import DatabaseStructure from './DatabaseStructure'; import { convertStringToValue, constructUpdateQuery, isUpdatable, } from './UpdateQueryUtil'; import sqlFormatter from 'sql-formatter'; import dateFormat from 'dateformat'; const PAGE_SIZE = 50; const BoldSpan = styled.span({ fontSize: 12, color: '#90949c', fontWeight: 'bold', textTransform: 'uppercase', }); const ErrorBar = styled.div({ backgroundColor: colors.cherry, color: colors.white, lineHeight: '26px', textAlign: 'center', }); const QueryHistoryManagedTable = styled(ManagedTable)({paddingLeft: 16}); const PageInfoContainer = styled(FlexRow)({alignItems: 'center'}); const TableInfoTextArea = styled(Textarea)({ width: '98%', height: '100%', marginLeft: '1%', marginTop: '1%', marginBottom: '1%', readOnly: true, }); type DatabasesPluginState = { selectedDatabase: number; selectedDatabaseTable: string | null; pageRowNumber: number; databases: Array; outdatedDatabaseList: boolean; viewMode: 'data' | 'structure' | 'SQL' | 'tableInfo' | 'queryHistory'; error: null; currentPage: Page | null; currentStructure: Structure | null; currentSort: TableRowSortOrder | null; query: Query | null; queryResult: QueryResult | null; favorites: Array; executionTime: number; tableInfo: string; queryHistory: Array; }; type Page = { databaseId: number; table: string; columns: Array; rows: Array>; start: number; count: number; total: number; highlightedRows: Array; editable: boolean; }; export type Structure = { databaseId: number; table: string; columns: Array; rows: Array>; indexesColumns: Array; indexesValues: Array>; }; type QueryResult = { table: QueriedTable | null; id: number | null; count: number | null; }; export type QueriedTable = { columns: Array; rows: Array>; highlightedRows: Array; }; type Actions = | SelectDatabaseEvent | SelectDatabaseTableEvent | UpdateDatabasesEvent | UpdateViewModeEvent | UpdatePageEvent | UpdateStructureEvent | DisplaySelectEvent | DisplayInsertEvent | DisplayUpdateDeleteEvent | UpdateTableInfoEvent | NextPageEvent | PreviousPageEvent | ExecuteEvent | RefreshEvent | UpdateFavoritesEvent | SortByChangedEvent | GoToRowEvent | UpdateQueryEvent; type DatabaseEntry = { id: number; name: string; tables: Array; }; type Query = { value: string; time: string; }; type UpdateDatabasesEvent = { databases: Array<{name: string; id: number; tables: Array}>; type: 'UpdateDatabases'; }; type SelectDatabaseEvent = { type: 'UpdateSelectedDatabase'; database: number; }; type SelectDatabaseTableEvent = { type: 'UpdateSelectedDatabaseTable'; table: string; }; type UpdateViewModeEvent = { type: 'UpdateViewMode'; viewMode: 'data' | 'structure' | 'SQL' | 'tableInfo' | 'queryHistory'; }; type UpdatePageEvent = { type: 'UpdatePage'; } & Page; type UpdateStructureEvent = { type: 'UpdateStructure'; databaseId: number; table: string; columns: Array; rows: Array>; indexesColumns: Array; indexesValues: Array>; }; type DisplaySelectEvent = { type: 'DisplaySelect'; columns: Array; values: Array>; }; type DisplayInsertEvent = { type: 'DisplayInsert'; id: number; }; type DisplayUpdateDeleteEvent = { type: 'DisplayUpdateDelete'; count: number; }; type UpdateTableInfoEvent = { type: 'UpdateTableInfo'; tableInfo: string; }; type NextPageEvent = { type: 'NextPage'; }; type PreviousPageEvent = { type: 'PreviousPage'; }; type ExecuteEvent = { type: 'Execute'; query: string; }; type RefreshEvent = { type: 'Refresh'; }; type UpdateFavoritesEvent = { type: 'UpdateFavorites'; favorites: Array | undefined; }; type SortByChangedEvent = { type: 'SortByChanged'; sortOrder: TableRowSortOrder; }; type GoToRowEvent = { type: 'GoToRow'; row: number; }; type UpdateQueryEvent = { type: 'UpdateQuery'; value: string; }; function transformRow( columns: Array, row: Array, index: number, ): TableBodyRow { const transformedColumns: {[key: string]: TableBodyColumn} = {}; for (let i = 0; i < columns.length; i++) { transformedColumns[columns[i]] = {value: renderValue(row[i], true)}; } return {key: String(index), columns: transformedColumns}; } function renderQueryHistory(history: Array) { if (!history || typeof history === 'undefined') { return null; } const columns = { time: { value: 'Time', resizable: true, }, query: { value: 'Query', resizable: true, }, }; const rows: TableRows = []; if (history.length > 0) { for (const query of history) { const time = query.time; const value = query.value; rows.push({ key: value, columns: {time: {value: time}, query: {value: value}}, }); } } return ( ); } type PageInfoProps = { currentRow: number; count: number; totalRows: number; onChange: (currentRow: number, count: number) => void; }; class PageInfo extends Component< PageInfoProps, {isOpen: boolean; inputValue: string} > { constructor(props: PageInfoProps) { super(props); this.state = {isOpen: false, inputValue: String(props.currentRow)}; } onOpen() { this.setState({isOpen: true}); } onInputChanged(e: ChangeEvent) { this.setState({inputValue: e.target.value}); } onSubmit(e: KeyboardEvent) { if (e.key === 'Enter') { const rowNumber = parseInt(this.state.inputValue, 10); this.props.onChange(rowNumber - 1, this.props.count); this.setState({isOpen: false}); } } render() { return (
{this.props.count === this.props.totalRows ? `${this.props.count} ` : `${this.props.currentRow + 1}-${ this.props.currentRow + this.props.count } `} of {this.props.totalRows} rows
{this.state.isOpen ? ( ) : ( )} ); } } export default class DatabasesPlugin extends FlipperPlugin< DatabasesPluginState, Actions, {} > { databaseClient: DatabaseClient; state: DatabasesPluginState = { selectedDatabase: 0, selectedDatabaseTable: null, pageRowNumber: 0, databases: [], outdatedDatabaseList: true, viewMode: 'data', error: null, currentPage: null, currentStructure: null, currentSort: null, query: null, queryResult: null, favorites: [], executionTime: 0, tableInfo: '', queryHistory: [], }; reducers = ([ [ 'UpdateDatabases', ( state: DatabasesPluginState, results: UpdateDatabasesEvent, ): DatabasesPluginState => { const updates = results.databases; const databases = updates.sort((db1, db2) => db1.id - db2.id); const selectedDatabase = state.selectedDatabase || (Object.values(databases)[0] ? // $FlowFixMe Object.values(databases)[0].id : 0); const selectedTable = state.selectedDatabaseTable && databases[selectedDatabase - 1].tables.includes( state.selectedDatabaseTable, ) ? state.selectedDatabaseTable : databases[selectedDatabase - 1].tables[0]; const sameTableSelected = selectedDatabase === state.selectedDatabase && selectedTable === state.selectedDatabaseTable; return { ...state, databases, outdatedDatabaseList: false, selectedDatabase: selectedDatabase, selectedDatabaseTable: selectedTable, pageRowNumber: 0, currentPage: sameTableSelected ? state.currentPage : null, currentStructure: null, currentSort: sameTableSelected ? state.currentSort : null, }; }, ], [ 'UpdateSelectedDatabase', ( state: DatabasesPluginState, event: SelectDatabaseEvent, ): DatabasesPluginState => { return { ...state, selectedDatabase: event.database, selectedDatabaseTable: state.databases[event.database - 1].tables[0] || null, pageRowNumber: 0, currentPage: null, currentStructure: null, currentSort: null, }; }, ], [ 'UpdateSelectedDatabaseTable', ( state: DatabasesPluginState, event: SelectDatabaseTableEvent, ): DatabasesPluginState => { return { ...state, selectedDatabaseTable: event.table, pageRowNumber: 0, currentPage: null, currentStructure: null, currentSort: null, }; }, ], [ 'UpdateViewMode', ( state: DatabasesPluginState, event: UpdateViewModeEvent, ): DatabasesPluginState => { return { ...state, viewMode: event.viewMode, error: null, }; }, ], [ 'UpdatePage', ( state: DatabasesPluginState, event: UpdatePageEvent, ): DatabasesPluginState => { return { ...state, currentPage: event, }; }, ], [ 'UpdateStructure', ( state: DatabasesPluginState, event: UpdateStructureEvent, ): DatabasesPluginState => { return { ...state, currentStructure: { databaseId: event.databaseId, table: event.table, columns: event.columns, rows: event.rows, indexesColumns: event.indexesColumns, indexesValues: event.indexesValues, }, currentPage: { ...state.currentPage!, editable: isUpdatable(event.columns, event.rows), }, }; }, ], [ 'DisplaySelect', ( state: DatabasesPluginState, event: DisplaySelectEvent, ): DatabasesPluginState => { return { ...state, queryResult: { table: { columns: event.columns, rows: event.values, highlightedRows: [], }, id: null, count: null, }, }; }, ], [ 'DisplayInsert', ( state: DatabasesPluginState, event: DisplayInsertEvent, ): DatabasesPluginState => { return { ...state, queryResult: { table: null, id: event.id, count: null, }, }; }, ], [ 'DisplayUpdateDelete', ( state: DatabasesPluginState, event: DisplayUpdateDeleteEvent, ): DatabasesPluginState => { return { ...state, queryResult: { table: null, id: null, count: event.count, }, }; }, ], [ 'UpdateTableInfo', ( state: DatabasesPluginState, event: UpdateTableInfoEvent, ): DatabasesPluginState => { return { ...state, tableInfo: event.tableInfo, }; }, ], [ 'NextPage', ( state: DatabasesPluginState, _event: UpdatePageEvent, ): DatabasesPluginState => { return { ...state, pageRowNumber: state.pageRowNumber + PAGE_SIZE, currentPage: null, }; }, ], [ 'PreviousPage', ( state: DatabasesPluginState, _event: UpdatePageEvent, ): DatabasesPluginState => { return { ...state, pageRowNumber: Math.max(state.pageRowNumber - PAGE_SIZE, 0), currentPage: null, }; }, ], [ 'Execute', ( state: DatabasesPluginState, event: ExecuteEvent, ): DatabasesPluginState => { const timeBefore = Date.now(); const {query} = event; this.databaseClient .getExecution({databaseId: state.selectedDatabase, value: query}) .then((data) => { this.setState({ error: null, executionTime: Date.now() - timeBefore, }); if (data.type === 'select') { this.dispatchAction({ type: 'DisplaySelect', columns: data.columns, values: data.values, }); } else if (data.type === 'insert') { this.dispatchAction({ type: 'DisplayInsert', id: data.insertedId, }); } else if (data.type === 'update_delete') { this.dispatchAction({ type: 'DisplayUpdateDelete', count: data.affectedCount, }); } }) .catch((e) => { this.setState({error: e}); }); let newHistory = this.state.queryHistory; const newQuery = this.state.query; if ( newQuery !== null && typeof newQuery !== 'undefined' && newHistory !== null && typeof newHistory !== 'undefined' ) { newQuery.time = dateFormat(new Date(), 'hh:MM:ss'); newHistory = newHistory.concat(newQuery); } return { ...state, queryHistory: newHistory, }; }, ], [ 'GoToRow', ( state: DatabasesPluginState, event: GoToRowEvent, ): DatabasesPluginState => { if (!state.currentPage) { return state; } const destinationRow = event.row < 0 ? 0 : event.row >= state.currentPage.total - PAGE_SIZE ? Math.max(state.currentPage.total - PAGE_SIZE, 0) : event.row; return { ...state, pageRowNumber: destinationRow, currentPage: null, }; }, ], [ 'Refresh', ( state: DatabasesPluginState, _event: RefreshEvent, ): DatabasesPluginState => { return { ...state, outdatedDatabaseList: true, currentPage: null, }; }, ], [ 'UpdateFavorites', ( state: DatabasesPluginState, event: UpdateFavoritesEvent, ): DatabasesPluginState => { let newFavorites = event.favorites || state.favorites; if ( state.query && state.query !== null && typeof state.query !== 'undefined' ) { const value = state.query.value; if (newFavorites.includes(value)) { const index = newFavorites.indexOf(value); newFavorites.splice(index, 1); } else { newFavorites = state.favorites.concat(value); } } window.localStorage.setItem( 'plugin-database-favorites-sql-queries', JSON.stringify(newFavorites), ); return { ...state, favorites: newFavorites, }; }, ], [ 'UpdateViewMode', ( state: DatabasesPluginState, event: UpdateViewModeEvent, ): DatabasesPluginState => { return { ...state, viewMode: event.viewMode, error: null, }; }, ], [ 'SortByChanged', (state: DatabasesPluginState, event: SortByChangedEvent) => { return { ...state, currentSort: event.sortOrder, pageRowNumber: 0, currentPage: null, }; }, ], [ 'UpdateQuery', (state: DatabasesPluginState, event: UpdateQueryEvent) => { return { ...state, query: { value: event.value, time: dateFormat(new Date(), 'hh:MM:ss'), }, }; }, ], ] as Array< [ string, ( state: DatabasesPluginState, event: UpdateQueryEvent, ) => DatabasesPluginState, ] >).reduce( ( acc: { [name: string]: ( state: DatabasesPluginState, event: UpdateQueryEvent, ) => DatabasesPluginState; }, val: [ string, ( state: DatabasesPluginState, event: UpdateQueryEvent, ) => DatabasesPluginState, ], ) => { const name = val[0]; const f = val[1]; acc[name] = (previousState, event) => { const newState = f(previousState, event); this.onStateChanged(previousState, newState); return newState; }; return acc; }, {}, ); onStateChanged( previousState: DatabasesPluginState, newState: DatabasesPluginState, ) { const databaseId = newState.selectedDatabase; const table = newState.selectedDatabaseTable; if ( newState.viewMode === 'data' && newState.currentPage === null && databaseId && table ) { this.databaseClient .getTableData({ count: PAGE_SIZE, databaseId: newState.selectedDatabase, order: newState.currentSort?.key, reverse: (newState.currentSort?.direction || 'up') === 'down', table: table, start: newState.pageRowNumber, }) .then((data) => { this.dispatchAction({ type: 'UpdatePage', databaseId: databaseId, table: table, columns: data.columns, rows: data.values, start: data.start, count: data.count, total: data.total, highlightedRows: [], editable: false, }); }) .catch((e) => { this.setState({error: e}); }); } if (newState.currentStructure === null && databaseId && table) { this.databaseClient .getTableStructure({ databaseId: databaseId, table: table, }) .then((data) => { this.dispatchAction({ type: 'UpdateStructure', databaseId: databaseId, table: table, columns: data.structureColumns, rows: data.structureValues, indexesColumns: data.indexesColumns, indexesValues: data.indexesValues, }); }) .catch((e) => { this.setState({error: e}); }); } if ( newState.viewMode === 'tableInfo' && newState.currentStructure === null && databaseId && table ) { this.databaseClient .getTableInfo({ databaseId: databaseId, table: table, }) .then((data) => { this.dispatchAction({ type: 'UpdateTableInfo', tableInfo: data.definition, }); }) .catch((e) => { this.setState({error: e}); }); } if (!previousState.outdatedDatabaseList && newState.outdatedDatabaseList) { this.databaseClient.getDatabases({}).then((databases) => { this.dispatchAction({ type: 'UpdateDatabases', databases, }); }); } } // to keep eslint happy constructor(props: FlipperPluginProps<{}>) { super(props); this.databaseClient = new DatabaseClient(this.client); } init() { this.databaseClient = new DatabaseClient(this.client); this.databaseClient.getDatabases({}).then((databases) => { this.dispatchAction({ type: 'UpdateDatabases', databases, }); }); this.dispatchAction({ type: 'UpdateFavorites', favorites: JSON.parse( localStorage.getItem('plugin-database-favorites-sql-queries') || '[]', ), }); } onDataClicked = () => { this.dispatchAction({type: 'UpdateViewMode', viewMode: 'data'}); }; onStructureClicked = () => { this.dispatchAction({type: 'UpdateViewMode', viewMode: 'structure'}); }; onSQLClicked = () => { this.dispatchAction({type: 'UpdateViewMode', viewMode: 'SQL'}); }; onTableInfoClicked = () => { this.dispatchAction({type: 'UpdateViewMode', viewMode: 'tableInfo'}); }; onQueryHistoryClicked = () => { this.dispatchAction({type: 'UpdateViewMode', viewMode: 'queryHistory'}); }; onRefreshClicked = () => { this.setState({error: null}); this.dispatchAction({type: 'Refresh'}); }; onFavoritesClicked = () => { this.dispatchAction({ type: 'UpdateFavorites', favorites: this.state.favorites, }); }; onDatabaseSelected = (selected: string) => { const dbId = this.state.databases.find((x) => x.name === selected)?.id || 0; this.dispatchAction({ database: dbId, type: 'UpdateSelectedDatabase', }); }; onDatabaseTableSelected = (selected: string) => { this.dispatchAction({ table: selected, type: 'UpdateSelectedDatabaseTable', }); }; onNextPageClicked = () => { this.dispatchAction({type: 'NextPage'}); }; onPreviousPageClicked = () => { this.dispatchAction({type: 'PreviousPage'}); }; onExecuteClicked = () => { if (this.state.query !== null) { this.dispatchAction({type: 'Execute', query: this.state.query.value}); } }; onQueryTextareaKeyPress = (event: KeyboardEvent) => { // Implement ctrl+enter as a shortcut for clicking 'Execute'. if (event.key === '\n' && event.ctrlKey) { event.preventDefault(); event.stopPropagation(); this.onExecuteClicked(); } }; onFavoriteClicked = (selected: any) => { this.setState({query: selected.target.value}); }; onGoToRow = (row: number, _count: number) => { this.dispatchAction({type: 'GoToRow', row: row}); }; onQueryChanged = (selected: any) => { this.dispatchAction({ type: 'UpdateQuery', value: selected.target.value, }); }; 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; } return ( ({ key: name, visible: true, }))} columns={page.columns.reduce( (acc, val) => Object.assign({}, acc, { [val]: {value: val, resizable: true, sortable: true}, }), {}, )} zebra={true} rows={page.rows.map((row: Array, index: number) => transformRow(page.columns, row, index), )} horizontallyScrollable={true} multiHighlight={true} onRowHighlighted={(highlightedRows) => this.setState( produce((draftState: DatabasesPluginState) => { if (draftState.currentPage !== null) { draftState.currentPage.highlightedRows = highlightedRows.map( parseInt, ); } }), ) } onSort={(sortOrder: TableRowSortOrder) => { this.dispatchAction({ type: 'SortByChanged', sortOrder, }); }} initialSortOrder={this.state.currentSort ?? undefined} /> {page.highlightedRows.length === 1 && ( )} ); } renderQuery(query: QueryResult | null) { if (!query || query === null) { return null; } if ( query.table && typeof query.table !== 'undefined' && query.table !== null ) { const table = query.table; const columns = table.columns; const rows = table.rows; return ( ({ key: name, visible: true, }))} columns={columns.reduce( (acc, val) => Object.assign({}, acc, {[val]: {value: val, resizable: true}}), {}, )} zebra={true} rows={rows.map((row: Array, index: number) => transformRow(columns, row, index), )} horizontallyScrollable={true} onRowHighlighted={(highlightedRows) => { this.setState({ queryResult: { table: { columns: columns, rows: rows, highlightedRows: highlightedRows.map(parseInt), }, id: null, count: null, }, }); }} /> {table.highlightedRows.length === 1 && ( )} ); } else if (query.id && query.id !== null) { return ( Row id: {query.id} ); } else if (query.count && query.count !== null) { return ( Rows affected: {query.count} ); } else { return null; } } render() { const tableOptions = (this.state.selectedDatabase && this.state.databases[this.state.selectedDatabase - 1] && this.state.databases[this.state.selectedDatabase - 1].tables.reduce( (options, tableName) => ({...options, [tableName]: tableName}), {}, )) || {}; return ( {this.state.viewMode === 'data' || this.state.viewMode === 'structure' || this.state.viewMode === 'tableInfo' ? ( Database
) : null} {this.state.viewMode === 'SQL' ? (
Database