/** * 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, TableBodyRow, TableRowSortOrder, Value, renderValue, } from 'flipper'; import React, {KeyboardEvent, ChangeEvent, useState, useCallback} from 'react'; import {Methods, Events} 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'; import {createState, PluginClient, usePlugin, useValue} from 'flipper-plugin'; 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; }; 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 DatabaseEntry = { id: number; name: string; tables: Array; }; type Query = { value: string; time: 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; }; function PageInfo(props: PageInfoProps) { const [state, setState] = useState({ isOpen: false, inputValue: String(props.currentRow), }); const onOpen = () => { setState({...state, isOpen: true}); }; const onInputChanged = (e: ChangeEvent) => { setState({...state, inputValue: e.target.value}); }; const onSubmit = (e: KeyboardEvent) => { if (e.key === 'Enter') { const rowNumber = parseInt(state.inputValue, 10); props.onChange(rowNumber - 1, props.count); setState({...state, isOpen: false}); } }; return (
{props.count === props.totalRows ? `${props.count} ` : `${props.currentRow + 1}-${props.currentRow + props.count} `} of {props.totalRows} rows
{state.isOpen ? ( ) : ( )} ); } export function plugin(client: PluginClient) { const pluginState = createState({ 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: [], }); const updateDatabases = (event: { databases: Array<{name: string; id: number; tables: Array}>; }) => { const updates = event.databases; const state = pluginState.get(); const databases = updates.sort((db1, db2) => db1.id - db2.id); const selectedDatabase = state.selectedDatabase || (Object.values(databases)[0] ? 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; pluginState.set({ ...state, databases, outdatedDatabaseList: false, selectedDatabase: selectedDatabase, selectedDatabaseTable: selectedTable, pageRowNumber: 0, currentPage: sameTableSelected ? state.currentPage : null, currentStructure: null, currentSort: sameTableSelected ? state.currentSort : null, }); }; const updateSelectedDatabase = (event: {database: number}) => { const state = pluginState.get(); pluginState.set({ ...state, selectedDatabase: event.database, selectedDatabaseTable: state.databases[event.database - 1].tables[0] || null, pageRowNumber: 0, currentPage: null, currentStructure: null, currentSort: null, }); }; const updateSelectedDatabaseTable = (event: {table: string}) => { const state = pluginState.get(); pluginState.set({ ...state, selectedDatabaseTable: event.table, pageRowNumber: 0, currentPage: null, currentStructure: null, currentSort: null, }); }; const updateViewMode = (event: { viewMode: 'data' | 'structure' | 'SQL' | 'tableInfo' | 'queryHistory'; }) => { pluginState.update((state) => { state.viewMode = event.viewMode; state.error = null; }); }; const updatePage = (event: Page) => { pluginState.update((state) => { state.currentPage = event; }); }; const updateStructure = (event: { databaseId: number; table: string; columns: Array; rows: Array>; indexesColumns: Array; indexesValues: Array>; }) => { pluginState.update((state) => { state.currentStructure = { databaseId: event.databaseId, table: event.table, columns: event.columns, rows: event.rows, indexesColumns: event.indexesColumns, indexesValues: event.indexesValues, }; }); }; const displaySelect = (event: { columns: Array; values: Array>; }) => { pluginState.update((state) => { state.queryResult = { table: { columns: event.columns, rows: event.values, highlightedRows: [], }, id: null, count: null, }; }); }; const displayInsert = (event: {id: number}) => { const state = pluginState.get(); pluginState.set({ ...state, queryResult: { table: null, id: event.id, count: null, }, }); }; const displayUpdateDelete = (event: {count: number}) => { pluginState.update((state) => { state.queryResult = { table: null, id: null, count: event.count, }; }); }; const updateTableInfo = (event: {tableInfo: string}) => { pluginState.update((state) => { state.tableInfo = event.tableInfo; }); }; const nextPage = () => { pluginState.update((state) => { state.pageRowNumber += PAGE_SIZE; state.currentPage = null; }); }; const previousPage = () => { pluginState.update((state) => { state.pageRowNumber = Math.max(state.pageRowNumber - PAGE_SIZE, 0); state.currentPage = null; }); }; const execute = (event: {query: string}) => { const timeBefore = Date.now(); const {query} = event; client .send('execute', { databaseId: pluginState.get().selectedDatabase, value: query, }) .then((data) => { pluginState.update((state) => { state.error = null; state.executionTime = Date.now() - timeBefore; }); if (data.type === 'select') { displaySelect({ columns: data.columns, values: data.values, }); } else if (data.type === 'insert') { displayInsert({ id: data.insertedId, }); } else if (data.type === 'update_delete') { displayUpdateDelete({ count: data.affectedCount, }); } }) .catch((e) => { pluginState.update((state) => { state.error = e; }); }); let newHistory = pluginState.get().queryHistory; const newQuery = pluginState.get().query; if ( newQuery !== null && typeof newQuery !== 'undefined' && newHistory !== null && typeof newHistory !== 'undefined' ) { newQuery.time = dateFormat(new Date(), 'hh:MM:ss'); newHistory = newHistory.concat(newQuery); } pluginState.update((state) => { state.queryHistory = newHistory; }); }; const goToRow = (event: {row: number}) => { const state = pluginState.get(); if (!state.currentPage) { return; } const destinationRow = event.row < 0 ? 0 : event.row >= state.currentPage.total - PAGE_SIZE ? Math.max(state.currentPage.total - PAGE_SIZE, 0) : event.row; pluginState.update((state) => { state.pageRowNumber = destinationRow; state.currentPage = null; }); }; const refresh = () => { pluginState.update((state) => { state.outdatedDatabaseList = true; state.currentPage = null; }); }; const updateFavorites = (event: {favorites: Array | undefined}) => { const state = pluginState.get(); const newFavorites = [...(event.favorites || state.favorites)]; if (state.query) { const value = state.query.value; const index = newFavorites.indexOf(value); if (index < 0) { newFavorites.push(value); } else { newFavorites.splice(index, 1); } } pluginState.set({...state, favorites: newFavorites}); window.localStorage.setItem( 'plugin-database-favorites-sql-queries', JSON.stringify(newFavorites), ); }; const sortByChanged = (event: {sortOrder: TableRowSortOrder}) => { const state = pluginState.get(); pluginState.set({ ...state, currentSort: event.sortOrder, pageRowNumber: 0, currentPage: null, }); }; const updateQuery = (event: {value: string}) => { const state = pluginState.get(); pluginState.set({ ...state, query: { value: event.value, time: dateFormat(new Date(), 'hh:MM:ss'), }, }); }; pluginState.subscribe( (newState: DatabasesPluginState, previousState: DatabasesPluginState) => { const databaseId = newState.selectedDatabase; const table = newState.selectedDatabaseTable; if ( newState.viewMode === 'data' && newState.currentPage === null && databaseId && table ) { client .send('getTableData', { count: PAGE_SIZE, databaseId: newState.selectedDatabase, order: newState.currentSort?.key, reverse: (newState.currentSort?.direction || 'up') === 'down', table: table, start: newState.pageRowNumber, }) .then((data) => { updatePage({ databaseId: databaseId, table: table, columns: data.columns, rows: data.values, start: data.start, count: data.count, total: data.total, highlightedRows: [], }); }) .catch((e) => { pluginState.update((state) => { state.error = e; }); }); } if (newState.currentStructure === null && databaseId && table) { client .send('getTableStructure', { databaseId: databaseId, table: table, }) .then((data) => { updateStructure({ databaseId: databaseId, table: table, columns: data.structureColumns, rows: data.structureValues, indexesColumns: data.indexesColumns, indexesValues: data.indexesValues, }); }) .catch((e) => { pluginState.update((state) => { state.error = e; }); }); } if ( newState.viewMode === 'tableInfo' && newState.currentStructure === null && databaseId && table ) { client .send('getTableInfo', { databaseId: databaseId, table: table, }) .then((data) => { updateTableInfo({ tableInfo: data.definition, }); }) .catch((e) => { pluginState.update((state) => { state.error = e; }); }); } if ( !previousState.outdatedDatabaseList && newState.outdatedDatabaseList ) { client.send('databaseList', {}).then((databases) => { updateDatabases({ databases, }); }); } }, ); client.onConnect(() => { client.send('databaseList', {}).then((databases) => { updateDatabases({ databases, }); }); updateFavorites({ favorites: JSON.parse( localStorage.getItem('plugin-database-favorites-sql-queries') || '[]', ), }); }); return { state: pluginState, updateDatabases, updateSelectedDatabase, updateSelectedDatabaseTable, updateViewMode, updatePage, updateStructure, displaySelect, displayInsert, displayUpdateDelete, updateTableInfo, nextPage, previousPage, execute, goToRow, refresh, updateFavorites, sortByChanged, updateQuery, }; } export function Component() { const instance = usePlugin(plugin); const state = useValue(instance.state); 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 onFavoritesClicked = useCallback(() => { instance.updateFavorites({ favorites: instance.state.get().favorites, }); }, [instance]); 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 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 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) => instance.state.update((draftState: DatabasesPluginState) => { if (draftState.currentPage !== null) { draftState.currentPage.highlightedRows = highlightedRows.map( parseInt, ); } }) } onSort={(sortOrder: TableRowSortOrder) => { instance.sortByChanged({ sortOrder, }); }} initialSortOrder={state.currentSort ?? undefined} /> {page.highlightedRows.length === 1 && ( )} ); }; const 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) => { instance.state.set({ ...instance.state.get(), 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; } }; const tableOptions = (state.selectedDatabase && state.databases[state.selectedDatabase - 1] && state.databases[state.selectedDatabase - 1].tables.reduce( (options, tableName) => ({...options, [tableName]: tableName}), {}, )) || {}; return ( {state.viewMode === 'data' || state.viewMode === 'structure' || state.viewMode === 'tableInfo' ? ( Database
) : null} {state.viewMode === 'SQL' ? (
Database