feat(editor): SQL editor overhaul (#6282)

* Draft setup
*  Implemented expression evaluation in Postgres node, minor SQL editor UI improvements, minor refacring
*  Added initial version of expression preview for SQL editor
*  Linking npm package for codemirror sql grammar instead of a local file
*  Moving expression editor wrapper elements to the component
*  Using expression preview in SQL editor
* Use SQL parser skipping whitespace
*  Added support for custom skipped segments specification
*  Fixing highlight problems with dots and expressions that resolve to zero
* 👕 Fixing linting error
*  Added current item support
*  Added expression support to more nodes with sql editor
*  Added expression support for other nodes
*  Implemented different SQL dialect support
* 🐛 Fixing hard-coded parameter names for editors
*  Fixing preview for nested queries, updating query when input data changes, adding keyboard shortcut to toggle comments
*  Adding a custom automcomplete notice for different editors
*  Updating SQL autocomplete notice
*  Added unit tests for SQL editor
*  Using latest grammar
* 🐛 Fixing code node editor rendering
* 💄 SQL preview dropdown matches editor width. Removing unnecessary css
*  Addressing PR review feedback
* 👌 Addressing PR review feedback pt2
* 👌 Added path alias for utils in nodes-base package
* 👌 Addressing more PR review feedback
*  Adding tests for `getResolvables` utility function
* Fixing lodash imports
* 👌 Better focus handling, adding more plugins to the editor, other minor imrovements
*  Not showing SQL autocomplete suggestions inside expressions
*  Using npm package for sql grammar
*  Removing autocomplete notice, adding line highlight on syntax error
* 👌 Addressing code review feedback
---------
Co-authored-by: Milorad Filipovic <milorad@n8n.io>
This commit is contained in:
Iván Ovejero 2023-06-22 16:47:28 +02:00 committed by GitHub
parent d431117c9e
commit beedfb609c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
68 changed files with 653 additions and 287 deletions

View file

@ -31,7 +31,6 @@
"@codemirror/lang-javascript": "^6.1.2",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-python": "^6.1.2",
"@codemirror/lang-sql": "^6.4.1",
"@codemirror/language": "^6.2.1",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.1.4",
@ -46,6 +45,7 @@
"@jsplumb/connector-bezier": "^5.13.2",
"@jsplumb/core": "^5.13.2",
"@jsplumb/util": "^5.13.2",
"@n8n/codemirror-lang-sql": "^1.0.0",
"axios": "^0.21.1",
"codemirror-lang-html-n8n": "^1.0.0",
"codemirror-lang-n8n-expression": "^0.2.0",

View file

@ -31,9 +31,10 @@ const cssStyleDeclaration = getComputedStyle(document.documentElement);
interface ThemeSettings {
isReadOnly?: boolean;
customMaxHeight?: string;
}
export const codeNodeEditorTheme = ({ isReadOnly }: ThemeSettings) => [
export const codeNodeEditorTheme = ({ isReadOnly, customMaxHeight }: ThemeSettings) => [
EditorView.theme({
'&': {
'font-size': BASE_STYLING.fontSize,
@ -79,7 +80,7 @@ export const codeNodeEditorTheme = ({ isReadOnly }: ThemeSettings) => [
},
'.cm-scroller': {
overflow: 'auto',
maxHeight: '100%',
maxHeight: customMaxHeight ?? '100%',
...(isReadOnly ? {} : { minHeight: '10em' }),
},
'.cm-diagnosticAction': {

View file

@ -31,36 +31,13 @@
/>
</div>
<div :class="isFocused ? $style.dropdown : $style.hidden">
<n8n-text size="small" compact :class="$style.header">
{{ $locale.baseText('parameterInput.resultForItem') }} {{ hoveringItemNumber }}
</n8n-text>
<n8n-text :class="$style.body">
<InlineExpressionEditorOutput
:value="value"
:isReadOnly="isReadOnly"
:segments="segments"
/>
</n8n-text>
<div :class="$style.footer">
<n8n-text size="small" compact>
{{ $locale.baseText('parameterInput.anythingInside') }}
</n8n-text>
<div :class="$style['expression-syntax-example']" v-text="`{{ }}`"></div>
<n8n-text size="small" compact>
{{ $locale.baseText('parameterInput.isJavaScript') }}
</n8n-text>
<n8n-link
:class="$style['learn-more']"
size="small"
underline
theme="text"
:to="expressionsDocsUrl"
>
{{ $locale.baseText('parameterInput.learnMore') }}
</n8n-link>
</div>
</div>
<InlineExpressionEditorOutput
:segments="segments"
:value="value"
:isReadOnly="isReadOnly"
:visible="isFocused"
:hoveringItemNumber="hoveringItemNumber"
/>
</div>
</template>
@ -74,7 +51,6 @@ import InlineExpressionEditorInput from '@/components/InlineExpressionEditor/Inl
import InlineExpressionEditorOutput from '@/components/InlineExpressionEditor/InlineExpressionEditorOutput.vue';
import ExpressionFunctionIcon from '@/components/ExpressionFunctionIcon.vue';
import { createExpressionTelemetryPayload } from '@/utils/telemetryUtils';
import { EXPRESSIONS_DOCS_URL } from '@/constants';
import type { Segment } from '@/types/expressions';
import type { TargetItem } from '@/Interface';
@ -92,7 +68,6 @@ export default defineComponent({
return {
isFocused: false,
segments: [] as Segment[],
expressionsDocsUrl: EXPRESSIONS_DOCS_URL,
};
},
props: {
@ -114,14 +89,10 @@ export default defineComponent({
computed: {
...mapStores(useNDVStore, useWorkflowsStore),
hoveringItemNumber(): number {
return (this.hoveringItem?.itemIndex ?? 0) + 1;
return this.ndvStore.hoveringItemNumber;
},
hoveringItem(): TargetItem | null {
if (this.ndvStore.isInputParentOfActiveNode) {
return this.ndvStore.hoveringItem;
}
return null;
return this.ndvStore.getHoveringItem;
},
isDragging(): boolean {
return this.ndvStore.isDraggableDragging;
@ -241,64 +212,4 @@ export default defineComponent({
border-bottom-right-radius: 0;
background-color: white;
}
.hidden {
display: none;
}
.dropdown {
display: flex;
flex-direction: column;
position: absolute;
z-index: 2; // cover tooltips
background: white;
border: var(--border-base);
border-top: none;
width: 100%;
box-shadow: 0 2px 6px 0 rgba(#441c17, 0.1);
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
.header,
.body,
.footer {
padding: var(--spacing-3xs);
}
.header {
color: var(--color-text-dark);
font-weight: var(--font-weight-bold);
padding-left: var(--spacing-2xs);
padding-top: var(--spacing-2xs);
}
.body {
padding-top: 0;
padding-left: var(--spacing-2xs);
color: var(--color-text-dark);
}
.footer {
border-top: var(--border-base);
padding: var(--spacing-4xs);
padding-left: var(--spacing-2xs);
padding-top: 0;
line-height: var(--font-line-height-regular);
color: var(--color-text-base);
.expression-syntax-example {
display: inline-block;
font-size: var(--font-size-2xs);
height: var(--font-size-m);
background-color: #f0f0f0;
margin-left: var(--spacing-5xs);
margin-right: var(--spacing-5xs);
}
.learn-more {
line-height: 1;
white-space: nowrap;
}
}
}
</style>

View file

@ -1,5 +1,30 @@
<template>
<div ref="root" class="ph-no-capture" data-test-id="inline-expression-editor-output"></div>
<div :class="visible ? $style.dropdown : $style.hidden">
<n8n-text size="small" compact :class="$style.header">
{{ $locale.baseText('parameterInput.resultForItem') }} {{ hoveringItemNumber }}
</n8n-text>
<n8n-text :class="$style.body">
<div ref="root" class="ph-no-capture" data-test-id="inline-expression-editor-output"></div>
</n8n-text>
<div :class="$style.footer">
<n8n-text size="small" compact>
{{ $locale.baseText('parameterInput.anythingInside') }}
</n8n-text>
<div :class="$style['expression-syntax-example']" v-text="`{{ }}`"></div>
<n8n-text size="small" compact>
{{ $locale.baseText('parameterInput.isJavaScript') }}
</n8n-text>
<n8n-link
:class="$style['learn-more']"
size="small"
underline
theme="text"
:to="expressionsDocsUrl"
>
{{ $locale.baseText('parameterInput.learnMore') }}
</n8n-link>
</div>
</div>
</template>
<script lang="ts">
@ -13,12 +38,29 @@ import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import { outputTheme } from './theme';
import type { Plaintext, Resolved, Segment } from '@/types/expressions';
import { EXPRESSIONS_DOCS_URL } from '@/constants';
export default defineComponent({
name: 'InlineExpressionEditorOutput',
props: {
segments: {
type: Array as PropType<Segment[]>,
required: true,
},
value: {
type: String,
},
isReadOnly: {
type: Boolean,
default: false,
},
visible: {
type: Boolean,
default: false,
},
hoveringItemNumber: {
type: Number,
required: true,
},
},
watch: {
@ -36,6 +78,7 @@ export default defineComponent({
data() {
return {
editor: null as EditorView | null,
expressionsDocsUrl: EXPRESSIONS_DOCS_URL,
};
},
mounted() {
@ -87,4 +130,64 @@ export default defineComponent({
});
</script>
<style lang="scss"></style>
<style lang="scss" module>
.hidden {
display: none;
}
.dropdown {
display: flex;
flex-direction: column;
position: absolute;
z-index: 2; // cover tooltips
background: white;
border: var(--border-base);
border-top: none;
width: 100%;
box-shadow: 0 2px 6px 0 rgba(#441c17, 0.1);
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
.header,
.body,
.footer {
padding: var(--spacing-3xs);
}
.header {
color: var(--color-text-dark);
font-weight: var(--font-weight-bold);
padding-left: var(--spacing-2xs);
padding-top: var(--spacing-2xs);
}
.body {
padding-top: 0;
padding-left: var(--spacing-2xs);
color: var(--color-text-dark);
}
.footer {
border-top: var(--border-base);
padding: var(--spacing-4xs);
padding-left: var(--spacing-2xs);
padding-top: 0;
line-height: var(--font-line-height-regular);
color: var(--color-text-base);
.expression-syntax-example {
display: inline-block;
font-size: var(--font-size-2xs);
height: var(--font-size-m);
background-color: #f0f0f0;
margin-left: var(--spacing-5xs);
margin-right: var(--spacing-5xs);
}
.learn-more {
line-height: 1;
white-space: nowrap;
}
}
}
</style>

View file

@ -107,7 +107,7 @@ export default defineComponent({
return false;
}
if (this.parameter.typeOptions?.editor === 'codeNodeEditor') {
if (['codeNodeEditor', 'sqlEditor'].includes(this.parameter.typeOptions?.editor)) {
return false;
}

View file

@ -1,17 +1,23 @@
<template>
<div ref="sqlEditor" class="ph-no-capture"></div>
<div :class="$style.sqlEditor" v-click-outside="onBlur">
<div ref="sqlEditor" data-test-id="sql-editor-container" class="ph-no-capture"></div>
<InlineExpressionEditorOutput
:segments="segments"
:value="query"
:isReadOnly="isReadOnly"
:visible="isFocused"
:hoveringItemNumber="hoveringItemNumber"
/>
</div>
</template>
<script lang="ts">
import type { PropType } from 'vue';
import { defineComponent } from 'vue';
import { autocompletion } from '@codemirror/autocomplete';
import { indentWithTab, history, redo } from '@codemirror/commands';
import { foldGutter, indentOnInput } from '@codemirror/language';
import { lintGutter } from '@codemirror/lint';
import type { Extension } from '@codemirror/state';
import { acceptCompletion, autocompletion, ifNotIn } from '@codemirror/autocomplete';
import { indentWithTab, history, redo, toggleComment } from '@codemirror/commands';
import { bracketMatching, foldGutter, indentOnInput, LanguageSupport } from '@codemirror/language';
import { EditorState } from '@codemirror/state';
import type { ViewUpdate } from '@codemirror/view';
import type { Extension } from '@codemirror/state';
import {
dropCursor,
EditorView,
@ -20,76 +26,189 @@ import {
keymap,
lineNumbers,
} from '@codemirror/view';
import { MSSQL, MySQL, PostgreSQL, sql, StandardSQL } from '@codemirror/lang-sql';
import type { SQLDialect } from 'n8n-workflow';
import {
MSSQL,
MySQL,
PostgreSQL,
StandardSQL,
MariaSQL,
SQLite,
Cassandra,
PLSQL,
keywordCompletionSource,
} from '@n8n/codemirror-lang-sql';
import type { SQLDialect as SQLDialectType } from '@n8n/codemirror-lang-sql';
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import { expressionManager } from '@/mixins/expressionManager';
import InlineExpressionEditorOutput from '@/components/InlineExpressionEditor/InlineExpressionEditorOutput.vue';
import { EXPRESSIONS_DOCS_URL } from '@/constants';
import { codeNodeEditorEventBus } from '@/event-bus';
const SQL_DIALECTS = {
standard: StandardSQL,
mssql: MSSQL,
mysql: MySQL,
postgres: PostgreSQL,
StandardSQL,
PostgreSQL,
MySQL,
MariaSQL,
MSSQL,
SQLite,
Cassandra,
PLSQL,
} as const;
type SQLEditorData = {
editor: EditorView | null;
isFocused: boolean;
skipSegments: string[];
expressionsDocsUrl: string;
};
export default defineComponent({
name: 'sql-editor',
components: {
InlineExpressionEditorOutput,
},
mixins: [expressionManager],
props: {
query: {
type: String,
required: true,
},
dialect: {
type: String as PropType<SQLDialect>,
default: 'standard',
type: String,
default: 'StandardSQL',
validator: (value: string) => {
return Object.keys(SQL_DIALECTS).includes(value);
},
},
isReadOnly: {
type: Boolean,
default: false,
},
},
data() {
data(): SQLEditorData {
return {
editor: {} as EditorView,
editor: null,
expressionsDocsUrl: EXPRESSIONS_DOCS_URL,
isFocused: false,
skipSegments: ['Statement', 'CompositeIdentifier', 'Parens'],
};
},
watch: {
'ndvStore.ndvInputData'() {
this.editor?.dispatch({
changes: {
from: 0,
to: this.editor.state.doc.length,
insert: this.query,
},
});
setTimeout(() => {
this.editor?.contentDOM.blur();
});
},
},
computed: {
doc(): string {
return this.editor.state.doc.toString();
return this.editor?.state.doc.toString() ?? '';
},
hoveringItemNumber(): number {
return this.ndvStore.hoveringItemNumber;
},
sqlDialect(): SQLDialectType {
return SQL_DIALECTS[this.dialect as keyof typeof SQL_DIALECTS] ?? SQL_DIALECTS.StandardSQL;
},
extensions(): Extension[] {
const dialect = this.sqlDialect;
function sqlWithN8nLanguageSupport() {
return new LanguageSupport(dialect.language, [
dialect.language.data.of({
autocomplete: ifNotIn(['Resolvable'], keywordCompletionSource(dialect, true)),
}),
n8nCompletionSources().map((source) => dialect.language.data.of(source)),
]);
}
const extensions = [
sqlWithN8nLanguageSupport(),
expressionInputHandler(),
codeNodeEditorTheme({ isReadOnly: this.isReadOnly, customMaxHeight: '350px' }),
lineNumbers(),
EditorView.lineWrapping,
EditorState.readOnly.of(this.isReadOnly),
EditorView.domEventHandlers({
focus: () => {
this.isFocused = true;
},
}),
EditorState.readOnly.of(this.isReadOnly),
EditorView.editable.of(!this.isReadOnly),
];
if (!this.isReadOnly) {
extensions.push(
history(),
keymap.of([
{ key: 'Mod-Shift-z', run: redo },
{ key: 'Mod-/', run: toggleComment },
{ key: 'Tab', run: acceptCompletion },
indentWithTab,
]),
autocompletion(),
indentOnInput(),
highlightActiveLine(),
highlightActiveLineGutter(),
foldGutter(),
dropCursor(),
bracketMatching(),
EditorView.updateListener.of((viewUpdate) => {
if (!viewUpdate.docChanged || !this.editor) return;
highlighter.removeColor(this.editor as EditorView, this.plaintextSegments);
highlighter.addColor(this.editor as EditorView, this.resolvableSegments);
this.$emit('valueChanged', this.doc);
}),
);
}
return extensions;
},
},
mounted() {
const dialect = SQL_DIALECTS[this.dialect as SQLDialect] ?? SQL_DIALECTS.standard;
const extensions: Extension[] = [
sql({ dialect, upperCaseKeywords: true }),
codeNodeEditorTheme({ maxHeight: false }),
lineNumbers(),
EditorView.lineWrapping,
lintGutter(),
EditorState.readOnly.of(this.isReadOnly),
];
if (!this.isReadOnly) codeNodeEditorEventBus.on('error-line-number', this.highlightLine);
if (this.isReadOnly) {
extensions.push(EditorView.editable.of(this.isReadOnly));
} else {
extensions.push(
history(),
keymap.of([indentWithTab, { key: 'Mod-Shift-z', run: redo }]),
autocompletion(),
indentOnInput(),
highlightActiveLine(),
highlightActiveLineGutter(),
foldGutter(),
dropCursor(),
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
if (!viewUpdate.docChanged) return;
this.$emit('valueChanged', this.doc);
}),
);
}
const state = EditorState.create({ doc: this.query, extensions });
const state = EditorState.create({ doc: this.query, extensions: this.extensions });
this.editor = new EditorView({ parent: this.$refs.sqlEditor as HTMLDivElement, state });
highlighter.addColor(this.editor as EditorView, this.resolvableSegments);
},
methods: {
onBlur() {
this.isFocused = false;
},
highlightLine(line: number | 'final') {
if (!this.editor) return;
if (line === 'final') {
this.editor.dispatch({
selection: { anchor: this.query.length },
});
return;
}
this.editor.dispatch({
selection: { anchor: this.editor.state.doc.line(line).from },
});
},
},
});
</script>
<style module lang="scss">
.sqlEditor {
position: relative;
}
</style>

View file

@ -0,0 +1,112 @@
import { render } from '@testing-library/vue';
import { PiniaVuePlugin } from 'pinia';
import { SETTINGS_STORE_DEFAULT_STATE, waitAllPromises } from '@/__tests__/utils';
import { STORES } from '@/constants';
import { createTestingPinia } from '@pinia/testing';
import SqlEditor from '@/components/SqlEditor/SqlEditor.vue';
import { expressionManager } from '@/mixins/expressionManager';
import type { TargetItem } from '@/Interface';
const EXPRESSION_OUTPUT_TEST_ID = 'inline-expression-editor-output';
const RESOLVABLES: { [key: string]: string | number | boolean } = {
'{{ $json.schema }}': 'public',
'{{ $json.table }}': 'users',
'{{ $json.id }}': 'id',
'{{ $json.limit - 10 }}': 0,
'{{ $json.active }}': false,
};
const DEFAULT_SETUP = {
pinia: createTestingPinia({
initialState: {
[STORES.SETTINGS]: {
settings: SETTINGS_STORE_DEFAULT_STATE.settings,
},
},
}),
props: {
dialect: 'PostgreSQL',
isReadOnly: false,
},
};
const renderComponent = (renderOptions: Parameters<typeof render>[1] = {}) =>
render(SqlEditor, { ...DEFAULT_SETUP, ...renderOptions }, (vue) => {
vue.use(PiniaVuePlugin);
});
describe('SQL Editor Preview Tests', () => {
beforeEach(() => {
vi.spyOn(expressionManager.methods, 'resolve').mockImplementation(
(resolvable: string, _targetItem?: TargetItem) => {
return { resolved: RESOLVABLES[resolvable] };
},
);
});
afterEach(() => {
vi.clearAllMocks();
});
it('renders basic query', async () => {
const { getByTestId } = renderComponent({
props: {
query: 'SELECT * FROM users',
},
});
await waitAllPromises();
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent('SELECT * FROM users');
});
it('renders basic query with expression', async () => {
const { getByTestId } = renderComponent({
props: {
query: 'SELECT * FROM {{ $json.table }}',
},
});
await waitAllPromises();
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent('SELECT * FROM users');
});
it('renders resolved expressions with dot between resolvables', async () => {
const { getByTestId } = renderComponent({
props: {
query: 'SELECT * FROM {{ $json.schema }}.{{ $json.table }}',
},
});
await waitAllPromises();
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent('SELECT * FROM public.users');
});
it('renders resolved expressions which resolve to 0', async () => {
const { getByTestId } = renderComponent({
props: {
query:
'SELECT * FROM {{ $json.schema }}.{{ $json.table }} WHERE {{ $json.id }} > {{ $json.limit - 10 }}',
},
});
await waitAllPromises();
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent(
'SELECT * FROM public.users WHERE id > 0',
);
});
it('keeps query formatting in rendered output', async () => {
const { getByTestId } = renderComponent({
props: {
query:
'SELECT * FROM {{ $json.schema }}.{{ $json.table }}\n WHERE id > {{ $json.limit - 10 }}\n AND active = {{ $json.active }};',
},
});
await waitAllPromises();
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent(
'SELECT * FROM public.users WHERE id > 0 AND active = false;',
);
// Output should have the same number of lines as the input
expect(getByTestId('sql-editor-container').getElementsByClassName('cm-line').length).toEqual(
getByTestId(EXPRESSION_OUTPUT_TEST_ID).getElementsByClassName('cm-line').length,
);
});
});

View file

@ -23,6 +23,7 @@ export const expressionManager = defineComponent({
data() {
return {
editor: {} as EditorView,
skipSegments: [] as string[],
};
},
watch: {
@ -71,6 +72,8 @@ export const expressionManager = defineComponent({
},
segments(): Segment[] {
if (!this.editor?.state) return [];
const rawSegments: RawSegment[] = [];
const fullTree = ensureSyntaxTree(
@ -83,14 +86,18 @@ export const expressionManager = defineComponent({
throw new Error(`Failed to parse expression: ${this.editor.state.doc.toString()}`);
}
const skipSegments = ['Program', 'Script', 'Document', ...this.skipSegments];
fullTree.cursor().iterate((node) => {
if (node.type.name === 'Program') return;
const text = this.editor.state.sliceDoc(node.from, node.to);
if (skipSegments.includes(node.type.name)) return;
rawSegments.push({
from: node.from,
to: node.to,
text: this.editor.state.sliceDoc(node.from, node.to),
token: node.type.name,
text,
token: node.type.name === 'Resolvable' ? 'Resolvable' : 'Plaintext',
});
});
@ -100,7 +107,18 @@ export const expressionManager = defineComponent({
if (token === 'Resolvable') {
const { resolved, error, fullError } = this.resolve(text, this.hoveringItem);
acc.push({ kind: 'resolvable', from, to, resolvable: text, resolved, error, fullError });
acc.push({
kind: 'resolvable',
from,
to,
resolvable: text,
// TODO:
// For some reason, expressions that resolve to a number 0 are breaking preview in the SQL editor
// This fixes that but as as TODO we should figure out why this is happening
resolved: String(resolved),
error,
fullError,
});
return acc;
}

View file

@ -4,6 +4,7 @@ import type {
IRunDataDisplayMode,
NDVState,
NodePanelType,
TargetItem,
XYPosition,
} from '@/Interface';
import type { INodeIssues, IRunData } from 'n8n-workflow';
@ -125,6 +126,16 @@ export const useNDVStore = defineStore(STORES.NDV, {
const parentNodes = workflow.getParentNodes(this.activeNode.name, 'main', 1);
return parentNodes.includes(inputNodeName);
},
hoveringItemNumber(): number {
return (this.hoveringItem?.itemIndex ?? 0) + 1;
},
getHoveringItem(): TargetItem | null {
if (this.isInputParentOfActiveNode) {
return this.hoveringItem;
}
return null;
},
},
actions: {
setInputNodeName(nodeName: string | undefined): void {

View file

@ -1,10 +1,13 @@
.cm-tooltip-autocomplete:after {
display: block;
content: 'n8n supports all JavaScript functions, including those not listed.';
padding: var(--spacing-2xs) var(--spacing-s);
border-top: 1px solid var(--color-foreground-dark);
}
.code-node-editor .cm-tooltip-autocomplete:after {
display: block;
content: 'n8n supports all JavaScript functions, including those not listed.';
}
// Custom autocomplete item type icons
// 1. Native and n8n extension functions:
.cm-completionIcon-extension-function, .cm-completionIcon-native-function {

View file

@ -4,5 +4,6 @@ module.exports = {
collectCoverageFrom: ['credentials/**/*.ts', 'nodes/**/*.ts', 'utils/**/*.ts'],
moduleNameMapper: {
'^@test/(.*)$': '<rootDir>/test/$1',
'^@utils/(.*)$': '<rootDir>/utils/$1',
},
};

View file

@ -9,7 +9,7 @@ import unset from 'lodash/unset';
import { cloneDeep } from 'lodash';
import set from 'lodash/set';
import union from 'lodash/union';
import { fuzzyCompare } from '../../utils/utilities';
import { fuzzyCompare } from '@utils/utilities';
type PairToMatch = {
field1: string;

View file

@ -11,7 +11,7 @@ import {
getItemCopy,
getItemsCopy,
pgInsert,
pgQuery,
pgQueryV2,
pgUpdate,
} from '../Postgres/v1/genericFunctions';
@ -73,9 +73,10 @@ export class CrateDb implements INodeType {
displayName: 'Query',
name: 'query',
type: 'string',
noDataExpression: true,
typeOptions: {
editor: 'sqlEditor',
sqlDialect: 'postgres',
sqlDialect: 'PostgreSQL',
},
displayOptions: {
show: {
@ -283,13 +284,9 @@ export class CrateDb implements INodeType {
// executeQuery
// ----------------------------------
const queryResult = await pgQuery(
this.getNodeParameter,
pgp,
db,
items,
this.continueOnFail(),
);
const queryResult = await pgQueryV2.call(this, pgp, db, items, this.continueOnFail(), {
resolveExpression: true,
});
returnItems = this.helpers.returnJsonArray(queryResult);
} else if (operation === 'insert') {

View file

@ -10,7 +10,7 @@ import { NodeApiError } from 'n8n-workflow';
import { createTransport } from 'nodemailer';
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
import { updateDisplayOptions } from '../../../utils/utilities';
import { updateDisplayOptions } from '@utils/utilities';
const properties: INodeProperties[] = [
// TODO: Add choice for text as text or html (maybe also from name)

View file

@ -16,7 +16,7 @@ import { googleApiRequest, googleApiRequestAllItems, merge, simplify } from './G
import moment from 'moment-timezone';
import type { IData } from './Interfaces';
import { oldVersionNotice } from '../../../../utils/descriptions';
import { oldVersionNotice } from '@utils/descriptions';
const versionDescription: INodeTypeDescription = {
displayName: 'Google Analytics',

View file

@ -20,7 +20,7 @@ import { recordFields, recordOperations } from './RecordDescription';
import { v4 as uuid } from 'uuid';
import { oldVersionNotice } from '../../../../utils/descriptions';
import { oldVersionNotice } from '@utils/descriptions';
const versionDescription: INodeTypeDescription = {
displayName: 'Google BigQuery',

View file

@ -3,7 +3,7 @@ import type { IExecuteFunctions } from 'n8n-core';
import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { NodeOperationError, sleep } from 'n8n-workflow';
import { updateDisplayOptions } from '../../../../../../utils/utilities';
import { getResolvables, updateDisplayOptions } from '@utils/utilities';
import type { JobInsertResponse } from '../../helpers/interfaces';
import { prepareOutput } from '../../helpers/utils';
@ -14,6 +14,7 @@ const properties: INodeProperties[] = [
displayName: 'SQL Query',
name: 'sqlQuery',
type: 'string',
noDataExpression: true,
typeOptions: {
editor: 'sqlEditor',
},
@ -31,6 +32,7 @@ const properties: INodeProperties[] = [
displayName: 'SQL Query',
name: 'sqlQuery',
type: 'string',
noDataExpression: true,
typeOptions: {
editor: 'sqlEditor',
},
@ -160,12 +162,16 @@ export async function execute(this: IExecuteFunctions): Promise<INodeExecutionDa
for (let i = 0; i < length; i++) {
try {
const sqlQuery = this.getNodeParameter('sqlQuery', i) as string;
let sqlQuery = this.getNodeParameter('sqlQuery', i) as string;
const options = this.getNodeParameter('options', i);
const projectId = this.getNodeParameter('projectId', i, undefined, {
extractValue: true,
});
for (const resolvable of getResolvables(sqlQuery)) {
sqlQuery = sqlQuery.replace(resolvable, this.evaluateExpression(resolvable, i) as string);
}
let rawOutput = false;
let includeSchema = false;

View file

@ -2,7 +2,7 @@ import type { IExecuteFunctions } from 'n8n-core';
import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import { updateDisplayOptions } from '../../../../../../utils/utilities';
import { updateDisplayOptions } from '@utils/utilities';
import type { TableSchema } from '../../helpers/interfaces';
import { checkSchema, wrapData } from '../../helpers/utils';
import { googleApiRequest } from '../../transport';

View file

@ -30,7 +30,7 @@ import { draftFields, draftOperations } from './DraftDescription';
import isEmpty from 'lodash/isEmpty';
import { oldVersionNotice } from '../../../../utils/descriptions';
import { oldVersionNotice } from '@utils/descriptions';
const versionDescription: INodeTypeDescription = {
displayName: 'Gmail',

View file

@ -8,7 +8,8 @@ import type {
} from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { placeholder } from './placeholder';
import { getResolvables, getValue } from './utils';
import { getValue } from './utils';
import { getResolvables } from '@utils/utilities';
import type { IValueData } from './types';
export class Html implements INodeType {

View file

@ -1,26 +1,6 @@
import type { IDataObject } from 'n8n-workflow';
import type { IValueData, Cheerio } from './types';
/**
* @TECH_DEBT Explore replacing with handlebars
*/
export function getResolvables(html: string) {
if (!html) return [];
const resolvables = [];
const resolvableRegex = /({{[\s\S]*?}})/g;
let match;
while ((match = resolvableRegex.exec(html)) !== null) {
if (match[1]) {
resolvables.push(match[1]);
}
}
return resolvables;
}
// The extraction functions
const extractFunctions: {
[key: string]: ($: Cheerio, valueData: IValueData) => string | undefined;

View file

@ -24,7 +24,7 @@ import {
replaceNullValues,
sanitizeUiMessage,
} from '../GenericFunctions';
import { keysToLowercase } from '../../../utils/utilities';
import { keysToLowercase } from '@utils/utilities';
function toText<T>(data: T) {
if (typeof data === 'object' && data !== null) {

View file

@ -12,7 +12,7 @@ import type {
} from 'n8n-workflow';
import { deepCopy } from 'n8n-workflow';
import { oldVersionNotice } from '../../../utils/descriptions';
import { oldVersionNotice } from '@utils/descriptions';
const versionDescription: INodeTypeDescription = {
displayName: 'Merge',

View file

@ -11,7 +11,7 @@ import assignWith from 'lodash/assignWith';
import get from 'lodash/get';
import merge from 'lodash/merge';
import mergeWith from 'lodash/mergeWith';
import { fuzzyCompare } from '../../../utils/utilities';
import { fuzzyCompare } from '@utils/utilities';
type PairToMatch = {
field1: string;

View file

@ -1,6 +1,6 @@
import type { IExecuteFunctions } from 'n8n-core';
import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from '../../../../../../utils/utilities';
import { updateDisplayOptions } from '@utils/utilities';
import { microsoftApiRequest } from '../../transport';
import { workbookRLC, worksheetRLC } from '../common.descriptions';

View file

@ -1,6 +1,6 @@
import type { IExecuteFunctions } from 'n8n-core';
import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { processJsonInput, updateDisplayOptions } from '../../../../../../utils/utilities';
import { processJsonInput, updateDisplayOptions } from '@utils/utilities';
import type { ExcelResponse } from '../../helpers/interfaces';
import { prepareOutput } from '../../helpers/utils';
import { microsoftApiRequest } from '../../transport';

View file

@ -1,6 +1,6 @@
import type { IExecuteFunctions } from 'n8n-core';
import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from '../../../../../../utils/utilities';
import { updateDisplayOptions } from '@utils/utilities';
import { microsoftApiRequest } from '../../transport';
import { tableRLC, workbookRLC, worksheetRLC } from '../common.descriptions';

View file

@ -1,6 +1,6 @@
import type { IExecuteFunctions } from 'n8n-core';
import type { INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from '../../../../../../utils/utilities';
import { updateDisplayOptions } from '@utils/utilities';
import { microsoftApiRequest } from '../../transport';
import { tableRLC, workbookRLC, worksheetRLC } from '../common.descriptions';

View file

@ -1,6 +1,6 @@
import type { IExecuteFunctions } from 'n8n-core';
import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from '../../../../../../utils/utilities';
import { updateDisplayOptions } from '@utils/utilities';
import { microsoftApiRequest, microsoftApiRequestAllItemsSkip } from '../../transport';
import { tableRLC, workbookRLC, worksheetRLC } from '../common.descriptions';

View file

@ -1,6 +1,6 @@
import type { IExecuteFunctions } from 'n8n-core';
import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from '../../../../../../utils/utilities';
import { updateDisplayOptions } from '@utils/utilities';
import { microsoftApiRequest, microsoftApiRequestAllItemsSkip } from '../../transport';
import { tableRLC, workbookRLC, worksheetRLC } from '../common.descriptions';

View file

@ -1,7 +1,7 @@
import type { IExecuteFunctions } from 'n8n-core';
import type { IDataObject, INodeExecutionData, INodeProperties, JsonObject } from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import { updateDisplayOptions } from '../../../../../../utils/utilities';
import { updateDisplayOptions } from '@utils/utilities';
import { microsoftApiRequestAllItemsSkip } from '../../transport';
import { tableRLC, workbookRLC, worksheetRLC } from '../common.descriptions';

View file

@ -1,6 +1,6 @@
import type { IExecuteFunctions } from 'n8n-core';
import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from '../../../../../../utils/utilities';
import { updateDisplayOptions } from '@utils/utilities';
import { microsoftApiRequest } from '../../transport';
import { workbookRLC } from '../common.descriptions';

View file

@ -1,7 +1,7 @@
import type { IExecuteFunctions } from 'n8n-core';
import type { INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { updateDisplayOptions } from '../../../../../../utils/utilities';
import { updateDisplayOptions } from '@utils/utilities';
import { microsoftApiRequest } from '../../transport';
import { workbookRLC } from '../common.descriptions';

View file

@ -1,6 +1,6 @@
import type { IExecuteFunctions } from 'n8n-core';
import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from '../../../../../../utils/utilities';
import { updateDisplayOptions } from '@utils/utilities';
import { microsoftApiRequest, microsoftApiRequestAllItems } from '../../transport';
const properties: INodeProperties[] = [

View file

@ -1,6 +1,6 @@
import type { IExecuteFunctions } from 'n8n-core';
import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { processJsonInput, updateDisplayOptions } from '../../../../../../utils/utilities';
import { processJsonInput, updateDisplayOptions } from '@utils/utilities';
import type { ExcelResponse } from '../../helpers/interfaces';
import { prepareOutput } from '../../helpers/utils';
import { microsoftApiRequest } from '../../transport';

View file

@ -1,6 +1,6 @@
import type { IExecuteFunctions } from 'n8n-core';
import type { INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from '../../../../../../utils/utilities';
import { updateDisplayOptions } from '@utils/utilities';
import { microsoftApiRequest } from '../../transport';
import { workbookRLC, worksheetRLC } from '../common.descriptions';

View file

@ -1,6 +1,6 @@
import type { IExecuteFunctions } from 'n8n-core';
import type { INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from '../../../../../../utils/utilities';
import { updateDisplayOptions } from '@utils/utilities';
import { microsoftApiRequest } from '../../transport';
import { workbookRLC, worksheetRLC } from '../common.descriptions';

View file

@ -1,6 +1,6 @@
import type { IExecuteFunctions } from 'n8n-core';
import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from '../../../../../../utils/utilities';
import { updateDisplayOptions } from '@utils/utilities';
import { microsoftApiRequest, microsoftApiRequestAllItems } from '../../transport';
import { workbookRLC } from '../common.descriptions';

View file

@ -1,6 +1,6 @@
import type { IExecuteFunctions } from 'n8n-core';
import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from '../../../../../../utils/utilities';
import { updateDisplayOptions } from '@utils/utilities';
import type { ExcelResponse } from '../../helpers/interfaces';
import { prepareOutput } from '../../helpers/utils';
import { microsoftApiRequest } from '../../transport';

View file

@ -1,7 +1,7 @@
import type { IExecuteFunctions } from 'n8n-core';
import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { processJsonInput, updateDisplayOptions } from '../../../../../../utils/utilities';
import { processJsonInput, updateDisplayOptions } from '@utils/utilities';
import type { ExcelResponse, UpdateSummary } from '../../helpers/interfaces';
import { prepareOutput, updateByAutoMaping, updateByDefinedValues } from '../../helpers/utils';
import { microsoftApiRequest } from '../../transport';

View file

@ -1,7 +1,7 @@
import type { IExecuteFunctions } from 'n8n-core';
import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { processJsonInput, updateDisplayOptions } from '../../../../../../utils/utilities';
import { processJsonInput, updateDisplayOptions } from '@utils/utilities';
import type { ExcelResponse, UpdateSummary } from '../../helpers/interfaces';
import { prepareOutput, updateByAutoMaping, updateByDefinedValues } from '../../helpers/utils';
import { microsoftApiRequest } from '../../transport';

View file

@ -3,7 +3,7 @@ import type { IDataObject, INode, INodeExecutionData } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import type { ExcelResponse, SheetData, UpdateSummary } from './interfaces';
import { constructExecutionMetaData } from 'n8n-core';
import { wrapData } from '../../../../../utils/utilities';
import { wrapData } from '@utils/utilities';
type PrepareOutputConfig = {
rawData: boolean;

View file

@ -11,7 +11,7 @@ import type {
} from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { chunk, flatten } from '../../../utils/utilities';
import { chunk, flatten, getResolvables } from '@utils/utilities';
import mssql from 'mssql';
@ -90,9 +90,10 @@ export class MicrosoftSql implements INodeType {
displayName: 'Query',
name: 'query',
type: 'string',
noDataExpression: true,
typeOptions: {
editor: 'sqlEditor',
sqlDialect: 'mssql',
sqlDialect: 'MSSQL',
},
displayOptions: {
show: {
@ -293,7 +294,11 @@ export class MicrosoftSql implements INodeType {
// executeQuery
// ----------------------------------
const rawQuery = this.getNodeParameter('query', 0) as string;
let rawQuery = this.getNodeParameter('query', 0) as string;
for (const resolvable of getResolvables(rawQuery)) {
rawQuery = rawQuery.replace(resolvable, this.evaluateExpression(resolvable, 0) as string);
}
const queryResult = await pool.request().query(rawQuery);

View file

@ -17,7 +17,7 @@ import type mysql2 from 'mysql2/promise';
import { copyInputItems, createConnection, searchTables } from './GenericFunctions';
import type { IExecuteFunctions } from 'n8n-core';
import { oldVersionNotice } from '../../../utils/descriptions';
import { oldVersionNotice } from '@utils/descriptions';
const versionDescription: INodeTypeDescription = {
displayName: 'MySQL',
@ -75,9 +75,10 @@ const versionDescription: INodeTypeDescription = {
displayName: 'Query',
name: 'query',
type: 'string',
noDataExpression: true,
typeOptions: {
editor: 'sqlEditor',
sqlDialect: 'mysql',
sqlDialect: 'MySQL',
},
displayOptions: {
show: {

View file

@ -9,7 +9,7 @@ import type {
WhereClause,
} from '../../helpers/interfaces';
import { updateDisplayOptions } from '../../../../../utils/utilities';
import { updateDisplayOptions } from '@utils/utilities';
import { addWhereClauses } from '../../helpers/utils';

View file

@ -4,7 +4,7 @@ import { NodeOperationError } from 'n8n-workflow';
import type { QueryRunner, QueryWithValues } from '../../helpers/interfaces';
import { updateDisplayOptions } from '../../../../../utils/utilities';
import { getResolvables, updateDisplayOptions } from '@utils/utilities';
import { prepareQueryAndReplacements, replaceEmptyStringsByNulls } from '../../helpers/utils';
@ -20,9 +20,10 @@ const properties: INodeProperties[] = [
required: true,
description:
"The SQL query to execute. You can use n8n expressions and $1, $2, $3, etc to refer to the 'Query Parameters' set in options below.",
noDataExpression: true,
typeOptions: {
editor: 'sqlEditor',
sqlDialect: 'mysql',
sqlDialect: 'MySQL',
},
hint: 'Prefer using query parameters over n8n expressions to avoid SQL injection attacks',
},
@ -58,7 +59,11 @@ export async function execute(
const queries: QueryWithValues[] = [];
for (let i = 0; i < items.length; i++) {
const rawQuery = this.getNodeParameter('query', i) as string;
let rawQuery = this.getNodeParameter('query', i) as string;
for (const resolvable of getResolvables(rawQuery)) {
rawQuery = rawQuery.replace(resolvable, this.evaluateExpression(resolvable, i) as string);
}
const options = this.getNodeParameter('options', i, {});

View file

@ -10,7 +10,7 @@ import type {
import { AUTO_MAP, BATCH_MODE, DATA_MODE } from '../../helpers/interfaces';
import { updateDisplayOptions } from '../../../../../utils/utilities';
import { updateDisplayOptions } from '@utils/utilities';
import { copyInputItems, replaceEmptyStringsByNulls } from '../../helpers/utils';

View file

@ -9,7 +9,7 @@ import type {
WhereClause,
} from '../../helpers/interfaces';
import { updateDisplayOptions } from '../../../../../utils/utilities';
import { updateDisplayOptions } from '@utils/utilities';
import { addSortRules, addWhereClauses } from '../../helpers/utils';

View file

@ -4,7 +4,7 @@ import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workf
import type { QueryRunner, QueryValues, QueryWithValues } from '../../helpers/interfaces';
import { AUTO_MAP, DATA_MODE } from '../../helpers/interfaces';
import { updateDisplayOptions } from '../../../../../utils/utilities';
import { updateDisplayOptions } from '@utils/utilities';
import { replaceEmptyStringsByNulls } from '../../helpers/utils';

View file

@ -4,7 +4,7 @@ import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workf
import type { QueryRunner, QueryValues, QueryWithValues } from '../../helpers/interfaces';
import { AUTO_MAP, DATA_MODE } from '../../helpers/interfaces';
import { updateDisplayOptions } from '../../../../../utils/utilities';
import { updateDisplayOptions } from '@utils/utilities';
import { replaceEmptyStringsByNulls } from '../../helpers/utils';

View file

@ -16,7 +16,7 @@ import pgPromise from 'pg-promise';
import { pgInsertV2, pgQueryV2, pgUpdate, wrapData } from './genericFunctions';
import { oldVersionNotice } from '../../../utils/descriptions';
import { oldVersionNotice } from '@utils/descriptions';
const versionDescription: INodeTypeDescription = {
displayName: 'Postgres',
@ -74,9 +74,10 @@ const versionDescription: INodeTypeDescription = {
displayName: 'Query',
name: 'query',
type: 'string',
noDataExpression: true,
typeOptions: {
editor: 'sqlEditor',
sqlDialect: 'postgres',
sqlDialect: 'PostgreSQL',
},
displayOptions: {
show: {

View file

@ -1,6 +1,7 @@
import type { IExecuteFunctions, IDataObject, INodeExecutionData, JsonObject } from 'n8n-workflow';
import type pgPromise from 'pg-promise';
import type pg from 'pg-promise/typescript/pg-subset';
import { getResolvables } from '@utils/utilities';
/**
* Returns of a shallow copy of the items which only contains the json data and
@ -168,7 +169,10 @@ export async function pgQueryV2(
db: pgPromise.IDatabase<{}, pg.IClient>,
items: INodeExecutionData[],
continueOnFail: boolean,
overrideMode?: string,
options?: {
overrideMode?: string;
resolveExpression?: boolean;
},
): Promise<IDataObject[]> {
const additionalFields = this.getNodeParameter('additionalFields', 0);
@ -183,13 +187,22 @@ export async function pgQueryV2(
type QueryWithValues = { query: string; values?: string[] };
const allQueries = new Array<QueryWithValues>();
for (let i = 0; i < items.length; i++) {
const query = this.getNodeParameter('query', i) as string;
let query = this.getNodeParameter('query', i) as string;
if (options?.resolveExpression) {
for (const resolvable of getResolvables(query)) {
query = query.replace(resolvable, this.evaluateExpression(resolvable, i) as string);
}
}
const values = valuesArray[i];
const queryFormat = { query, values };
allQueries.push(queryFormat);
}
const mode = overrideMode ? overrideMode : ((additionalFields.mode ?? 'multiple') as string);
const mode = options?.overrideMode
? options.overrideMode
: ((additionalFields.mode ?? 'multiple') as string);
if (mode === 'multiple') {
return (await db.multi(pgp.helpers.concat(allQueries)))
.map((result, i) => {

View file

@ -2,7 +2,7 @@ import type { IExecuteFunctions } from 'n8n-core';
import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { updateDisplayOptions } from '../../../../../utils/utilities';
import { updateDisplayOptions } from '@utils/utilities';
import type {
PgpDatabase,

View file

@ -2,7 +2,7 @@ import type { IExecuteFunctions } from 'n8n-core';
import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { updateDisplayOptions } from '../../../../../utils/utilities';
import { getResolvables, updateDisplayOptions } from '@utils/utilities';
import type { PgpDatabase, QueriesRunner, QueryWithValues } from '../../helpers/interfaces';
@ -17,18 +17,19 @@ const properties: INodeProperties[] = [
type: 'string',
default: '',
placeholder: 'e.g. SELECT id, name FROM product WHERE quantity > $1 AND price <= $2',
noDataExpression: true,
required: true,
description:
"The SQL query to execute. You can use n8n expressions and $1, $2, $3, etc to refer to the 'Query Parameters' set in options below.",
typeOptions: {
editor: 'sqlEditor',
sqlDialect: 'postgres',
sqlDialect: 'PostgreSQL',
},
hint: 'Prefer using query parameters over n8n expressions to avoid SQL injection attacks',
},
{
displayName: `
To use query parameters in your SQL query, reference them as $1, $2, $3, etc in the corresponding order. <a href="https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.postgres/#use-query-parameters">More info</a>.
To use query parameters in your SQL query, reference them as $1, $2, $3, etc in the corresponding order. <a href="https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.postgres/#use-query-parameters" target="_blank">More info</a>.
`,
name: 'notice',
type: 'notice',
@ -58,7 +59,11 @@ export async function execute(
const queries: QueryWithValues[] = [];
for (let i = 0; i < items.length; i++) {
const query = this.getNodeParameter('query', i) as string;
let query = this.getNodeParameter('query', i) as string;
for (const resolvable of getResolvables(query)) {
query = query.replace(resolvable, this.evaluateExpression(resolvable, i) as string);
}
let values: IDataObject[] = [];

View file

@ -1,7 +1,7 @@
import type { IExecuteFunctions } from 'n8n-core';
import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from '../../../../../utils/utilities';
import { updateDisplayOptions } from '@utils/utilities';
import type {
PgpDatabase,

View file

@ -1,7 +1,7 @@
import type { IExecuteFunctions } from 'n8n-core';
import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from '../../../../../utils/utilities';
import { updateDisplayOptions } from '@utils/utilities';
import type {
PgpDatabase,

View file

@ -2,7 +2,7 @@ import type { IExecuteFunctions } from 'n8n-core';
import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { updateDisplayOptions } from '../../../../../utils/utilities';
import { updateDisplayOptions } from '@utils/utilities';
import type {
PgpDatabase,

View file

@ -2,7 +2,7 @@ import type { IExecuteFunctions } from 'n8n-core';
import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { updateDisplayOptions } from '../../../../../utils/utilities';
import { updateDisplayOptions } from '@utils/utilities';
import type {
PgpDatabase,

View file

@ -8,7 +8,7 @@ import { NodeOperationError } from 'n8n-workflow';
import pgPromise from 'pg-promise';
import { pgInsert, pgQuery } from '../Postgres/v1/genericFunctions';
import { pgInsert, pgQueryV2 } from '../Postgres/v1/genericFunctions';
export class QuestDb implements INodeType {
description: INodeTypeDescription = {
@ -60,9 +60,10 @@ export class QuestDb implements INodeType {
displayName: 'Query',
name: 'query',
type: 'string',
noDataExpression: true,
typeOptions: {
editor: 'sqlEditor',
sqlDialect: 'postgres',
sqlDialect: 'PostgreSQL',
},
displayOptions: {
show: {
@ -225,14 +226,10 @@ export class QuestDb implements INodeType {
const additionalFields = this.getNodeParameter('additionalFields', 0);
const mode = (additionalFields.mode || 'independently') as string;
const queryResult = await pgQuery(
this.getNodeParameter,
pgp,
db,
items,
this.continueOnFail(),
mode,
);
const queryResult = await pgQueryV2.call(this, pgp, db, items, this.continueOnFail(), {
overrideMode: mode,
resolveExpression: true,
});
returnItems = this.helpers.returnJsonArray(queryResult);
} else if (operation === 'insert') {

View file

@ -22,7 +22,7 @@ import { userProfileFields, userProfileOperations } from './UserProfileDescripti
import { slackApiRequest, slackApiRequestAllItems, validateJSON } from './GenericFunctions';
import type { IAttachment } from './MessageInterface';
import { oldVersionNotice } from '../../../utils/descriptions';
import { oldVersionNotice } from '@utils/descriptions';
import moment from 'moment';

View file

@ -9,6 +9,7 @@ import type {
import { connect, copyInputItems, destroy, execute } from './GenericFunctions';
import snowflake from 'snowflake-sdk';
import { getResolvables } from '@utils/utilities';
export class Snowflake implements INodeType {
description: INodeTypeDescription = {
@ -65,6 +66,7 @@ export class Snowflake implements INodeType {
displayName: 'Query',
name: 'query',
type: 'string',
noDataExpression: true,
typeOptions: {
editor: 'sqlEditor',
},
@ -178,7 +180,12 @@ export class Snowflake implements INodeType {
// ----------------------------------
for (let i = 0; i < items.length; i++) {
const query = this.getNodeParameter('query', i) as string;
let query = this.getNodeParameter('query', i) as string;
for (const resolvable of getResolvables(query)) {
query = query.replace(resolvable, this.evaluateExpression(resolvable, i) as string);
}
responseData = await execute(connection, query, []);
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData as IDataObject[]),

View file

@ -6,7 +6,7 @@ import type {
} from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { pgInsert, pgQuery, pgUpdate } from '../Postgres/v1/genericFunctions';
import { pgInsert, pgQueryV2, pgUpdate } from '../Postgres/v1/genericFunctions';
import pgPromise from 'pg-promise';
@ -65,9 +65,10 @@ export class TimescaleDb implements INodeType {
displayName: 'Query',
name: 'query',
type: 'string',
noDataExpression: true,
typeOptions: {
editor: 'sqlEditor',
sqlDialect: 'postgres',
sqlDialect: 'PostgreSQL',
},
displayOptions: {
show: {
@ -279,13 +280,9 @@ export class TimescaleDb implements INodeType {
// executeQuery
// ----------------------------------
const queryResult = await pgQuery(
this.getNodeParameter,
pgp,
db,
items,
this.continueOnFail(),
);
const queryResult = await pgQueryV2.call(this, pgp, db, items, this.continueOnFail(), {
resolveExpression: true,
});
returnItems = this.helpers.returnJsonArray(queryResult);
} else if (operation === 'insert') {

View file

@ -17,7 +17,7 @@
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
"typecheck": "tsc",
"build": "tsc -p tsconfig.build.json && gulp build:icons && gulp build:translations && pnpm build:metadata",
"build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json && gulp build:icons && gulp build:translations && pnpm build:metadata",
"build:translations": "gulp build:translations",
"build:metadata": "pnpm n8n-generate-known && pnpm n8n-generate-ui-types",
"format": "prettier --write . --ignore-path ../../.prettierignore",

View file

@ -1,4 +1,4 @@
import { fuzzyCompare, keysToLowercase, wrapData } from '../../utils/utilities';
import { fuzzyCompare, getResolvables, keysToLowercase, wrapData } from '@utils/utilities';
//most test cases for fuzzyCompare are done in Compare Datasets node tests
describe('Test fuzzyCompare', () => {
@ -101,3 +101,39 @@ describe('Test keysToLowercase', () => {
expect(test6).toEqual(undefined);
});
});
describe('Test getResolvables', () => {
it('should return empty array when there are no resolvables', () => {
expect(getResolvables('Plain String, no resolvables here.')).toEqual([]);
});
it('should properly handle resovables in SQL query', () => {
expect(getResolvables('SELECT * FROM {{ $json.db }}.{{ $json.table }};')).toEqual([
'{{ $json.db }}',
'{{ $json.table }}',
]);
});
it('should properly handle resovables in HTML string', () => {
expect(
getResolvables(
`
<!DOCTYPE html>
<html>
<head><title>{{ $json.pageTitle }}</title></head>
<body><h1>{{ $json.heading }}</h1></body>
<html>
<style>
body { height: {{ $json.pageHeight }}; }
</style>
<script>
console.log('{{ $json.welcomeMessage }}');
</script>
`,
),
).toEqual([
'{{ $json.pageTitle }}',
'{{ $json.heading }}',
'{{ $json.pageHeight }}',
'{{ $json.welcomeMessage }}',
]);
});
});

View file

@ -5,7 +5,8 @@
"types": ["node", "jest"],
"noEmit": true,
"paths": {
"@test/*": ["./test/*"]
"@test/*": ["./test/*"],
"@utils/*": ["./utils/*"]
},
// TODO: remove all options below this line
"noImplicitReturns": false,
@ -16,5 +17,12 @@
"references": [
{ "path": "../workflow/tsconfig.build.json" },
{ "path": "../core/tsconfig.build.json" }
]
],
"tsc-alias": {
"replacers": {
"base-url": {
"enabled": false
}
}
}
}

View file

@ -214,3 +214,23 @@ export const keysToLowercase = <T>(headers: T) => {
return acc;
}, {} as IDataObject);
};
/**
* @TECH_DEBT Explore replacing with handlebars
*/
export function getResolvables(expression: string) {
if (!expression) return [];
const resolvables = [];
const resolvableRegex = /({{[\s\S]*?}})/g;
let match;
while ((match = resolvableRegex.exec(expression)) !== null) {
if (match[1]) {
resolvables.push(match[1]);
}
}
return resolvables;
}

View file

@ -1025,7 +1025,15 @@ export type CodeAutocompleteTypes = 'function' | 'functionItem';
export type EditorType = 'code' | 'codeNodeEditor' | 'htmlEditor' | 'sqlEditor' | 'json';
export type CodeNodeEditorLanguage = (typeof CODE_LANGUAGES)[number];
export type CodeExecutionMode = (typeof CODE_EXECUTION_MODES)[number];
export type SQLDialect = 'mssql' | 'mysql' | 'postgres';
export type SQLDialect =
| 'StandardSQL'
| 'PostgreSQL'
| 'MySQL'
| 'MariaSQL'
| 'MSSQL'
| 'SQLite'
| 'Cassandra'
| 'PLSQL';
export interface ILoadOptions {
routing?: {

View file

@ -773,9 +773,6 @@ importers:
'@codemirror/lang-python':
specifier: ^6.1.2
version: 6.1.2(@codemirror/state@6.1.4)(@codemirror/view@6.5.1)(@lezer/common@1.0.1)
'@codemirror/lang-sql':
specifier: ^6.4.1
version: 6.4.1(@codemirror/view@6.5.1)(@lezer/common@1.0.1)
'@codemirror/language':
specifier: ^6.2.1
version: 6.2.1
@ -818,6 +815,9 @@ importers:
'@jsplumb/util':
specifier: ^5.13.2
version: 5.13.2
'@n8n/codemirror-lang-sql':
specifier: ^1.0.0
version: 1.0.0(@codemirror/view@6.5.1)(@lezer/common@1.0.1)
axios:
specifier: ^0.21.1
version: 0.21.4
@ -4128,19 +4128,6 @@ packages:
- '@lezer/common'
dev: false
/@codemirror/lang-sql@6.4.1(@codemirror/view@6.5.1)(@lezer/common@1.0.1):
resolution: {integrity: sha512-PFB56L+A0WGY35uRya+Trt5g19V9k2V9X3c55xoFW4RgiATr/yLqWsbbnEsdxuMn5tLpuikp7Kmj9smRsqBXAg==}
dependencies:
'@codemirror/autocomplete': 6.4.0(@codemirror/language@6.2.1)(@codemirror/state@6.1.4)(@codemirror/view@6.5.1)(@lezer/common@1.0.1)
'@codemirror/language': 6.2.1
'@codemirror/state': 6.1.4
'@lezer/highlight': 1.1.1
'@lezer/lr': 1.2.3
transitivePeerDependencies:
- '@codemirror/view'
- '@lezer/common'
dev: false
/@codemirror/language@6.2.1:
resolution: {integrity: sha512-MC3svxuvIj0MRpFlGHxLS6vPyIdbTr2KKPEW46kCoCXw2ktb4NTkpkPBI/lSP/FoNXLCBJ0mrnUi1OoZxtpW1Q==}
dependencies:
@ -5272,6 +5259,19 @@ packages:
dev: false
optional: true
/@n8n/codemirror-lang-sql@1.0.0(@codemirror/view@6.5.1)(@lezer/common@1.0.1):
resolution: {integrity: sha512-7bmlhaSW+f/g+IarWbif/D9bUgwW8bjCbjfW6BCGqZHXTz9UQt8fM6tQ9MNh/3sZz9LPwcnT7XSSv73Ku0rriw==}
dependencies:
'@codemirror/autocomplete': 6.4.0(@codemirror/language@6.2.1)(@codemirror/state@6.1.4)(@codemirror/view@6.5.1)(@lezer/common@1.0.1)
'@codemirror/language': 6.2.1
'@codemirror/state': 6.1.4
'@lezer/highlight': 1.1.1
'@lezer/lr': 1.2.3
transitivePeerDependencies:
- '@codemirror/view'
- '@lezer/common'
dev: false
/@n8n_io/license-sdk@2.4.0:
resolution: {integrity: sha512-99kuCVH4NcBi4nyn/WIpd6KSIMLk/pbBks0zr8bC65ALKj0se7/2MwC6N+WwGkG7NqH0kMdGe/7Y5KnJkMTefg==}
engines: {node: '>=14.0.0', npm: '>=7.10.0'}