join wizard

This commit is contained in:
Jan Prochazka
2020-05-28 18:13:55 +02:00
parent cafd42a61e
commit a11b3773ce
6 changed files with 416 additions and 116 deletions

View File

@@ -0,0 +1,189 @@
import React from 'react';
import ModalBase from './ModalBase';
import { FormButtonRow } from '../utility/forms';
import FormStyledButton from '../widgets/FormStyledButton';
import SqlEditor from '../sqleditor/SqlEditor';
import styled from 'styled-components';
import keycodes from '../utility/keycodes';
import ModalHeader from './ModalHeader';
import ModalContent from './ModalContent';
import ModalFooter from './ModalFooter';
import analyseQuerySources from '../sqleditor/analyseQuerySources';
import TableControl, { TableColumn } from '../utility/TableControl';
import { TextField } from '../utility/inputs';
const FlexLine = styled.div`
display: flex;
margin-bottom: 15px;
`;
const FlexColumn = styled.div`
margin: 5px;
`;
const Label = styled.div`
margin: 5px;
`;
const SqlWrapper = styled.div`
position: relative;
height: 80px;
width: 40vw;
`;
const JOIN_TYPES = ['INNER JOIN', 'LEFT JOIN', 'RIGHT JOIN'];
export default function InsertJoinModal({ sql, modalState, engine, dbinfo, onInsert }) {
const sources = React.useMemo(
() => analyseQuerySources(sql, [...dbinfo.tables.map((x) => x.pureName), ...dbinfo.views.map((x) => x.pureName)]),
[sql, dbinfo]
);
const [sourceIndex, setSourceIndex] = React.useState(0);
const [targetIndex, setTargetIndex] = React.useState(0);
const [joinIndex, setJoinIndex] = React.useState(0);
const [alias, setAlias] = React.useState('');
const sourceRef = React.useRef(null);
const targetRef = React.useRef(null);
const aliasRef = React.useRef(null);
const joinRef = React.useRef(null);
const targets = React.useMemo(() => {
const source = sources[sourceIndex];
if (!source) return [];
/** @type {import('@dbgate/types').TableInfo} */
const table = dbinfo.tables.find((x) => x.pureName == sources[sourceIndex].name);
if (!table) return [];
return [
...table.foreignKeys.map((fk) => ({
baseColumns: fk.columns.map((x) => x.columnName).join(', '),
refTable: fk.refTableName,
refColumns: fk.columns.map((x) => x.refColumnName).join(', '),
constraintName: fk.constraintName,
columnMap: fk.columns,
})),
...table.dependencies.map((fk) => ({
baseColumns: fk.columns.map((x) => x.refColumnName).join(', '),
refTable: fk.pureName,
refColumns: fk.columns.map((x) => x.columnName).join(', '),
constraintName: fk.constraintName,
columnMap: fk.columns.map((x) => ({
columnName: x.refColumnName,
refColumnName: x.columnName,
})),
})),
];
}, [sourceIndex, sources]);
const sqlPreview = React.useMemo(() => {
const source = sources[sourceIndex];
const target = targets[targetIndex];
if (source && target) {
return `${JOIN_TYPES[joinIndex]} ${target.refTable}${alias ? ` ${alias}` : ''} ON ${target.columnMap
.map((col) => `${source.name}.${col.columnName} = ${alias || target.refTable}.${col.refColumnName}`)
.join(' AND ')}`;
}
return '';
}, [joinIndex, sources, targets, sourceIndex, targetIndex, alias]);
const sourceKeyDown = React.useCallback((event) => {
if (event.keyCode == keycodes.enter || event.keyCode == keycodes.rightArrow) {
targetRef.current.focus();
}
}, []);
const targetKeyDown = React.useCallback((event) => {
if (event.keyCode == keycodes.leftArrow) {
sourceRef.current.focus();
}
if (event.keyCode == keycodes.enter || event.keyCode == keycodes.rightArrow) {
joinRef.current.focus();
}
}, []);
const joinKeyDown = React.useCallback((event) => {
if (event.keyCode == keycodes.leftArrow) {
targetRef.current.focus();
}
if (event.keyCode == keycodes.enter || event.keyCode == keycodes.rightArrow) {
aliasRef.current.focus();
}
}, []);
const aliasKeyDown = React.useCallback((event) => {
if (event.keyCode == keycodes.enter) {
event.preventDefault();
modalState.close();
onInsert(sqlPreview);
}
}, []);
return (
<ModalBase modalState={modalState}>
<ModalHeader modalState={modalState}>Insert join</ModalHeader>
<ModalContent>
<FlexLine>
<FlexColumn>
<Label>Existing table</Label>
<TableControl
rows={sources}
focusOnCreate
selectedIndex={sourceIndex}
setSelectedIndex={setSourceIndex}
onKeyDown={sourceKeyDown}
tableRef={sourceRef}
>
<TableColumn fieldName="alias" header="Alias" />
<TableColumn fieldName="name" header="Name" />
</TableControl>
</FlexColumn>
<FlexColumn>
<Label>New table</Label>
<TableControl
rows={targets}
selectedIndex={targetIndex}
setSelectedIndex={setTargetIndex}
tableRef={targetRef}
onKeyDown={targetKeyDown}
>
<TableColumn fieldName="baseColumns" header="Column from" />
<TableColumn fieldName="refTable" header="Table to" />
<TableColumn fieldName="refColumns" header="Column to" />
{/* <TableColumn fieldName="constraintName" header="Foreign key" /> */}
</TableControl>
</FlexColumn>
<FlexColumn>
<Label>Join</Label>
<TableControl
rows={JOIN_TYPES.map((name) => ({ name }))}
selectedIndex={joinIndex}
setSelectedIndex={setJoinIndex}
tableRef={joinRef}
onKeyDown={joinKeyDown}
>
<TableColumn fieldName="name" header="Join type" />
</TableControl>
<Label>Alias</Label>
<TextField
value={alias}
onChange={(e) => setAlias(e.target.value)}
editorRef={aliasRef}
onKeyDown={aliasKeyDown}
/>
</FlexColumn>
</FlexLine>
<SqlWrapper>
<SqlEditor value={sqlPreview} engine={engine} readOnly />
</SqlWrapper>
</ModalContent>
<ModalFooter>
<FormStyledButton
value="OK"
onClick={() => {
modalState.close();
onInsert(sqlPreview);
}}
/>
<FormStyledButton type="button" value="Close" onClick={modalState.close} />
</ModalFooter>
</ModalBase>
);
}

View File

@@ -2,9 +2,12 @@ import React from 'react';
import styled from 'styled-components';
import AceEditor from 'react-ace';
import useDimensions from '../utility/useDimensions';
import { addCompleter, setCompleters } from 'ace-builds/src-noconflict/ext-language_tools';
import { getDatabaseInfo } from '../utility/metadataLoaders';
import analyseQuerySources from './analyseQuerySources';
import keycodes from '../utility/keycodes';
import useCodeCompletion from './useCodeCompletion';
import showModal from '../modals/showModal';
import InsertJoinModal from '../modals/InsertJoinModal';
import { getDatabaseInfo } from '../utility/metadataLoaders';
const Wrapper = styled.div`
position: absolute;
@@ -55,118 +58,50 @@ export default function SqlEditor({
const currentEditorRef = editorRef || ownEditorRef;
React.useEffect(() => {
if (!tabVisible) return;
setCompleters([]);
addCompleter({
getCompletions: async function (editor, session, pos, prefix, callback) {
const cursor = session.selection.cursor;
const line = session.getLine(cursor.row).slice(0, cursor.column);
const dbinfo = await getDatabaseInfo({ conid, database });
let list = COMMON_KEYWORDS.map((word) => ({
name: word,
value: word,
caption: word,
meta: 'keyword',
score: 800,
}));
if (/from\s*([a-zA-Z0-9_]*)?$/i.test(line)) {
if (dbinfo) {
list = [
...list,
...dbinfo.tables.map((x) => ({
name: x.pureName,
value: x.pureName,
caption: x.pureName,
meta: 'table',
score: 1000,
})),
...dbinfo.views.map((x) => ({
name: x.pureName,
value: x.pureName,
caption: x.pureName,
meta: 'view',
score: 1000,
})),
];
}
}
const colMatch = line.match(/([a-zA-Z0-9_]+)\.([a-zA-Z0-9_]*)?$/);
if (colMatch && dbinfo) {
const table = colMatch[1];
const sources = analyseQuerySources(editor.getValue(), [
...dbinfo.tables.map((x) => x.pureName),
...dbinfo.views.map((x) => x.pureName),
]);
const source = sources.find((x) => (x.alias || x.name) == table);
if (source) {
const table = dbinfo.tables.find((x) => x.pureName == source.name);
if (table) {
list = [
...list,
...table.columns.map((x) => ({
name: x.columnName,
value: x.columnName,
caption: x.columnName,
meta: 'column',
score: 1000,
})),
];
}
}
}
callback(null, list);
},
});
const doLiveAutocomplete = function (e) {
const editor = e.editor;
var hasCompleter = editor.completer && editor.completer.activated;
const session = editor.session;
const cursor = session.selection.cursor;
const line = session.getLine(cursor.row).slice(0, cursor.column);
// We don't want to autocomplete with no prefix
if (e.command.name === 'backspace') {
// do not hide after backspace
} else if (e.command.name === 'insertstring') {
if (!hasCompleter || e.args == '.') {
editor.execCommand('startAutocomplete');
}
// if (e.args == ' ' || e.args == '.') {
// if (/from\s*$/i.test(line)) {
// currentEditorRef.current.editor.execCommand('startAutocomplete');
// }
// }
}
};
currentEditorRef.current.editor.commands.on('afterExec', doLiveAutocomplete);
return () => {
currentEditorRef.current.editor.commands.removeListener('afterExec', doLiveAutocomplete);
};
}, [tabVisible, conid, database, currentEditorRef.current]);
useCodeCompletion({
conid,
database,
tabVisible,
currentEditorRef,
});
React.useEffect(() => {
if ((tabVisible || focusOnCreate) && currentEditorRef.current && currentEditorRef.current.editor)
currentEditorRef.current.editor.focus();
}, [tabVisible, focusOnCreate]);
const handleKeyDown = React.useCallback(
async (data, hash, keyString, keyCode, event) => {
if (keyCode == keycodes.j && event.ctrlKey && !readOnly && tabVisible) {
event.preventDefault();
const dbinfo = await getDatabaseInfo({ conid, database });
showModal((modalState) => (
<InsertJoinModal
sql={currentEditorRef.current.editor.getValue()}
modalState={modalState}
engine={engine}
dbinfo={dbinfo}
onInsert={(text) => {
const editor = currentEditorRef.current.editor;
editor.session.insert(editor.getCursorPosition(), text);
}}
/>
));
}
if (onKeyDown) onKeyDown(data, hash, keyString, keyCode, event);
},
[onKeyDown]
);
React.useEffect(() => {
if (onKeyDown && currentEditorRef.current) {
currentEditorRef.current.editor.keyBinding.addKeyboardHandler(onKeyDown);
if ((onKeyDown || !readOnly) && currentEditorRef.current) {
currentEditorRef.current.editor.keyBinding.addKeyboardHandler(handleKeyDown);
}
return () => {
currentEditorRef.current.editor.keyBinding.removeKeyboardHandler(onKeyDown);
currentEditorRef.current.editor.keyBinding.removeKeyboardHandler(handleKeyDown);
};
}, [onKeyDown]);
}, [handleKeyDown]);
return (
<Wrapper ref={containerRef}>

View File

@@ -19,7 +19,7 @@ export default function analyseQuerySources(sql, sourceNames) {
name: word,
});
}
if (/^(where)|(inner)|(left)|(right)$/i.test(postWord)) {
if (/^(where)|(inner)|(left)|(right)|(on)$/i.test(postWord)) {
continue;
}
res.push({

View File

@@ -0,0 +1,123 @@
import React from 'react';
import { addCompleter, setCompleters } from 'ace-builds/src-noconflict/ext-language_tools';
import { getDatabaseInfo } from '../utility/metadataLoaders';
import analyseQuerySources from './analyseQuerySources';
const COMMON_KEYWORDS = [
'select',
'where',
'update',
'delete',
'group',
'order',
'from',
'by',
'create',
'table',
'drop',
'alter',
'view',
'execute',
'procedure',
];
export default function useCodeCompletion({ conid, database, tabVisible, currentEditorRef }) {
React.useEffect(() => {
if (!tabVisible) return;
setCompleters([]);
addCompleter({
getCompletions: async function (editor, session, pos, prefix, callback) {
const cursor = session.selection.cursor;
const line = session.getLine(cursor.row).slice(0, cursor.column);
const dbinfo = await getDatabaseInfo({ conid, database });
let list = COMMON_KEYWORDS.map((word) => ({
name: word,
value: word,
caption: word,
meta: 'keyword',
score: 800,
}));
if (/(join)|(from)|(update)|(delete)|(insert)\s*([a-zA-Z0-9_]*)?$/i.test(line)) {
if (dbinfo) {
list = [
...list,
...dbinfo.tables.map((x) => ({
name: x.pureName,
value: x.pureName,
caption: x.pureName,
meta: 'table',
score: 1000,
})),
...dbinfo.views.map((x) => ({
name: x.pureName,
value: x.pureName,
caption: x.pureName,
meta: 'view',
score: 1000,
})),
];
}
}
const colMatch = line.match(/([a-zA-Z0-9_]+)\.([a-zA-Z0-9_]*)?$/);
if (colMatch && dbinfo) {
const table = colMatch[1];
const sources = analyseQuerySources(editor.getValue(), [
...dbinfo.tables.map((x) => x.pureName),
...dbinfo.views.map((x) => x.pureName),
]);
const source = sources.find((x) => (x.alias || x.name) == table);
if (source) {
const table = dbinfo.tables.find((x) => x.pureName == source.name);
if (table) {
list = [
...list,
...table.columns.map((x) => ({
name: x.columnName,
value: x.columnName,
caption: x.columnName,
meta: 'column',
score: 1000,
})),
];
}
}
}
callback(null, list);
},
});
const doLiveAutocomplete = function (e) {
const editor = e.editor;
var hasCompleter = editor.completer && editor.completer.activated;
const session = editor.session;
const cursor = session.selection.cursor;
const line = session.getLine(cursor.row).slice(0, cursor.column);
// We don't want to autocomplete with no prefix
if (e.command.name === 'backspace') {
// do not hide after backspace
} else if (e.command.name === 'insertstring') {
if (!hasCompleter || e.args == '.') {
editor.execCommand('startAutocomplete');
}
// if (e.args == ' ' || e.args == '.') {
// if (/from\s*$/i.test(line)) {
// currentEditorRef.current.editor.execCommand('startAutocomplete');
// }
// }
}
};
currentEditorRef.current.editor.commands.on('afterExec', doLiveAutocomplete);
return () => {
currentEditorRef.current.editor.commands.removeListener('afterExec', doLiveAutocomplete);
};
}, [tabVisible, conid, database, currentEditorRef.current]);
}

View File

@@ -37,7 +37,7 @@ export default function ObjectListControl({ collection = [], title, showIfEmpty
<TableColumn
fieldName="displayName"
header="Name"
formatter={col => <AppObjectControl data={col} makeAppObj={makeAppObj} component="span" />}
formatter={(col) => <AppObjectControl data={col} makeAppObj={makeAppObj} component="span" />}
/>
{children}
</TableControl>

View File

@@ -1,15 +1,24 @@
import React from 'react';
import _ from 'lodash';
import styled from 'styled-components';
import keycodes from './keycodes';
const Table = styled.table`
border-collapse: collapse;
width: 100%;
user-select: ${(props) =>
// @ts-ignore
props.focusable ? 'none' : ''};
// outline: none;
`;
const TableHead = styled.thead``;
const TableBody = styled.tbody``;
const TableHeaderRow = styled.tr``;
const TableBodyRow = styled.tr``;
const TableBodyRow = styled.tr`
background-color: ${(props) =>
// @ts-ignore
props.isSelected ? '#ccccff' : ''};
`;
const TableHeaderCell = styled.td`
border: 1px solid #e8eef4;
background-color: #e8eef4;
@@ -30,26 +39,70 @@ function format(row, col) {
return row[fieldName];
}
export default function TableControl({ rows = [], children }) {
console.log('children', children);
export default function TableControl({
rows = [],
children,
focusOnCreate = false,
onKeyDown = undefined,
tabIndex = -1,
setSelectedIndex = undefined,
selectedIndex = undefined,
tableRef = undefined,
}) {
const columns = (children instanceof Array ? _.flatten(children) : [children])
.filter(child => child && child.props && child.props.fieldName)
.map(child => child.props);
.filter((child) => child && child.props && child.props.fieldName)
.map((child) => child.props);
const myTableRef = React.useRef(null);
const currentTableRef = tableRef || myTableRef;
React.useEffect(() => {
if (focusOnCreate) {
currentTableRef.current.focus();
}
}, []);
const handleKeyDown = React.useCallback((event) => {
if (event.keyCode == keycodes.downArrow) {
setSelectedIndex((i) => Math.min(i + 1, rows.length - 1));
}
if (event.keyCode == keycodes.upArrow) {
setSelectedIndex((i) => Math.max(0, i - 1));
}
if (onKeyDown) onKeyDown(event);
}, []);
return (
<Table>
<Table
ref={currentTableRef}
onKeyDown={selectedIndex != null ? handleKeyDown : undefined}
tabIndex={selectedIndex != null ? tabIndex : undefined}
// @ts-ignore
focusable={selectedIndex != null}
>
<TableHead>
<TableHeaderRow>
{columns.map(x => (
{columns.map((x) => (
<TableHeaderCell key={x.fieldName}>{x.header}</TableHeaderCell>
))}
</TableHeaderRow>
</TableHead>
<TableBody>
{rows.map((row, index) => (
<TableBodyRow key={index}>
{columns.map(col => (
<TableBodyRow
key={index}
// @ts-ignore
isSelected={index == selectedIndex}
onClick={
selectedIndex != null
? () => {
setSelectedIndex(index);
currentTableRef.current.focus();
}
: undefined
}
>
{columns.map((col) => (
<TableBodyCell key={col.fieldName}>{format(row, col)}</TableBodyCell>
))}
</TableBodyRow>