/** * 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, Toolbar, Select, FlexColumn, FlexRow, ManagedTable, Text, Button, ButtonGroup, Input, colors, getStringFromErrorLike, Spacer, Textarea, DetailSidebar, Panel, ManagedDataInspector, } from 'flipper'; import {Component} from 'react'; import type {TableBodyRow, TableRowSortOrder} from 'flipper'; import {FlipperPlugin} from 'flipper'; import {DatabaseClient} from './ClientProtocol'; import {renderValue} from 'flipper'; import type {Value} from 'flipper'; import ButtonNavigation from './ButtonNavigation'; 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', }); type DatabasesPluginState = {| selectedDatabase: number, selectedDatabaseTable: ?string, pageRowNumber: number, databases: Array, outdatedDatabaseList: boolean, viewMode: 'data' | 'structure' | 'SQL' | 'tableInfo' | 'queryHistory', error: ?null, currentPage: ?Page, currentStructure: ?Structure, currentSort: ?TableRowSortOrder, query: ?Query, queryResult: ?QueryResult, favorites: Array, executionTime: number, tableInfo: string, queryHistory: Array, |}; type Page = { databaseId: number, table: string, columns: Array, rows: Array, start: number, count: number, total: number, }; type Structure = {| databaseId: number, table: string, columns: Array, rows: Array, indexesColumns: Array, indexesValues: Array, |}; type QueryResult = { table: ?QueriedTable, id: ?number, count: ?number, }; 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', databaseId: number, table: string, columns: Array, values: Array>, start: number, count: number, total: number, |}; 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', }; type RefreshEvent = { type: 'Refresh', }; type UpdateFavoritesEvent = { type: 'UpdateFavorites', favorites: ?Array, }; 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 = {}; for (let i = 0; i < columns.length; i++) { transformedColumns[columns[i]] = {value: renderValue(row[i])}; } return {key: String(index), columns: transformedColumns}; } function renderTable(page: ?Page, component: DatabasesPlugin) { if (!page) { return null; } return ( ({ key: name, visible: true, }))} columns={page.columns.reduce((acc, val) => { acc[val] = {value: val, resizable: true, sortable: true}; return acc; }, {})} zebra={true} rows={page.rows} horizontallyScrollable={true} multiHighlight={true} onSort={(sortOrder: TableRowSortOrder) => { component.dispatchAction({ type: 'SortByChanged', sortOrder, }); }} initialSortOrder={component.state.currentSort} /> ); } function renderDatabaseColumns(structure: ?Structure) { if (!structure) { return null; } return ( ({ key: name, visible: true, }))} columns={structure.columns.reduce((acc, val) => { acc[val] = {value: val, resizable: true}; return acc; }, {})} zebra={true} rows={structure.rows || []} horizontallyScrollable={true} /> ); } function renderDatabaseIndexes(structure: ?Structure) { if (!structure) { return null; } return ( ({ key: name, visible: true, }))} columns={structure.indexesColumns.reduce((acc, val) => { acc[val] = {value: val, resizable: true}; return acc; }, {})} zebra={true} rows={structure.indexesValues || []} horizontallyScrollable={true} /> ); } 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 = []; if (history.length > 0) { for (const query of history) { const time = query.time; const value = query.value; rows.push({ key: query, 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) { this.setState({inputValue: e.target.value}); } onSubmit(e: SyntheticKeyboardEvent<>) { 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; 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: { rows: event.values.map((row: Array, index: number) => transformRow(event.columns, row, index), ), ...event, }, }; }, ], [ 'UpdateStructure', ( state: DatabasesPluginState, event: UpdateStructureEvent, ): DatabasesPluginState => { return { ...state, currentStructure: { databaseId: event.databaseId, table: event.table, columns: event.columns, rows: event.rows.map((row: Array, index: number) => transformRow(event.columns, row, index), ), indexesColumns: event.indexesColumns, indexesValues: event.indexesValues.map( (row: Array, index: number) => transformRow(event.indexesColumns, row, index), ), }, }; }, ], [ 'DisplaySelect', ( state: DatabasesPluginState, event: DisplaySelectEvent, ): DatabasesPluginState => { return { ...state, queryResult: { table: { columns: event.columns, rows: event.values.map((row: Array, index: number) => transformRow(event.columns, row, index), ), 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, results: ExecuteEvent, ): DatabasesPluginState => { const timeBefore = Date.now(); if ( this.state.query !== null && typeof this.state.query !== 'undefined' ) { this.databaseClient .getExecution({ databaseId: state.selectedDatabase, value: this.state.query.value, }) .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'), }, }; }, ], ].reduce((acc, val) => { 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, values: data.values, start: data.start, count: data.count, total: data.total, }); }) .catch((e) => { this.setState({error: e}); }); } if ( newState.viewMode === 'structure' && 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, }); }); } } 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 = () => { this.dispatchAction({type: 'Execute'}); }; 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, }); }; renderStructure() { return ( <> {renderDatabaseColumns(this.state.currentStructure)} {renderDatabaseIndexes(this.state.currentStructure)} ); } renderSidebar = (table: QueriedTable) => { if ( table.highlightedRows === null || typeof table.highlightedRows === 'undefined' || table.highlightedRows.length !== 1 ) { return null; } const id = table.highlightedRows[0]; const cols = { col: { value: 'Column', resizable: true, }, val: { value: 'Value', resizable: true, }, }; const colSizes = { col: '35%', val: 'flex', }; return ( ); }; sidebarRows = (id: number, table: QueriedTable) => { const columns = table.columns; const row = table.rows[id]; if (columns.length === 1) { const sidebarArray = []; // TODO(T60896483): Narrow the scope of this try/catch block. try { const parsed = JSON.parse(row.columns[columns[0]].value.props.children); for (const key in parsed) { sidebarArray.push( this.buildSidebarRow(key, { props: { children: parsed[key], }, }), ); } } catch (e) { sidebarArray.push( this.buildSidebarRow(columns[0], row.columns[columns[0]].value), ); } return sidebarArray; } else { return columns.map((column, i) => this.buildSidebarRow(columns[i], row.columns[columns[i]].value), ); } }; buildSidebarRow = (key: string, val: any) => { let output = ''; // TODO(T60896483): Narrow the scope of this try/catch block. try { const parsed = JSON.parse(val.props.children); for (const key in parsed) { try { parsed[key] = JSON.parse(parsed[key]); } catch (err) {} } output = ( ); } catch (error) { output = val; } return { columns: { col: {value: {key}}, val: { value: output, }, }, key: key, }; }; renderQuery(query: ?QueryResult) { 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) => { acc[val] = {value: val, resizable: true}; return acc; }, {})} zebra={true} rows={rows} horizontallyScrollable={true} onRowHighlighted={(highlightedRows) => { this.setState({ queryResult: { table: { columns: columns, rows: rows, highlightedRows: highlightedRows, }, id: null, count: null, }, }); }} /> {this.renderSidebar(table)} ); } 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