쿼리 편집기 — 서드파티 없이 만든 구문 강조와 편집 보조
배포본에 서드파티 편집기 DLL 을 넣지 않기로 해서 직접 만들었다. ■ 색칠 방식 — 투명 TextBox 뒤에 그린다 RichTextBox 로 직접 칠하면 입력할 때마다 서식을 다시 입혀야 해서 실행취소 묶음이 깨지고 IME 조합 중 깜빡인다. 대신 편집은 평범한 TextBox 가 그대로 하고(캐럿·선택·실행취소·IME·스크롤이 전부 공짜다) 글자만 투명하게 해서, 뒤에 깔린 SqlHighlightLayer 가 같은 자리에 색을 입혀 그린다. 맞춰야 하는 것은 하나뿐이다 — 글꼴·크기·여백·줄바꿈 설정이 TextBox 와 완전히 같을 것. 성능은 화면에 보이는 줄만 그려서 잡았다. x 위치는 앞부분을 다시 재서 잡는다 — 조각 폭을 누적하면 자간·힌팅 때문에 뒤로 갈수록 한두 픽셀씩 밀린다. ■ 조각내기(SqlTokenizer) — Core 에 순수 함수로 예약어 · 내장함수 · 숫자 · 문자열 · 주석 · 치환변수, 그리고 Broken. Broken 을 따로 둔 것이 요점이다. 이 쿼리는 EMR 런타임에서 돌고 실패해도 빈 catch 가 삼켜 화면에 아무 표시가 없다. 닫히지 않은 따옴표나 << >> 는 편집기에서 즉시 눈에 띄어야 한다. Oracle 방언 기준이고, '' 이스케이프를 문자열 끊김으로 오인하지 않는다. 줄바꿈을 넘긴 문자열은 닫는 따옴표를 빠뜨린 것으로 본다 — 안 그러면 뒤 문장 전체가 문자열로 물든다. 조각은 겹치지 않고 전 구간을 덮는다(빈틈이 있으면 그 글자가 안 그려진다). 테스트로 고정했다. ■ 편집 보조 줄 번호 · 글자/줄 수 · Tab·Shift+Tab 들여쓰기(선택 줄 단위) · Enter 자동 들여쓰기 · Ctrl+/ 주석 토글(섞인 상태면 전부 붙인다 — 토글이 예측 가능해진다). 저장 전 경고 두 가지를 하단에 띄운다: 닫히지 않은 조각, 그리고 카탈로그에 없는 치환 변수. 후자는 접두어를 임의로 줄여 쓴 경우를 잡는다 — 런타임은 그걸 치환하지 못하고 <<...>> 를 SQL 에 그대로 남겨 ORA 오류를 내며, 그 데이터소스를 참조하는 컨트롤이 전부 공백이 된다. ■ 치환 변수 패널 이름·설명·초성으로 좁히는 검색을 붙였다(ㅊㅌㅂㅎ → 차트번호). Enter 로도 삽입된다. 변수 목록 자체의 전량 이식은 별도 커밋으로 이어간다 — 현재 25개는 레거시 surface 의 절반이다. SQL 이 아닌 용도(상용구 문구)로 재사용할 때는 색칠을 끈다. 예약어와 겹치는 낱말이 엉뚱하게 물들면 안 된다. 단위 테스트 12건 추가(전 구간 덮기·대소문자·식별자 속 예약어 오검출·'' 이스케이프· 변수 통째 인식·닫히지 않은 조각 2종·줄 넘긴 문자열). 회귀: 테스트 174/174, 편집 스모크 실패 0, DB 왕복 1,271건 diff 0/예외 0, 종이 렌더 P062 바이트 동일. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
146d808aac
commit
de46f70035
@@ -1,22 +1,31 @@
|
||||
using System.ComponentModel;
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Input;
|
||||
using SheetMe.Core.Catalog;
|
||||
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 데이터소스(MDataTable) 쿼리 전용 편집기 — 큰 SQL 편집 영역 + 치환 변수 삽입.
|
||||
/// 데이터소스(MDataTable) 쿼리 전용 편집기 — 구문 강조 + 편집 보조 + 치환 변수 삽입.
|
||||
///
|
||||
/// 치환 규칙 원본: [014]EMRLoader bzDesignSheetLoader.ConvertQuery.
|
||||
/// 토큰 접두어는 <b>클래스 전체 이름</b>이어야 하며 런타임이 GetType.FullName 과 완전일치로 비교한다
|
||||
/// (ucLoadSheetBase.vb:7619-7631 이 형을 고정). 목록은 <see cref="LegacyQueryVariableCatalog"/> 참조.
|
||||
///
|
||||
/// 색칠은 <see cref="Controls.SqlHighlightLayer"/> 가 뒤에서 하고 편집은 투명 TextBox 가 그대로 한다 —
|
||||
/// 서드파티 편집기 컴포넌트를 배포본에 넣지 않기로 해서 직접 만든 구성이다.
|
||||
///
|
||||
/// 큰 다중행 편집 영역이 필요한 다른 용도(상용구 문구)에도 재사용한다 —
|
||||
/// 그 경우 <c>showVariables: false</c> 로 SQL 변수 패널을 숨긴다.
|
||||
/// 그 경우 <c>showVariables: false</c> 로 SQL 변수 패널을 숨기고 색칠도 끈다.
|
||||
/// </summary>
|
||||
public partial class QueryEditorWindow : Window
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly bool isSql;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>편집 결과 텍스트 — 확인 시 채워짐</summary>
|
||||
public string QueryText { get; private set; } = string.Empty;
|
||||
@@ -29,19 +38,21 @@ public partial class QueryEditorWindow : Window
|
||||
public QueryEditorWindow(string ownerLabel, string initialQuery, bool showVariables = true)
|
||||
{
|
||||
InitializeComponent();
|
||||
isSql = showVariables;
|
||||
SqlBox.Text = initialQuery;
|
||||
|
||||
if (showVariables)
|
||||
{
|
||||
Title = $"쿼리 편집 — {ownerLabel}";
|
||||
var view = new CollectionViewSource { Source = LegacyQueryVariableCatalog.All };
|
||||
view.GroupDescriptions.Add(new PropertyGroupDescription(nameof(QueryVariable.Group)));
|
||||
VariableList.ItemsSource = view.View;
|
||||
Highlight.Source = SqlBox;
|
||||
ApplyVariableFilter(string.Empty);
|
||||
}
|
||||
else
|
||||
{
|
||||
// SQL 이 아닌 텍스트에 SQL 색을 칠하면 예약어와 겹치는 낱말이 엉뚱하게 물든다
|
||||
Title = ownerLabel;
|
||||
HintText.Text = "내용을 편집한 뒤 확인을 누르세요.";
|
||||
SqlBox.Foreground = (System.Windows.Media.Brush)FindResource("B.Ink");
|
||||
VariablePane.Visibility = Visibility.Collapsed;
|
||||
SplitterColumn.Width = new GridLength(0);
|
||||
VariableColumn.Width = new GridLength(0);
|
||||
@@ -51,15 +62,267 @@ public partial class QueryEditorWindow : Window
|
||||
{
|
||||
SqlBox.Focus();
|
||||
SqlBox.CaretIndex = SqlBox.Text.Length;
|
||||
Refresh();
|
||||
};
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
private void OnSqlChanged(object sender, TextChangedEventArgs e)
|
||||
=> LengthText.Text = $"{SqlBox.Text.Length:N0}자";
|
||||
#region Methods - Editing
|
||||
private void OnSqlChanged(object sender, TextChangedEventArgs e) => Refresh();
|
||||
|
||||
private void OnInsertVariable(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
||||
private void Refresh()
|
||||
{
|
||||
var text = SqlBox.Text;
|
||||
LengthText.Text = $"{text.Length:N0}자 · {CountLines(text)}줄";
|
||||
UpdateLineNumbers(text);
|
||||
UpdateProblems(text);
|
||||
}
|
||||
|
||||
private static int CountLines(string text)
|
||||
{
|
||||
var lines = 1;
|
||||
foreach (var ch in text)
|
||||
{
|
||||
if (ch == '\n')
|
||||
{
|
||||
lines++;
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private void UpdateLineNumbers(string text)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
var count = CountLines(text);
|
||||
for (var i = 1; i <= count; i++)
|
||||
{
|
||||
builder.Append(i).Append('\n');
|
||||
}
|
||||
LineNumbers.Text = builder.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 저장 전에 눈에 띄어야 하는 것만 알린다 — 닫히지 않은 조각과 축약된 치환 변수.
|
||||
///
|
||||
/// 런타임은 치환에 실패해도 빈 catch 로 삼켜 화면에 아무 표시가 없다.
|
||||
/// 여기서 잡지 못하면 배포된 뒤 임상 화면에서 빈칸으로만 드러난다.
|
||||
/// </summary>
|
||||
private void UpdateProblems(string text)
|
||||
{
|
||||
if (!isSql)
|
||||
{
|
||||
ProblemText.Text = string.Empty;
|
||||
return;
|
||||
}
|
||||
|
||||
var problems = new List<string>();
|
||||
if (SqlTokenizer.Tokenize(text).Any(t => t.Kind == SqlTokenKind.Broken))
|
||||
{
|
||||
problems.Add("닫히지 않은 따옴표 또는 << >>");
|
||||
}
|
||||
|
||||
var known = LegacyQueryVariableCatalog.All.Select(v => v.Token).ToHashSet(StringComparer.Ordinal);
|
||||
var unknown = SqlTokenizer.VariablesIn(text)
|
||||
.Where(v => !known.Contains(v))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
if (unknown.Count > 0)
|
||||
{
|
||||
problems.Add($"목록에 없는 변수 {unknown.Count}건: {unknown[0]}");
|
||||
}
|
||||
|
||||
ProblemText.Text = problems.Count == 0 ? string.Empty : string.Join(" · ", problems);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 편집 보조 — Tab 들여쓰기, Enter 자동 들여쓰기, Ctrl+/ 주석 토글.
|
||||
/// TextBox 가 기본으로 주지 않는 것 중 SQL 을 칠 때 손이 가장 많이 가는 것들만 넣었다.
|
||||
/// </summary>
|
||||
private void OnSqlKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
var ctrl = (Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control;
|
||||
var shift = (Keyboard.Modifiers & ModifierKeys.Shift) == ModifierKeys.Shift;
|
||||
|
||||
if (e.Key == Key.Tab)
|
||||
{
|
||||
IndentSelection(outdent: shift);
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
if (ctrl && (e.Key == Key.Oem2 || e.Key == Key.Divide))
|
||||
{
|
||||
ToggleComment();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
if (e.Key == Key.Return && !ctrl)
|
||||
{
|
||||
AutoIndentNewLine();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>현재 줄의 시작 위치</summary>
|
||||
private int LineStartOf(int index)
|
||||
{
|
||||
var text = SqlBox.Text;
|
||||
var start = index;
|
||||
while (start > 0 && text[start - 1] != '\n')
|
||||
{
|
||||
start--;
|
||||
}
|
||||
return start;
|
||||
}
|
||||
|
||||
/// <summary>Enter — 앞 줄의 들여쓰기를 그대로 물려준다</summary>
|
||||
private void AutoIndentNewLine()
|
||||
{
|
||||
var text = SqlBox.Text;
|
||||
var caret = SqlBox.SelectionStart;
|
||||
var lineStart = LineStartOf(caret);
|
||||
var indent = 0;
|
||||
while (lineStart + indent < text.Length && lineStart + indent < caret
|
||||
&& (text[lineStart + indent] == ' ' || text[lineStart + indent] == '\t'))
|
||||
{
|
||||
indent++;
|
||||
}
|
||||
var insert = "\n" + text.Substring(lineStart, indent);
|
||||
var selectionLength = SqlBox.SelectionLength;
|
||||
SqlBox.Text = text.Remove(caret, selectionLength).Insert(caret, insert);
|
||||
SqlBox.CaretIndex = caret + insert.Length;
|
||||
}
|
||||
|
||||
/// <summary>Tab / Shift+Tab — 선택한 줄 전체를 들여쓰거나 내어쓴다</summary>
|
||||
private void IndentSelection(bool outdent)
|
||||
{
|
||||
const string Unit = " ";
|
||||
var text = SqlBox.Text;
|
||||
var start = SqlBox.SelectionStart;
|
||||
var length = SqlBox.SelectionLength;
|
||||
|
||||
// 선택이 없으면 커서 자리에 공백만 넣는다(내어쓰기는 줄 단위로 동작)
|
||||
if (length == 0 && !outdent)
|
||||
{
|
||||
SqlBox.Text = text.Insert(start, Unit);
|
||||
SqlBox.CaretIndex = start + Unit.Length;
|
||||
return;
|
||||
}
|
||||
|
||||
var blockStart = LineStartOf(start);
|
||||
var blockEnd = start + length;
|
||||
while (blockEnd < text.Length && text[blockEnd] != '\n')
|
||||
{
|
||||
blockEnd++;
|
||||
}
|
||||
|
||||
var lines = text[blockStart..blockEnd].Split('\n');
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
if (outdent)
|
||||
{
|
||||
if (lines[i].StartsWith(Unit, StringComparison.Ordinal))
|
||||
{
|
||||
lines[i] = lines[i][Unit.Length..];
|
||||
}
|
||||
else
|
||||
{
|
||||
lines[i] = lines[i].TrimStart(' ', '\t');
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
lines[i] = Unit + lines[i];
|
||||
}
|
||||
}
|
||||
|
||||
var replaced = string.Join('\n', lines);
|
||||
SqlBox.Text = text[..blockStart] + replaced + text[blockEnd..];
|
||||
SqlBox.SelectionStart = blockStart;
|
||||
SqlBox.SelectionLength = replaced.Length;
|
||||
}
|
||||
|
||||
/// <summary>Ctrl+/ — 선택한 줄에 -- 를 붙이거나 뗀다</summary>
|
||||
private void ToggleComment()
|
||||
{
|
||||
const string Mark = "-- ";
|
||||
var text = SqlBox.Text;
|
||||
var start = SqlBox.SelectionStart;
|
||||
var blockStart = LineStartOf(start);
|
||||
var blockEnd = start + SqlBox.SelectionLength;
|
||||
while (blockEnd < text.Length && text[blockEnd] != '\n')
|
||||
{
|
||||
blockEnd++;
|
||||
}
|
||||
|
||||
var lines = text[blockStart..blockEnd].Split('\n');
|
||||
// 한 줄이라도 주석이 아니면 전부 붙인다 — 섞인 상태에서 토글이 예측 가능해진다
|
||||
var allCommented = lines.All(l => l.TrimStart().StartsWith("--", StringComparison.Ordinal));
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
if (allCommented)
|
||||
{
|
||||
var trimmed = lines[i].TrimStart();
|
||||
var pad = lines[i][..(lines[i].Length - trimmed.Length)];
|
||||
trimmed = trimmed[2..];
|
||||
if (trimmed.StartsWith(' '))
|
||||
{
|
||||
trimmed = trimmed[1..];
|
||||
}
|
||||
lines[i] = pad + trimmed;
|
||||
}
|
||||
else
|
||||
{
|
||||
lines[i] = Mark + lines[i];
|
||||
}
|
||||
}
|
||||
|
||||
var replaced = string.Join('\n', lines);
|
||||
SqlBox.Text = text[..blockStart] + replaced + text[blockEnd..];
|
||||
SqlBox.SelectionStart = blockStart;
|
||||
SqlBox.SelectionLength = replaced.Length;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods - Variables
|
||||
private void OnVariableSearchChanged(object sender, TextChangedEventArgs e)
|
||||
=> ApplyVariableFilter(VariableSearch.Text);
|
||||
|
||||
/// <summary>이름·설명·초성으로 좁힌다 — 목록이 길어 눈으로 훑기 어렵다</summary>
|
||||
private void ApplyVariableFilter(string keyword)
|
||||
{
|
||||
var tokens = keyword.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var matched = tokens.Length == 0
|
||||
? LegacyQueryVariableCatalog.All
|
||||
: LegacyQueryVariableCatalog.All
|
||||
.Where(v => tokens.All(t =>
|
||||
TagSearch.Matches(v.Description, t) || TagSearch.Matches(v.Token, t)))
|
||||
.ToList();
|
||||
|
||||
var view = new CollectionViewSource { Source = matched };
|
||||
view.GroupDescriptions.Add(new PropertyGroupDescription(nameof(QueryVariable.Group)));
|
||||
VariableList.ItemsSource = view.View;
|
||||
VariableHint.Text = matched.Count == LegacyQueryVariableCatalog.All.Count
|
||||
? $"{matched.Count}개 · 더블클릭 또는 Enter 로 삽입합니다."
|
||||
: $"{matched.Count}/{LegacyQueryVariableCatalog.All.Count}개 · 더블클릭 또는 Enter 로 삽입합니다.";
|
||||
if (matched.Count > 0)
|
||||
{
|
||||
VariableList.SelectedIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnVariableListKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.Return)
|
||||
{
|
||||
InsertSelectedVariable();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnInsertVariable(object sender, MouseButtonEventArgs e) => InsertSelectedVariable();
|
||||
|
||||
private void InsertSelectedVariable()
|
||||
{
|
||||
if (VariableList.SelectedItem is not QueryVariable variable)
|
||||
{
|
||||
@@ -70,7 +333,9 @@ public partial class QueryEditorWindow : Window
|
||||
SqlBox.CaretIndex = caret + variable.Token.Length;
|
||||
SqlBox.Focus();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods - Confirm
|
||||
private void OnConfirm(object sender, RoutedEventArgs e)
|
||||
{
|
||||
QueryText = SqlBox.Text;
|
||||
|
||||
Reference in New Issue
Block a user