diff --git a/desktop/plugins/public/databases/DatabasesPlugin.tsx b/desktop/plugins/public/databases/DatabasesPlugin.tsx new file mode 100644 index 000000000..ad676e9eb --- /dev/null +++ b/desktop/plugins/public/databases/DatabasesPlugin.tsx @@ -0,0 +1,813 @@ +/** + * 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 { + ManagedTable, + TableBodyColumn, + TableRows, + TableBodyRow, + TableRowSortOrder, + TableHighlightedRows, +} from 'flipper'; +import { + DatabaseEntry, + Page, + plugin, + Query, + QueryResult, + Structure, +} from './index'; +import {getStringFromErrorLike} from './utils'; +import {Value, renderValue} from './TypeBasedValueRenderer'; +import React, {KeyboardEvent, ChangeEvent, useState, useCallback} from 'react'; +import ButtonNavigation from './ButtonNavigation'; +import DatabaseDetailSidebar from './DatabaseDetailSidebar'; +import DatabaseStructure from './DatabaseStructure'; +import { + convertStringToValue, + constructUpdateQuery, + isUpdatable, +} from './UpdateQueryUtil'; +import sqlFormatter from 'sql-formatter'; +import { + usePlugin, + useValue, + Layout, + useMemoize, + Toolbar, + theme, + styled, + produce, +} from 'flipper-plugin'; +import { + Select, + Radio, + RadioChangeEvent, + Typography, + Button, + Menu, + Dropdown, + Input, +} from 'antd'; +import { + ConsoleSqlOutlined, + DatabaseOutlined, + DownOutlined, + HistoryOutlined, + SettingOutlined, + StarFilled, + StarOutlined, + TableOutlined, +} from '@ant-design/icons'; + +const {TextArea} = Input; + +const {Option} = Select; + +const {Text} = Typography; + +const BoldSpan = styled.span({ + fontSize: 12, + color: '#90949c', + fontWeight: 'bold', + textTransform: 'uppercase', +}); +const ErrorBar = styled.div({ + backgroundColor: theme.errorColor, + color: theme.textColorPrimary, + lineHeight: '26px', + textAlign: 'center', +}); +const PageInfoContainer = styled(Layout.Horizontal)({alignItems: 'center'}); + +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}; +} + +const QueryHistory = React.memo(({history}: {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 (let i = 0; i < history.length; i++) { + const query = history[i]; + const time = query.time; + const value = query.value; + rows.push({ + key: `${i}`, + columns: {time: {value: time}, query: {value: value}}, + }); + } + } + + return ( + + + + ); +}); + +type PageInfoProps = { + currentRow: number; + count: number; + totalRows: number; + onChange: (currentRow: number, count: number) => void; +}; + +const PageInfo = React.memo((props: PageInfoProps) => { + const [state, setState] = useState({ + isOpen: false, + inputValue: String(props.currentRow), + }); + + const onOpen = useCallback(() => { + setState({...state, isOpen: true}); + }, [state]); + + const onInputChanged = useCallback( + (e: ChangeEvent) => { + setState({...state, inputValue: e.target.value}); + }, + [state], + ); + + const onSubmit = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Enter') { + const rowNumber = parseInt(state.inputValue, 10); + props.onChange(rowNumber - 1, props.count); + setState({...state, isOpen: false}); + } + }, + [props, state], + ); + + return ( + +
+ + {props.count === props.totalRows + ? `${props.count} ` + : `${props.currentRow + 1}-${props.currentRow + props.count} `} + of {props.totalRows} rows + +
+ {state.isOpen ? ( + + ) : ( + + )} + + ); +}); + +const DataTable = React.memo( + ({ + page, + highlightedRowsChanged, + sortOrderChanged, + currentSort, + currentStructure, + onRowEdited, + }: { + page: Page | null; + highlightedRowsChanged: (highlightedRows: TableHighlightedRows) => void; + sortOrderChanged: (sortOrder: TableRowSortOrder) => void; + currentSort: TableRowSortOrder | null; + currentStructure: Structure | null; + onRowEdited: (changes: {[key: string]: string | null}) => void; + }) => + page ? ( + + ({ + 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={highlightedRowsChanged} + onSort={sortOrderChanged} + initialSortOrder={currentSort ?? undefined} + /> + {page.highlightedRows.length === 1 && ( + + )} + + ) : null, +); + +const QueryTable = React.memo( + ({ + query, + highlightedRowsChanged, + }: { + query: QueryResult | null; + highlightedRowsChanged: (highlightedRows: TableHighlightedRows) => void; + }) => { + 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={highlightedRowsChanged} + /> + {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; + } + }, +); + +const FavoritesMenu = React.memo( + ({ + favorites, + onClick, + }: { + favorites: string[]; + onClick: (value: string) => void; + }) => { + const onMenuClick = useCallback((p: any) => onClick(p.key as string), [ + onClick, + ]); + return ( + + {favorites.map((q) => ( + + {q} + + ))} + + ); + }, +); + +export function Component() { + const instance = usePlugin(plugin); + const state = useValue(instance.state); + const favorites = useValue(instance.favoritesState); + + const onViewModeChanged = useCallback( + (evt: RadioChangeEvent) => { + instance.updateViewMode({viewMode: evt.target.value ?? 'data'}); + }, + [instance], + ); + + const onDataClicked = useCallback(() => { + instance.updateViewMode({viewMode: 'data'}); + }, [instance]); + + const onStructureClicked = useCallback(() => { + instance.updateViewMode({viewMode: 'structure'}); + }, [instance]); + + const onSQLClicked = useCallback(() => { + instance.updateViewMode({viewMode: 'SQL'}); + }, [instance]); + + const onTableInfoClicked = useCallback(() => { + instance.updateViewMode({viewMode: 'tableInfo'}); + }, [instance]); + + const onQueryHistoryClicked = useCallback(() => { + instance.updateViewMode({viewMode: 'queryHistory'}); + }, [instance]); + + const onRefreshClicked = useCallback(() => { + instance.state.update((state) => { + state.error = null; + }); + instance.refresh(); + }, [instance]); + + const onFavoriteButtonClicked = useCallback(() => { + if (state.query) { + instance.addOrRemoveQueryToFavorites(state.query.value); + } + }, [instance, state.query]); + + const onDatabaseSelected = useCallback( + (selected: string) => { + const dbId = + instance.state.get().databases.find((x) => x.name === selected)?.id || + 0; + instance.updateSelectedDatabase({ + database: dbId, + }); + }, + [instance], + ); + + const onDatabaseTableSelected = useCallback( + (selected: string) => { + instance.updateSelectedDatabaseTable({ + table: selected, + }); + }, + [instance], + ); + + const onNextPageClicked = useCallback(() => { + instance.nextPage(); + }, [instance]); + + const onPreviousPageClicked = useCallback(() => { + instance.previousPage(); + }, [instance]); + + const onExecuteClicked = useCallback(() => { + const query = instance.state.get().query; + if (query) { + instance.execute({query: query.value}); + } + }, [instance]); + + const onQueryTextareaKeyPress = useCallback( + (event: KeyboardEvent) => { + // Implement ctrl+enter as a shortcut for clicking 'Execute'. + if (event.key === '\n' && event.ctrlKey) { + event.preventDefault(); + event.stopPropagation(); + onExecuteClicked(); + } + }, + [onExecuteClicked], + ); + + const onGoToRow = useCallback( + (row: number, _count: number) => { + instance.goToRow({row: row}); + }, + [instance], + ); + + const onQueryChanged = useCallback( + (selected: any) => { + instance.updateQuery({ + value: selected.target.value, + }); + }, + [instance], + ); + + const onFavoriteQuerySelected = useCallback( + (query: string) => { + instance.updateQuery({ + value: query, + }); + }, + [instance], + ); + + const pageHighlightedRowsChanged = useCallback( + (rows: TableHighlightedRows) => { + instance.pageHighlightedRowsChanged(rows); + }, + [instance], + ); + + const queryHighlightedRowsChanged = useCallback( + (rows: TableHighlightedRows) => { + instance.queryHighlightedRowsChanged(rows); + }, + [instance], + ); + + const sortOrderChanged = useCallback( + (sortOrder: TableRowSortOrder) => { + instance.sortByChanged({sortOrder}); + }, + [instance], + ); + + const onRowEdited = useCallback( + (change: {[key: string]: string | null}) => { + const { + selectedDatabaseTable, + currentStructure, + viewMode, + currentPage, + } = instance.state.get(); + 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}, + ); + instance.execute({ + query: constructUpdateQuery( + selectedDatabaseTable, + primaryColumnIndexes.reduce((acc, idx) => { + acc[columns[idx]] = row[idx]; + return acc; + }, {} as {[key: string]: Value}), + changeValue, + ), + }); + instance.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; + } + }, + ), + ), + }); + }, + [instance], + ); + + const databaseOptions = useMemoize( + (databases) => + databases.map((x) => ( + + )), + [state.databases], + ); + + const selectedDatabaseName = useMemoize( + (selectedDatabase: number, databases: DatabaseEntry[]) => + selectedDatabase && databases[state.selectedDatabase - 1] + ? databases[selectedDatabase - 1].name + : undefined, + [state.selectedDatabase, state.databases], + ); + + const tableOptions = useMemoize( + (selectedDatabase: number, databases: DatabaseEntry[]) => + selectedDatabase && databases[state.selectedDatabase - 1] + ? databases[selectedDatabase - 1].tables.map((tableName) => ( + + )) + : [], + [state.selectedDatabase, state.databases], + ); + + const selectedTableName = useMemoize( + ( + selectedDatabase: number, + databases: DatabaseEntry[], + selectedDatabaseTable: string | null, + ) => + selectedDatabase && databases[selectedDatabase - 1] + ? databases[selectedDatabase - 1].tables.find( + (t) => t === selectedDatabaseTable, + ) ?? databases[selectedDatabase - 1].tables[0] + : undefined, + [state.selectedDatabase, state.databases, state.selectedDatabaseTable], + ); + + return ( + + + + + + Data + + + + Structure + + + + SQL + + + + Table Info + + + + Query History + + + + {state.viewMode === 'data' || + state.viewMode === 'structure' || + state.viewMode === 'tableInfo' ? ( + + Database + + Table + +
+ + + ) : null} + {state.viewMode === 'SQL' ? ( + + + Database + + + +