desktop/plugins/public/databases/DatabasesPlugin.tsx (750 lines of code) (raw):

/** * Copyright (c) Meta Platforms, Inc. and 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<string>, row: Array<Value>, 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<Query>}) => { 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 ( <Layout.Horizontal grow> <ManagedTable floating={false} columns={columns} columnSizes={{time: 75}} zebra rows={rows} horizontallyScrollable /> </Layout.Horizontal> ); }); 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<any>) => { 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 ( <PageInfoContainer grow> <div style={{flex: 1}} /> <Text> {props.count === props.totalRows ? `${props.count} ` : `${props.currentRow + 1}-${props.currentRow + props.count} `} of {props.totalRows} rows </Text> <div style={{flex: 1}} /> {state.isOpen ? ( <Input tabIndex={-1} placeholder={(props.currentRow + 1).toString()} onChange={onInputChanged} onKeyDown={onSubmit} /> ) : ( <Button style={{textAlign: 'center'}} onClick={onOpen}> Go To Row </Button> )} </PageInfoContainer> ); }); 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 && page.columns ? ( <Layout.Horizontal grow> <ManagedTable tableKey={`databases-${page.databaseId}-${page.table}`} floating={false} columnOrder={page.columns.map((name) => ({ key: name, visible: true, }))} columns={page.columns.reduce( (acc, val) => Object.assign({}, acc, { [val]: {value: val, resizable: true, sortable: true}, }), {}, )} zebra rows={page.rows.map((row: Array<Value>, index: number) => transformRow(page.columns, row, index), )} horizontallyScrollable multiHighlight onRowHighlighted={highlightedRowsChanged} onSort={sortOrderChanged} initialSortOrder={currentSort ?? undefined} /> {page.highlightedRows.length === 1 && ( <DatabaseDetailSidebar columnLabels={page.columns} columnValues={page.rows[page.highlightedRows[0]]} onSave={ currentStructure && isUpdatable(currentStructure.columns, currentStructure.rows) ? onRowEdited : undefined } /> )} </Layout.Horizontal> ) : 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 ( <Layout.Container grow> <ManagedTable floating={false} multiline columnOrder={columns.map((name) => ({ key: name, visible: true, }))} columns={columns.reduce( (acc, val) => Object.assign({}, acc, {[val]: {value: val, resizable: true}}), {}, )} zebra rows={rows.map((row: Array<Value>, index: number) => transformRow(columns, row, index), )} horizontallyScrollable onRowHighlighted={highlightedRowsChanged} /> {table.highlightedRows.length === 1 && ( <DatabaseDetailSidebar columnLabels={table.columns} columnValues={table.rows[table.highlightedRows[0]]} /> )} </Layout.Container> ); } else if (query.id && query.id !== null) { return ( <Layout.Horizontal grow pad> <Text>Row id: {query.id}</Text> </Layout.Horizontal> ); } else if (query.count && query.count !== null) { return ( <Layout.Horizontal grow pad> <Text>Rows affected: {query.count}</Text> </Layout.Horizontal> ); } 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 ( <Menu> {favorites.map((q) => ( <Menu.Item key={q} onClick={onMenuClick}> {q} </Menu.Item> ))} </Menu> ); }, ); 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<string>) .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) => ( <Option key={x.name} value={x.name} label={x.name}> {x.name} </Option> )), [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) => ( <Option key={tableName} value={tableName} label={tableName}> {tableName} </Option> )) : [], [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 ( <Layout.Container grow> <Toolbar position="top"> <Radio.Group value={state.viewMode} onChange={onViewModeChanged}> <Radio.Button value="data" onClick={onDataClicked}> <TableOutlined style={{marginRight: 5}} /> <Typography.Text>Data</Typography.Text> </Radio.Button> <Radio.Button onClick={onStructureClicked} value="structure"> <SettingOutlined style={{marginRight: 5}} /> <Typography.Text>Structure</Typography.Text> </Radio.Button> <Radio.Button onClick={onSQLClicked} value="SQL"> <ConsoleSqlOutlined style={{marginRight: 5}} /> <Typography.Text>SQL</Typography.Text> </Radio.Button> <Radio.Button onClick={onTableInfoClicked} value="tableInfo"> <DatabaseOutlined style={{marginRight: 5}} /> <Typography.Text>Table Info</Typography.Text> </Radio.Button> <Radio.Button onClick={onQueryHistoryClicked} value="queryHistory"> <HistoryOutlined style={{marginRight: 5}} /> <Typography.Text>Query History</Typography.Text> </Radio.Button> </Radio.Group> </Toolbar> {state.viewMode === 'data' || state.viewMode === 'structure' || state.viewMode === 'tableInfo' ? ( <Toolbar position="top"> <BoldSpan>Database</BoldSpan> <Select showSearch value={selectedDatabaseName} onChange={onDatabaseSelected} style={{flex: 1}} dropdownMatchSelectWidth={false}> {databaseOptions} </Select> <BoldSpan>Table</BoldSpan> <Select showSearch value={selectedTableName} onChange={onDatabaseTableSelected} style={{flex: 1}} dropdownMatchSelectWidth={false}> {tableOptions} </Select> <div /> <Button onClick={onRefreshClicked} type="default"> Refresh </Button> </Toolbar> ) : null} {state.viewMode === 'SQL' ? ( <Layout.Container> <Toolbar position="top"> <BoldSpan>Database</BoldSpan> <Select showSearch value={selectedDatabaseName} onChange={onDatabaseSelected} dropdownMatchSelectWidth={false}> {databaseOptions} </Select> </Toolbar> <Layout.Horizontal pad={theme.space.small} style={{paddingBottom: 0}}> <TextArea onChange={onQueryChanged} onKeyPress={onQueryTextareaKeyPress} placeholder="Type query here.." value={ state.query !== null && typeof state.query !== 'undefined' ? state.query.value : undefined } /> </Layout.Horizontal> <Toolbar position="top"> <Layout.Right> <div /> <Layout.Horizontal gap={theme.space.small}> <Button icon={ state.query && favorites.includes(state.query.value) ? ( <StarFilled /> ) : ( <StarOutlined /> ) } onClick={onFavoriteButtonClicked} /> <Dropdown overlay={ <FavoritesMenu favorites={favorites} onClick={onFavoriteQuerySelected} /> }> <Button onClick={() => {}}> Choose from previous queries <DownOutlined /> </Button> </Dropdown> <Button type="primary" onClick={onExecuteClicked} title={'Execute SQL [Ctrl+Return]'}> Execute </Button> </Layout.Horizontal> </Layout.Right> </Toolbar> </Layout.Container> ) : null} <Layout.Container grow> {state.viewMode === 'data' ? ( <DataTable page={state.currentPage} highlightedRowsChanged={pageHighlightedRowsChanged} onRowEdited={onRowEdited} sortOrderChanged={sortOrderChanged} currentSort={state.currentSort} currentStructure={state.currentStructure} /> ) : null} {state.viewMode === 'structure' && state.currentStructure ? ( <DatabaseStructure structure={state.currentStructure} /> ) : null} {state.viewMode === 'SQL' ? ( <QueryTable query={state.queryResult} highlightedRowsChanged={queryHighlightedRowsChanged} /> ) : null} {state.viewMode === 'tableInfo' ? ( <Layout.Horizontal grow pad={theme.space.small} style={{paddingBottom: 0}}> <TextArea value={sqlFormatter.format(state.tableInfo)} readOnly /> </Layout.Horizontal> ) : null} {state.viewMode === 'queryHistory' ? ( <QueryHistory history={state.queryHistory} /> ) : null} </Layout.Container> <Toolbar position="bottom" style={{paddingLeft: 8}}> <Layout.Horizontal grow> {state.viewMode === 'SQL' && state.executionTime !== 0 ? ( <Text> {state.executionTime} ms </Text> ) : null} {state.viewMode === 'data' && state.currentPage ? ( <PageInfo currentRow={state.currentPage.start} count={state.currentPage.count} totalRows={state.currentPage.total} onChange={onGoToRow} /> ) : null} {state.viewMode === 'data' && state.currentPage ? ( <ButtonNavigation canGoBack={state.currentPage.start > 0} canGoForward={ state.currentPage.start + state.currentPage.count < state.currentPage.total } onBack={onPreviousPageClicked} onForward={onNextPageClicked} /> ) : null} </Layout.Horizontal> </Toolbar> {state.error && ( <ErrorBar>{getStringFromErrorLike(state.error)}</ErrorBar> )} </Layout.Container> ); }