Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
283 changes: 180 additions & 103 deletions web/pgadmin/static/js/components/ReactCodeMirror/CustomEditorView.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,20 @@ import { clearBreakpoints, hasBreakpoint, toggleBreakpoint } from './extensions/
import { autoCompleteCompartment, eol, eolCompartment } from './extensions/extraStates';


// Keywords that can begin a standalone SQL statement. Used by
// _needsExpansion to distinguish "new query after blank line" from
// "clause continuation after blank line".
const STATEMENT_STARTERS = new Set([
'SELECT', 'INSERT', 'UPDATE', 'DELETE', 'CREATE', 'ALTER', 'DROP',
'TRUNCATE', 'GRANT', 'REVOKE', 'COMMIT', 'ROLLBACK', 'BEGIN',
'END', 'SAVEPOINT', 'RELEASE', 'SET', 'SHOW', 'EXPLAIN', 'ANALYZE',
'VACUUM', 'REINDEX', 'CLUSTER', 'COMMENT', 'COPY', 'DO', 'LOCK',
'NOTIFY', 'LISTEN', 'UNLISTEN', 'LOAD', 'RESET', 'DISCARD',
'DECLARE', 'FETCH', 'MOVE', 'CLOSE', 'PREPARE', 'EXECUTE',
'DEALLOCATE', 'WITH', 'TABLE', 'VALUES', 'CALL', 'IMPORT', 'MERGE',
'REFRESH', 'SECURITY', 'REASSIGN', 'ABORT', 'START', 'CHECKPOINT',
]);

function getAutocompLoading({ bottom, left }, dom) {
const cmRect = dom.getBoundingClientRect();
const div = document.createElement('div');
Expand Down Expand Up @@ -50,136 +64,199 @@ export default class CustomEditorView extends EditorView {
return this.state.sliceDoc();
}

/* Function to extract query based on position passed */
getQueryAt(currPos) {
try {
if(typeof currPos == 'undefined') {
currPos = this.state.selection.main.head;
/* Check whether a blank-line boundary cut through a SQL statement.
*
* Uses two checks:
* 1. Syntax tree — does a Statement node straddle startPos (starts
* before AND ends after)? If not, no expansion is needed.
* 2. First-word — does the range start with a keyword that CAN begin
* a standalone SQL statement? If so, the blank line is a
* legitimate separator (handles the parser merging semicolon-less
* queries into one Statement). Anything else (clause keywords
* like FROM/WHERE, identifiers, etc.) means the blank line cut
* through a statement and expansion is needed.
*
* Returns true when expansion is needed, false otherwise.
*/
_needsExpansion(startPos, endPos) {
const query = this.state.sliceDoc(startPos, endPos).trim();
if (!query) return false;

const tree = syntaxTree(this.state);
let statementStartsBefore = false;

tree.iterate({
from: startPos,
to: endPos,
enter: (node) => {
if (node.type.name === 'Statement' && node.from < startPos && node.to > startPos) {
statementStartsBefore = true;
}
}
const tree = syntaxTree(this.state);
});

let origLine = this.state.doc.lineAt(currPos);
let startPos = currPos;
// No Statement extends before our range — blank line is a
// legitimate boundary (e.g. comment blocks, separate queries).
if (!statementStartsBefore) return false;

// Move the startPos a known node type or a space.
// We don't want to be in an unknown teritory
for(;startPos<origLine.to; startPos++) {
let node = tree.resolve(startPos);
if(node.type.name != 'Script') {
break;
}
const currChar = this.state.sliceDoc(startPos, startPos+1);
if(currChar == ' ' || currChar == '\t') {
// A Statement extends before our range, but the parser may have
// merged multiple semicolon-less queries into one Statement.
// Only skip expansion when the range starts with a keyword that
// can begin a standalone SQL statement. Anything else (clause
// keywords like FROM/WHERE, identifiers, expressions, etc.)
// means the blank line split a statement — expand.

// Comment-only blocks are self-contained — don't expand.
if (query.startsWith('--') || query.startsWith('/*')) return false;

const firstWord = query.split(/[\s\n\r(;]+/)[0].toUpperCase();

return !STATEMENT_STARTERS.has(firstWord);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/* Internal helper to find query boundaries with blank line as boundary */
_findQueryBoundaries(currPos, tree, stopAtBlankLine = true) {
let origLine = this.state.doc.lineAt(currPos);
let startPos = currPos;

// Move the startPos to a known node type or a space
for(;startPos<origLine.to; startPos++) {
let node = tree.resolve(startPos);
if(node.type.name != 'Script') {
break;
}
const currChar = this.state.sliceDoc(startPos, startPos+1);
if(currChar == ' ' || currChar == '\t') {
break;
}
}

let maxEndPos = this.state.doc.length;
let statementStartPos = -1;
let validTextFound = false;

// Go in reverse direction to get the start position
while(startPos >= 0) {
const currLine = this.state.doc.lineAt(startPos);

// If empty line then start with prev line
// If empty line in between then that's it
if(currLine.text.trim() == '') {
if(origLine.number != currLine.number && stopAtBlankLine) {
startPos = currLine.to + 1;
break;
}
startPos = currLine.from - 1;
continue;
}

let maxEndPos = this.state.doc.length;
let statementStartPos = -1;
let validTextFound = false;

// we'll go in reverse direction to get the start position.
while(startPos >= 0) {
const currLine = this.state.doc.lineAt(startPos);

// If empty line then start with prev line
// If empty line in between then that's it
if(currLine.text.trim() == '') {
if(origLine.number != currLine.number) {
startPos = currLine.to + 1;
break;
}
startPos = currLine.from - 1;
continue;
}
// Script type doesn't give any info, better skip it
const currChar = this.state.sliceDoc(startPos, startPos+1);
let node = tree.resolve(startPos);
if(node.type.name == 'Script' || (currChar == '\n')) {
startPos -= 1;
continue;
}

// Skip the comments
if(node.type.name == 'LineComment' || node.type.name == 'BlockComment') {
startPos = node.from - 1;
validTextFound = true;
continue;
}

// Sometimes, node type is child of statement
while(node.type.name != 'Statement' && node.parent) {
node = node.parent;
}

// Script type doesn't give any info, better skip it.
const currChar = this.state.sliceDoc(startPos, startPos+1);
let node = tree.resolve(startPos);
if(node.type.name == 'Script' || (currChar == '\n')) {
// We already had found valid text
if(validTextFound) {
if(statementStartPos >= 0 && statementStartPos < startPos) {
startPos -= 1;
continue;
}
startPos = node.to;
break;
}

// Skip the comments
if(node.type.name == 'LineComment' || node.type.name == 'BlockComment') {
startPos = node.from - 1;
// comments are valid text
validTextFound = true;
continue;
}
// Statement found for the first time
if(node.type.name == 'Statement') {
statementStartPos = node.from;
maxEndPos = node.to;

// sometimes, node type is child of statement.
while(node.type.name != 'Statement' && node.parent) {
node = node.parent;
if(node.from >= currLine.from) {
startPos = node.from;
}
}

// We already had found valid text
if(validTextFound) {
// continue till it reaches start so we can check for empty lines, etc.
if(statementStartPos >= 0 && statementStartPos < startPos) {
startPos -= 1;
continue;
}
// don't go beyond this
startPos = node.to;
break;
}
validTextFound = true;
startPos -= 1;
}

// statement found for the first time
if(node.type.name == 'Statement') {
statementStartPos = node.from;
maxEndPos = node.to;
// Move forward from start position
let endPos = startPos + 1;
maxEndPos = maxEndPos == -1 ? this.state.doc.length : maxEndPos;
while(endPos < maxEndPos) {
const currLine = this.state.doc.lineAt(endPos);

// if the statement is on the same line, jump to stmt start
if(node.from >= currLine.from) {
startPos = node.from;
}
}
// If empty line in between then that's it
if(currLine.text.trim() == '' && stopAtBlankLine) {
break;
}

validTextFound = true;
startPos -= 1;
let node = tree.resolve(endPos);
// Skip the comments
if(node.type.name == 'LineComment' || node.type.name == 'BlockComment') {
endPos = node.to + 1;
continue;
}

// move forward from start position
let endPos = startPos+1;
maxEndPos = maxEndPos == -1 ? this.state.doc.length : maxEndPos;
while(endPos < maxEndPos) {
const currLine = this.state.doc.lineAt(endPos);
// Skip any other types
if(node.type.name != 'Statement') {
endPos += 1;
continue;
}

// If empty line in between then that's it
if(currLine.text.trim() == '') {
break;
}
// Can't go beyond a statement
if(node.type.name == 'Statement') {
maxEndPos = node.to;
}

let node = tree.resolve(endPos);
// Skip the comments
if(node.type.name == 'LineComment' || node.type.name == 'BlockComment') {
endPos = node.to + 1;
continue;
}
if(currLine.to < maxEndPos) {
endPos = currLine.to + 1;
} else {
endPos += 1;
}
}

// Skip any other types
if(node.type.name != 'Statement') {
endPos += 1;
continue;
}
// Make sure start and end are valid values
if(startPos < 0) startPos = 0;
if(endPos > this.state.doc.length) endPos = this.state.doc.length;

// can't go beyond a statement
if(node.type.name == 'Statement') {
maxEndPos = node.to;
}
return { startPos, endPos };
}

if(currLine.to < maxEndPos) {
endPos = currLine.to + 1;
} else {
endPos +=1;
}
/* Function to extract query based on position passed */
getQueryAt(currPos) {
try {
if(typeof currPos == 'undefined') {
currPos = this.state.selection.main.head;
}
const tree = syntaxTree(this.state);

// make sure start and end are valid values;
if(startPos < 0) startPos = 0;
if(endPos > this.state.doc.length) endPos = this.state.doc.length;
// First pass: find boundaries treating blank lines as boundaries
let { startPos, endPos } = this._findQueryBoundaries(currPos, tree, true);

// If a blank-line boundary cut through a Statement node, the
// extracted range is a fragment. Retry ignoring blank lines so
// the full statement (e.g. SELECT … FROM … WHERE across blank
// lines, or EXPLAIN followed by SELECT) is returned.
if (this._needsExpansion(startPos, endPos)) {
const expanded = this._findQueryBoundaries(currPos, tree, false);
startPos = expanded.startPos;
endPos = expanded.endPos;
}

return {
value: this.state.sliceDoc(startPos, endPos).trim(),
Expand Down
Loading
Loading