Skip to content

Commit a022f65

Browse files
authored
Merge pull request #1553 from utmstack/backlog/add-sql-hints-to-code-editor
Backlog/add sql hints to code editor
2 parents f48e2a5 + f9f2fdf commit a022f65

File tree

10 files changed

+350
-167
lines changed

10 files changed

+350
-167
lines changed

frontend/src/app/log-analyzer/explorer/log-analyzer-view/log-analyzer-view.component.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
(clearData)="onClearData()"
5050
[queryError]="sqlError"
5151
[customKeywords]="indexPatternNames.concat(fieldsNames)"
52+
[showSuggestions]="true"
5253
>
5354
</app-code-editor>
5455
</div>

frontend/src/app/shared/components/code-editor/code-editor.component.html

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<div #editorContainer class="editor-container">
2-
<div class="editor-header pr-2">
2+
<div class="editor-header pr-2" *ngIf="showFullEditor">
33
<div class="header-title">
44
<i class="icon-database"></i>
55
<span>SQL Console</span>
@@ -35,18 +35,20 @@
3535
</button>
3636
</div>
3737
</div>
38-
3938
<div class="editor-section">
39+
<app-query-suggestions *ngIf="showSuggestions"
40+
(selectQuery)="writeValue($event)">
41+
</app-query-suggestions>
4042
<ngx-monaco-editor
41-
class="editor-area"
43+
class="editor-area pt-2"
4244
[options]="consoleOptions"
4345
[(ngModel)]="sqlQuery"
44-
(ngModelChange)="resetMessages()"
45-
(onInit)="onEditorInit()">>
46+
(ngModelChange)="clearMessages()"
47+
(onInit)="onEditorInit($event)">>
4648
</ngx-monaco-editor>
4749
</div>
4850

49-
<div class="execution-info" *ngIf="successMessage || errorMessage">
51+
<div class="execution-info" *ngIf="(successMessage || errorMessage) && showFullEditor">
5052
<div [ngClass]="errorMessage ? 'text-danger-800' : 'text-green-800'">
5153
<span>{{ errorMessage || successMessage }}</span>
5254
</div>

frontend/src/app/shared/components/code-editor/code-editor.component.scss

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,19 @@
2929
}
3030

3131
.editor-section {
32+
display: flex;
33+
flex-direction: row;
3234
flex: 1;
3335
overflow: hidden;
3436
border-bottom: 1px solid #e0e6ed;
37+
background-color: #fffffe;
3538

3639
.editor-area {
3740
height: 100%;
3841
width: 100%;
3942
border: 1px solid rgba(0,0,0,.125);
43+
flex: 1;
44+
min-width: 0;
4045
}
4146
}
4247

@@ -60,10 +65,10 @@
6065
color: #fff !important;
6166
}
6267

63-
::ng-deep .monaco-editor {
64-
padding-top: 10px;
65-
}
66-
6768
.monaco-editor .suggest-widget .codicon {
6869
display: none !important;
6970
}
71+
72+
app-query-suggestions {
73+
min-width: 300px;
74+
}
Lines changed: 62 additions & 156 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import {
2-
Component, EventEmitter,
3-
Input, OnInit, Output
2+
Component, EventEmitter, forwardRef,
3+
Input, OnDestroy, OnInit, Output
44
} from '@angular/core';
5+
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
6+
import {SqlValidationService} from '../../services/code-editor/sql-validation.service';
57

6-
interface ConsoleOptions {
8+
export interface ConsoleOptions {
79
value?: string;
810
language?: 'sql';
911
theme?: 'vs' | 'vs-dark' | 'hc-black' | string;
@@ -17,7 +19,9 @@ interface ConsoleOptions {
1719
};
1820
overviewRulerLanes?: number;
1921
wordWrap?: 'off' | 'on' | 'wordWrapColumn' | 'bounded';
20-
automaticLayout: boolean;
22+
automaticLayout?: boolean;
23+
lineNumbers?: 'off' | 'on';
24+
cursorSmoothCaretAnimation?: 'off' | 'on';
2125
}
2226

2327
const SQL_KEYWORDS = ['CREATE', 'DROP', 'ALTER', 'TRUNCATE',
@@ -28,24 +32,34 @@ const SQL_KEYWORDS = ['CREATE', 'DROP', 'ALTER', 'TRUNCATE',
2832
'FROM', 'WHERE', 'GROUP BY', 'HAVING', 'ORDER BY', 'DISTINCT',
2933
'JOIN', 'INNER', 'LEFT', 'RIGHT', 'FULL', 'UNION', 'INTERSECT',
3034
'NULL', 'TRUE', 'FALSE',
31-
'AS', 'CASE', 'WHEN', 'THEN', 'END'
35+
'AS', 'CASE', 'WHEN', 'THEN', 'END',
36+
'LIMIT', 'OFFSET'
3237
];
3338

3439
@Component({
3540
selector: 'app-code-editor',
3641
templateUrl: './code-editor.component.html',
37-
styleUrls: ['./code-editor.component.scss']
42+
styleUrls: ['./code-editor.component.scss'],
43+
providers: [
44+
{
45+
provide: NG_VALUE_ACCESSOR,
46+
useExisting: forwardRef(() => CodeEditorComponent),
47+
multi: true
48+
}
49+
]
3850
})
39-
export class CodeEditorComponent implements OnInit {
51+
export class CodeEditorComponent implements OnInit, OnDestroy, ControlValueAccessor {
52+
@Input() showFullEditor = true;
53+
@Input() showSuggestions = false;
4054
@Input() consoleOptions?: ConsoleOptions;
4155
@Output() execute = new EventEmitter<string>();
4256
@Output() clearData = new EventEmitter<void>();
4357
@Input() queryError: string | null = null;
4458
@Input() customKeywords: string[] = [];
59+
4560
sqlQuery = '';
4661
errorMessage = '';
4762
successMessage = '';
48-
4963
readonly defaultOptions: ConsoleOptions = {
5064
value: this.sqlQuery,
5165
language: 'sql',
@@ -60,17 +74,29 @@ export class CodeEditorComponent implements OnInit {
6074
},
6175
overviewRulerLanes: 0,
6276
wordWrap: 'on',
63-
automaticLayout: true
77+
automaticLayout: true,
78+
lineNumbers: 'on',
79+
cursorSmoothCaretAnimation: 'off'
6480
};
81+
private completionProvider?: monaco.IDisposable;
82+
private onChange = (_: any) => {};
83+
private onTouched = () => {};
6584

66-
constructor() {}
85+
constructor(private sqlValidationService: SqlValidationService) {}
6786

6887
ngOnInit(): void {
6988
this.consoleOptions = { ...this.defaultOptions, ...this.consoleOptions };
7089
}
7190

72-
onEditorInit() {
73-
monaco.languages.registerCompletionItemProvider('sql', {
91+
ngOnDestroy(): void {
92+
if (this.completionProvider) {
93+
this.completionProvider.dispose();
94+
this.completionProvider = undefined;
95+
}
96+
}
97+
98+
onEditorInit(editorInstance: monaco.editor.IStandaloneCodeEditor) {
99+
this.completionProvider = monaco.languages.registerCompletionItemProvider('sql', {
74100
provideCompletionItems: () => {
75101
const allKeywords = Array.from(new Set([
76102
...SQL_KEYWORDS,
@@ -86,24 +112,24 @@ export class CodeEditorComponent implements OnInit {
86112
return { suggestions };
87113
}
88114
});
115+
116+
editorInstance.onDidChangeModelContent(() => {
117+
const val = editorInstance.getValue();
118+
this.sqlQuery = val;
119+
this.onChange(val);
120+
this.onTouched();
121+
});
89122
}
90123

91124
executeQuery(): void {
92-
this.resetMessages();
93-
const query = this.sqlQuery ? this.sqlQuery.trim() : '';
94-
if (!query) {
95-
this.errorMessage = 'The query cannot be empty.';
96-
return;
97-
}
98-
99-
const validationError = this.validateSqlQuery(query);
100-
if (validationError) {
101-
this.errorMessage = validationError;
125+
this.clearMessages();
126+
this.errorMessage = this.sqlValidationService.validateSqlQuery(this.sqlQuery);
127+
if (this.errorMessage) {
102128
return;
103129
}
104130

105131
try {
106-
const cleanedQuery = query.replace(/\n/g, ' ');
132+
const cleanedQuery = this.sqlQuery.replace(/\n/g, ' ');
107133
this.execute.emit(cleanedQuery);
108134
} catch (err) {
109135
this.errorMessage = err instanceof Error ? err.message : String(err);
@@ -112,20 +138,19 @@ export class CodeEditorComponent implements OnInit {
112138

113139
clearQuery(): void {
114140
this.sqlQuery = '';
115-
this.resetMessages();
141+
this.clearMessages();
116142
this.clearData.emit();
117143
}
118144

119145
formatQuery(): void {
120-
this.resetMessages();
146+
this.clearMessages();
121147
this.sqlQuery = this.formatSql(this.sqlQuery);
122148
}
123149

124150
private formatSql(sql: string): string {
125-
const keywords = ['SELECT', 'FROM', 'WHERE', 'JOIN', 'LEFT', 'RIGHT', 'INNER', 'ON', 'GROUP BY', 'ORDER BY', 'LIMIT'];
126151
let formatted = sql;
127152

128-
keywords.forEach(keyword => {
153+
SQL_KEYWORDS.forEach(keyword => {
129154
const regex = new RegExp(`\\b${keyword}\\b`, 'gi');
130155
formatted = formatted.replace(regex, `\n${keyword}`);
131156
});
@@ -134,148 +159,29 @@ export class CodeEditorComponent implements OnInit {
134159
}
135160

136161
copyQuery(): void {
137-
this.resetMessages();
162+
this.clearMessages();
138163
(navigator as any).clipboard.writeText(this.sqlQuery);
139164
this.successMessage = 'Query copied to clipboard.';
140165
}
141166

142-
resetMessages(): void {
167+
clearMessages(): void {
143168
this.errorMessage = '';
144169
this.successMessage = '';
145170
}
146171

147-
private validateSqlQuery(query: string): string | null {
148-
const trimmed = query.trim().replace(/;+\s*$/, '');
149-
const upper = trimmed.toUpperCase();
150-
151-
const startPattern = /^\s*SELECT\b/i;
152-
if (!startPattern.test(trimmed)) {
153-
return 'Query must start with SELECT.';
154-
}
155-
156-
const minimalPattern = /^\s*SELECT\s+.+\s+FROM\s+.+/is;
157-
if (!minimalPattern.test(trimmed)) {
158-
return 'Query must be at least: SELECT <columns> FROM <table>.';
159-
}
160-
161-
const forbiddenPattern = /\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|REPLACE|TRUNCATE|MERGE|GRANT|REVOKE|EXEC|EXECUTE|COMMIT|ROLLBACK|INTO)\b/i;
162-
const commentPattern = /(--.*?$|\/\*.*?\*\/)/gm;
163-
const allowedFunctions = new Set(['COUNT', 'AVG', 'MIN', 'MAX', 'SUM']);
164-
165-
if (forbiddenPattern.test(upper)) {
166-
return 'Query contains forbidden SQL keywords.';
167-
}
168-
if (commentPattern.test(trimmed)) {
169-
return 'Query must not contain SQL comments (-- or /* */).';
170-
}
171-
if (trimmed.includes(';')) {
172-
return 'Query must not contain internal semicolons.';
173-
}
174-
if (!this.balancedQuotes(trimmed)) {
175-
return 'Quotes are not balanced.';
176-
}
177-
if (!this.balancedParentheses(trimmed)) {
178-
return 'Parentheses are not balanced.';
179-
}
180-
181-
if (this.hasMisplacedCommas(trimmed)) {
182-
return 'Query contains misplaced commas.';
183-
}
184-
185-
if (this.hasSubqueryWithoutAlias(trimmed)) {
186-
return 'Subquery in FROM must have an alias.';
187-
}
188-
189-
const functions = this.extractFunctions(upper);
190-
for (const func of functions) {
191-
if (!allowedFunctions.has(func)) {
192-
return `Unsupported SQL function: ${func}.`;
193-
}
194-
}
195-
196-
return null;
197-
}
198-
199-
private balancedParentheses(query: string): boolean {
200-
let count = 0;
201-
for (const c of query) {
202-
if (c === '(') {
203-
count++;
204-
} else if (c === ')') {
205-
count--;
206-
}
207-
if (count < 0) {
208-
return false;
209-
}
210-
}
211-
return count === 0;
172+
writeValue(value: any): void {
173+
this.sqlQuery = value || '';
212174
}
213175

214-
private balancedQuotes(query: string): boolean {
215-
let sq = 0;
216-
let dq = 0;
217-
let escaped = false; for (const c of query) {
218-
if (escaped) { escaped = false; continue; }
219-
if (c === '\\') { escaped = true; continue; }
220-
if (c === '\'') {
221-
sq++;
222-
} else {
223-
if (c === '"') { dq++; }
224-
}
225-
}
226-
return (sq % 2 === 0) && (dq % 2 === 0);
176+
registerOnChange(fn: any): void {
177+
this.onChange = fn;
227178
}
228179

229-
private extractFunctions(upperQuery: string): string[] {
230-
const funcPattern = /\b(COUNT|AVG|MIN|MAX|SUM)\s*\(/g;
231-
const funcs: string[] = [];
232-
233-
let match: RegExpExecArray | null = funcPattern.exec(upperQuery);
234-
while (match !== null) {
235-
funcs.push(match[1]);
236-
match = funcPattern.exec(upperQuery);
237-
}
238-
239-
return funcs;
180+
registerOnTouched(fn: any): void {
181+
this.onTouched = fn;
240182
}
241183

242-
private hasMisplacedCommas(query: string): boolean {
243-
const upperQuery = query.toUpperCase();
244-
245-
if (upperQuery.startsWith('SELECT ,') || upperQuery.includes(',,')) {
246-
return true;
247-
}
248-
249-
if (/,\s*FROM/i.test(upperQuery)) {
250-
return true;
251-
}
252-
253-
const selectPart = query
254-
.replace(/^SELECT\s+/i, '')
255-
.replace(/\s+FROM.*$/i, '')
256-
.trim();
257-
258-
if (selectPart.startsWith(',') || selectPart.endsWith(',')) {
259-
return true;
260-
}
261-
262-
const fields = selectPart.split(',');
263-
for (const f of fields) {
264-
if (f.trim() === '') {
265-
return true;
266-
}
267-
}
268-
269-
return false;
270-
}
271-
272-
private hasSubqueryWithoutAlias(query: string): boolean {
273-
const subqueryRegex = /FROM\s*\([^)]*\)/i;
274-
if (!subqueryRegex.test(query)) {
275-
return false;
276-
}
277-
const aliasRegex = /FROM\s*\([^)]*\)\s+(AS\s+\w+|\w+)/i;
278-
return !aliasRegex.test(query);
184+
setDisabledState?(isDisabled: boolean): void {
185+
// Optional: handle disabled state
279186
}
280-
281187
}

0 commit comments

Comments
 (0)