쿼리 편집기 — 서드파티 없이 만든 구문 강조와 편집 보조
배포본에 서드파티 편집기 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
@@ -0,0 +1,236 @@
|
|||||||
|
namespace SheetMe.Core.Catalog;
|
||||||
|
|
||||||
|
/// <summary>SQL 조각의 종류 — 색을 정하는 단위</summary>
|
||||||
|
public enum SqlTokenKind
|
||||||
|
{
|
||||||
|
/// <summary>그 밖(식별자·연산자·공백)</summary>
|
||||||
|
Plain,
|
||||||
|
/// <summary>예약어(SELECT, FROM …)</summary>
|
||||||
|
Keyword,
|
||||||
|
/// <summary>내장 함수(NVL, TO_CHAR …)</summary>
|
||||||
|
Function,
|
||||||
|
/// <summary>숫자 리터럴</summary>
|
||||||
|
Number,
|
||||||
|
/// <summary>문자열 리터럴('...')</summary>
|
||||||
|
Text,
|
||||||
|
/// <summary>주석(-- 또는 /* */)</summary>
|
||||||
|
Comment,
|
||||||
|
/// <summary>치환 변수(<<...>>) — 이 편집기에서 가장 중요한 조각</summary>
|
||||||
|
Variable,
|
||||||
|
/// <summary>따옴표가 닫히지 않은 문자열처럼, 그대로 두면 오류가 나는 조각</summary>
|
||||||
|
Broken,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>텍스트 안의 한 조각</summary>
|
||||||
|
public readonly record struct SqlToken(int Start, int Length, SqlTokenKind Kind)
|
||||||
|
{
|
||||||
|
/// <summary>조각 끝(배타)</summary>
|
||||||
|
public int End => Start + Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SQL 구문 조각내기 — 편집기의 색칠 근거.
|
||||||
|
///
|
||||||
|
/// <b>왜 직접 만드는가.</b> 서드파티 편집기 컴포넌트를 배포본에 넣지 않기로 했다.
|
||||||
|
/// 필요한 것은 완전한 파서가 아니라 <b>보고 판단할 수 있을 만큼의 구분</b>이다:
|
||||||
|
/// 예약어·문자열·주석·숫자, 그리고 이 시스템에서만 의미가 있는 <c><<치환변수>></c>.
|
||||||
|
///
|
||||||
|
/// <b>Broken 을 따로 두는 이유.</b> 이 쿼리는 실행 시점에 치환된 뒤 EMR 런타임에서 돌고,
|
||||||
|
/// 실패해도 빈 catch 가 삼켜 화면에는 아무 표시가 없다. 닫히지 않은 따옴표처럼
|
||||||
|
/// 확실히 깨진 조각은 편집기에서 즉시 눈에 띄어야 한다.
|
||||||
|
///
|
||||||
|
/// 방언은 Oracle 기준이다(M.CMM.DBAccess 가 Oracle.ManagedDataAccess 를 쓴다).
|
||||||
|
/// </summary>
|
||||||
|
public static class SqlTokenizer
|
||||||
|
{
|
||||||
|
#region Member Fields
|
||||||
|
/// <summary>Oracle SQL 예약어 — 대소문자 무시</summary>
|
||||||
|
public static readonly IReadOnlySet<string> Keywords = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"SELECT", "FROM", "WHERE", "AND", "OR", "NOT", "NULL", "IS", "IN", "EXISTS",
|
||||||
|
"BETWEEN", "LIKE", "ORDER", "BY", "GROUP", "HAVING", "DISTINCT", "ALL",
|
||||||
|
"UNION", "INTERSECT", "MINUS", "JOIN", "INNER", "LEFT", "RIGHT", "FULL",
|
||||||
|
"OUTER", "CROSS", "ON", "AS", "CASE", "WHEN", "THEN", "ELSE", "END",
|
||||||
|
"INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", "MERGE", "USING",
|
||||||
|
"CREATE", "ALTER", "DROP", "TABLE", "VIEW", "INDEX", "WITH", "DUAL",
|
||||||
|
"ASC", "DESC", "CONNECT", "START", "PRIOR", "LEVEL", "ROWNUM", "ROWID",
|
||||||
|
"PARTITION", "OVER", "FETCH", "FIRST", "NEXT", "ROWS", "ONLY", "OFFSET",
|
||||||
|
"PIVOT", "UNPIVOT", "ANY", "SOME", "CAST", "DECODE",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>자주 쓰는 Oracle 내장 함수 — 예약어와 색을 달리해 눈에 덜 튀게</summary>
|
||||||
|
public static readonly IReadOnlySet<string> Functions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"NVL", "NVL2", "COALESCE", "SUBSTR", "SUBSTRB", "INSTR", "LENGTH", "LENGTHB",
|
||||||
|
"TRIM", "LTRIM", "RTRIM", "LPAD", "RPAD", "REPLACE", "TRANSLATE", "UPPER", "LOWER",
|
||||||
|
"TO_CHAR", "TO_DATE", "TO_NUMBER", "TO_TIMESTAMP", "SYSDATE", "SYSTIMESTAMP",
|
||||||
|
"ADD_MONTHS", "MONTHS_BETWEEN", "LAST_DAY", "NEXT_DAY", "TRUNC", "ROUND",
|
||||||
|
"COUNT", "SUM", "AVG", "MIN", "MAX", "ROW_NUMBER", "RANK", "DENSE_RANK",
|
||||||
|
"LISTAGG", "WM_CONCAT", "GREATEST", "LEAST", "MOD", "ABS", "CEIL", "FLOOR",
|
||||||
|
"SIGN", "POWER", "SQRT", "REGEXP_LIKE", "REGEXP_SUBSTR", "REGEXP_REPLACE",
|
||||||
|
};
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Methods
|
||||||
|
/// <summary>
|
||||||
|
/// 전체를 조각으로 나눈다 — 조각은 겹치지 않고 순서대로이며, 빈틈은 Plain 으로 채워진다.
|
||||||
|
/// 색칠하는 쪽이 위치 계산을 다시 하지 않도록 전 구간을 덮는다.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<SqlToken> Tokenize(string text)
|
||||||
|
{
|
||||||
|
var tokens = new List<SqlToken>();
|
||||||
|
if (string.IsNullOrEmpty(text))
|
||||||
|
{
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
var i = 0;
|
||||||
|
var plainStart = 0;
|
||||||
|
|
||||||
|
void FlushPlain(int upto)
|
||||||
|
{
|
||||||
|
if (upto > plainStart)
|
||||||
|
{
|
||||||
|
tokens.Add(new SqlToken(plainStart, upto - plainStart, SqlTokenKind.Plain));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (i < text.Length)
|
||||||
|
{
|
||||||
|
var ch = text[i];
|
||||||
|
|
||||||
|
// 치환 변수 <<...>> — 닫히지 않으면 SQL 에 그대로 남아 오류가 난다
|
||||||
|
if (ch == '<' && i + 1 < text.Length && text[i + 1] == '<')
|
||||||
|
{
|
||||||
|
var close = text.IndexOf(">>", i + 2, StringComparison.Ordinal);
|
||||||
|
FlushPlain(i);
|
||||||
|
if (close < 0)
|
||||||
|
{
|
||||||
|
tokens.Add(new SqlToken(i, text.Length - i, SqlTokenKind.Broken));
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
tokens.Add(new SqlToken(i, close + 2 - i, SqlTokenKind.Variable));
|
||||||
|
i = close + 2;
|
||||||
|
plainStart = i;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 한 줄 주석
|
||||||
|
if (ch == '-' && i + 1 < text.Length && text[i + 1] == '-')
|
||||||
|
{
|
||||||
|
var lineEnd = text.IndexOf('\n', i);
|
||||||
|
var end = lineEnd < 0 ? text.Length : lineEnd;
|
||||||
|
FlushPlain(i);
|
||||||
|
tokens.Add(new SqlToken(i, end - i, SqlTokenKind.Comment));
|
||||||
|
i = end;
|
||||||
|
plainStart = i;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 블록 주석
|
||||||
|
if (ch == '/' && i + 1 < text.Length && text[i + 1] == '*')
|
||||||
|
{
|
||||||
|
var close = text.IndexOf("*/", i + 2, StringComparison.Ordinal);
|
||||||
|
var end = close < 0 ? text.Length : close + 2;
|
||||||
|
FlushPlain(i);
|
||||||
|
tokens.Add(new SqlToken(i, end - i, SqlTokenKind.Comment));
|
||||||
|
i = end;
|
||||||
|
plainStart = i;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 문자열 — Oracle 은 '' 로 작은따옴표를 이스케이프한다
|
||||||
|
if (ch == '\'')
|
||||||
|
{
|
||||||
|
var j = i + 1;
|
||||||
|
var closed = false;
|
||||||
|
while (j < text.Length)
|
||||||
|
{
|
||||||
|
if (text[j] == '\'')
|
||||||
|
{
|
||||||
|
if (j + 1 < text.Length && text[j + 1] == '\'')
|
||||||
|
{
|
||||||
|
j += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
closed = true;
|
||||||
|
j++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// 줄바꿈을 넘긴 문자열은 거의 항상 닫는 따옴표를 빠뜨린 것이다
|
||||||
|
if (text[j] == '\n')
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
FlushPlain(i);
|
||||||
|
tokens.Add(new SqlToken(i, j - i, closed ? SqlTokenKind.Text : SqlTokenKind.Broken));
|
||||||
|
i = j;
|
||||||
|
plainStart = i;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 숫자
|
||||||
|
if (char.IsDigit(ch) && (i == 0 || !IsWordChar(text[i - 1])))
|
||||||
|
{
|
||||||
|
var j = i;
|
||||||
|
while (j < text.Length && (char.IsDigit(text[j]) || text[j] == '.'))
|
||||||
|
{
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
FlushPlain(i);
|
||||||
|
tokens.Add(new SqlToken(i, j - i, SqlTokenKind.Number));
|
||||||
|
i = j;
|
||||||
|
plainStart = i;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 낱말 — 예약어/함수만 골라낸다
|
||||||
|
if (IsWordStart(ch))
|
||||||
|
{
|
||||||
|
var j = i;
|
||||||
|
while (j < text.Length && IsWordChar(text[j]))
|
||||||
|
{
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
var word = text[i..j];
|
||||||
|
if (Keywords.Contains(word) || Functions.Contains(word))
|
||||||
|
{
|
||||||
|
FlushPlain(i);
|
||||||
|
tokens.Add(new SqlToken(i, j - i,
|
||||||
|
Keywords.Contains(word) ? SqlTokenKind.Keyword : SqlTokenKind.Function));
|
||||||
|
plainStart = j;
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
FlushPlain(text.Length);
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsWordStart(char ch) => char.IsLetter(ch) || ch == '_';
|
||||||
|
|
||||||
|
private static bool IsWordChar(char ch) => char.IsLetterOrDigit(ch) || ch == '_' || ch == '$' || ch == '#';
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 텍스트에 든 치환 변수 토큰 전부 — 검증·요약용.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<string> VariablesIn(string text)
|
||||||
|
{
|
||||||
|
var found = new List<string>();
|
||||||
|
foreach (var token in Tokenize(text))
|
||||||
|
{
|
||||||
|
if (token.Kind == SqlTokenKind.Variable)
|
||||||
|
{
|
||||||
|
found.Add(text.Substring(token.Start, token.Length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using SheetMe.Core.Catalog;
|
||||||
|
|
||||||
|
namespace SheetMe.Designer.Controls;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SQL 색칠 레이어 — 투명한 <see cref="TextBox"/> 뒤에 같은 글자를 색을 입혀 그린다.
|
||||||
|
///
|
||||||
|
/// <b>왜 이 방식인가.</b> 서드파티 편집기 컴포넌트를 배포본에 넣지 않기로 했다.
|
||||||
|
/// RichTextBox 로 직접 칠하는 방법도 있지만 편집할 때마다 서식을 다시 입혀야 해서
|
||||||
|
/// 실행취소 묶음이 깨지고 IME 조합 중 깜빡인다.
|
||||||
|
/// 대신 <b>편집은 평범한 TextBox 가 그대로 하고</b>(캐럿·선택·실행취소·IME·스크롤 전부 공짜),
|
||||||
|
/// 글자만 투명하게 해서 이 레이어가 뒤에서 같은 자리에 색을 입혀 그린다.
|
||||||
|
///
|
||||||
|
/// 자리를 맞추는 조건은 하나다 — <b>글꼴·크기·여백·줄바꿈 설정이 TextBox 와 완전히 같을 것.</b>
|
||||||
|
/// 그래서 폭·글꼴을 직접 읽어 쓰고, 줄바꿈 없음(NoWrap)을 전제로 한 줄씩 그린다.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SqlHighlightLayer : FrameworkElement
|
||||||
|
{
|
||||||
|
#region Member Fields
|
||||||
|
private TextBox? source;
|
||||||
|
private ScrollViewer? scroller;
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Properties
|
||||||
|
/// <summary>색칠 대상 — 이 TextBox 의 글자를 뒤에서 다시 그린다</summary>
|
||||||
|
public TextBox? Source
|
||||||
|
{
|
||||||
|
get => source;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(source, value))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Detach();
|
||||||
|
source = value;
|
||||||
|
Attach();
|
||||||
|
InvalidateVisual();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Methods
|
||||||
|
private void Attach()
|
||||||
|
{
|
||||||
|
if (source is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
source.TextChanged += OnSourceChanged;
|
||||||
|
source.SizeChanged += OnSourceChanged;
|
||||||
|
source.Loaded += OnSourceLoaded;
|
||||||
|
HookScroller();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Detach()
|
||||||
|
{
|
||||||
|
if (source is not null)
|
||||||
|
{
|
||||||
|
source.TextChanged -= OnSourceChanged;
|
||||||
|
source.SizeChanged -= OnSourceChanged;
|
||||||
|
source.Loaded -= OnSourceLoaded;
|
||||||
|
}
|
||||||
|
if (scroller is not null)
|
||||||
|
{
|
||||||
|
scroller.ScrollChanged -= OnScrolled;
|
||||||
|
scroller = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSourceLoaded(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
HookScroller();
|
||||||
|
InvalidateVisual();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TextBox 내부 ScrollViewer 를 찾아 스크롤을 따라간다.
|
||||||
|
/// 이걸 놓치면 스크롤한 순간 글자와 색이 어긋나 보인다.
|
||||||
|
/// </summary>
|
||||||
|
private void HookScroller()
|
||||||
|
{
|
||||||
|
if (source is null || scroller is not null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scroller = FindScrollViewer(source);
|
||||||
|
if (scroller is not null)
|
||||||
|
{
|
||||||
|
scroller.ScrollChanged += OnScrolled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ScrollViewer? FindScrollViewer(DependencyObject root)
|
||||||
|
{
|
||||||
|
if (root is ScrollViewer found)
|
||||||
|
{
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
var count = VisualTreeHelper.GetChildrenCount(root);
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
if (FindScrollViewer(VisualTreeHelper.GetChild(root, i)) is { } child)
|
||||||
|
{
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnScrolled(object sender, ScrollChangedEventArgs e) => InvalidateVisual();
|
||||||
|
|
||||||
|
private void OnSourceChanged(object sender, EventArgs e) => InvalidateVisual();
|
||||||
|
|
||||||
|
/// <summary>색 하나를 테마 자원에서 — 없으면 눈에 띄는 대체색(자원 누락을 조용히 넘기지 않는다)</summary>
|
||||||
|
private Brush BrushOf(string key)
|
||||||
|
=> TryFindResource(key) as Brush ?? Brushes.Magenta;
|
||||||
|
|
||||||
|
private Brush BrushFor(SqlTokenKind kind) => kind switch
|
||||||
|
{
|
||||||
|
SqlTokenKind.Keyword => BrushOf("B.SqlKeyword"),
|
||||||
|
SqlTokenKind.Function => BrushOf("B.SqlFunction"),
|
||||||
|
SqlTokenKind.Number => BrushOf("B.SqlNumber"),
|
||||||
|
SqlTokenKind.Text => BrushOf("B.SqlText"),
|
||||||
|
SqlTokenKind.Comment => BrushOf("B.SqlComment"),
|
||||||
|
SqlTokenKind.Variable => BrushOf("B.SqlVariable"),
|
||||||
|
SqlTokenKind.Broken => BrushOf("B.Danger"),
|
||||||
|
_ => BrushOf("B.Ink"),
|
||||||
|
};
|
||||||
|
|
||||||
|
protected override void OnRender(DrawingContext dc)
|
||||||
|
{
|
||||||
|
if (source is null || source.Text.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var text = source.Text;
|
||||||
|
var tokens = SqlTokenizer.Tokenize(text);
|
||||||
|
var typeface = new Typeface(source.FontFamily, source.FontStyle, source.FontWeight, source.FontStretch);
|
||||||
|
var dpi = VisualTreeHelper.GetDpi(this).PixelsPerDip;
|
||||||
|
|
||||||
|
// TextBox 의 내부 여백 + 스크롤 오프셋만큼 옮겨 그린다
|
||||||
|
var offsetX = source.Padding.Left + source.BorderThickness.Left - (scroller?.HorizontalOffset ?? 0);
|
||||||
|
var offsetY = source.Padding.Top + source.BorderThickness.Top - (scroller?.VerticalOffset ?? 0);
|
||||||
|
|
||||||
|
// 줄 높이는 실제 글꼴에서 잰다 — 상수로 두면 글꼴이 바뀔 때 어긋난다
|
||||||
|
var probe = new FormattedText("Ag", CultureInfo.InvariantCulture, FlowDirection.LeftToRight,
|
||||||
|
typeface, source.FontSize, Brushes.Black, dpi);
|
||||||
|
var lineHeight = probe.Height;
|
||||||
|
|
||||||
|
var lineIndex = 0;
|
||||||
|
var lineStart = 0;
|
||||||
|
var height = ActualHeight;
|
||||||
|
|
||||||
|
while (lineStart <= text.Length)
|
||||||
|
{
|
||||||
|
var newline = text.IndexOf('\n', lineStart);
|
||||||
|
var lineEnd = newline < 0 ? text.Length : newline;
|
||||||
|
var y = offsetY + lineIndex * lineHeight;
|
||||||
|
|
||||||
|
// 화면 밖 줄은 그리지 않는다 — 긴 쿼리에서 매 입력마다 전부 그리면 눈에 띄게 느려진다
|
||||||
|
if (y > height)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (y + lineHeight >= 0)
|
||||||
|
{
|
||||||
|
DrawLine(dc, text, tokens, lineStart, lineEnd, typeface, dpi, offsetX, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newline < 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
lineStart = newline + 1;
|
||||||
|
lineIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 한 줄을 조각별 색으로 그린다.
|
||||||
|
/// 조각 경계마다 <see cref="FormattedText"/> 를 새로 만들되, x 위치는 <b>앞부분 전체를 다시 재서</b> 잡는다 —
|
||||||
|
/// 폭을 누적하면 자간·힌팅 때문에 뒤로 갈수록 한두 픽셀씩 밀린다.
|
||||||
|
/// </summary>
|
||||||
|
private void DrawLine(DrawingContext dc, string text, IReadOnlyList<SqlToken> tokens,
|
||||||
|
int lineStart, int lineEnd, Typeface typeface, double dpi, double offsetX, double y)
|
||||||
|
{
|
||||||
|
foreach (var token in tokens)
|
||||||
|
{
|
||||||
|
if (token.End <= lineStart || token.Start >= lineEnd)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var start = Math.Max(token.Start, lineStart);
|
||||||
|
var end = Math.Min(token.End, lineEnd);
|
||||||
|
if (end <= start)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var prefix = text[lineStart..start];
|
||||||
|
var x = offsetX;
|
||||||
|
if (prefix.Length > 0)
|
||||||
|
{
|
||||||
|
var measured = new FormattedText(prefix, CultureInfo.InvariantCulture, FlowDirection.LeftToRight,
|
||||||
|
typeface, source!.FontSize, Brushes.Black, dpi);
|
||||||
|
x += measured.WidthIncludingTrailingWhitespace;
|
||||||
|
}
|
||||||
|
|
||||||
|
var piece = new FormattedText(text[start..end], CultureInfo.InvariantCulture, FlowDirection.LeftToRight,
|
||||||
|
typeface, source!.FontSize, BrushFor(token.Kind), dpi);
|
||||||
|
dc.DrawText(piece, new Point(x, y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -47,6 +47,14 @@
|
|||||||
오류는 B.CloseHover 와 같은 값이라 다크 팔레트에 새 색이 늘지 않는다. -->
|
오류는 B.CloseHover 와 같은 값이라 다크 팔레트에 새 색이 늘지 않는다. -->
|
||||||
<SolidColorBrush x:Key="B.Warning" Color="#E0A33A" />
|
<SolidColorBrush x:Key="B.Warning" Color="#E0A33A" />
|
||||||
<SolidColorBrush x:Key="B.Danger" Color="#E04A2B" />
|
<SolidColorBrush x:Key="B.Danger" Color="#E04A2B" />
|
||||||
|
|
||||||
|
<!-- SQL 구문 색 — 어두운 배경 대비 4.5:1 이상 -->
|
||||||
|
<SolidColorBrush x:Key="B.SqlKeyword" Color="#6FB6F2" />
|
||||||
|
<SolidColorBrush x:Key="B.SqlFunction" Color="#C89BE8" />
|
||||||
|
<SolidColorBrush x:Key="B.SqlNumber" Color="#E8A76A" />
|
||||||
|
<SolidColorBrush x:Key="B.SqlText" Color="#7FC98A" />
|
||||||
|
<SolidColorBrush x:Key="B.SqlComment" Color="#8A8F98" />
|
||||||
|
<SolidColorBrush x:Key="B.SqlVariable" Color="#F0A868" />
|
||||||
<!-- 강조 채움 버튼의 호버·누름. 흰 글자를 얹으므로 밝히지 않고 어둡게 간다(양 테마 동일값).
|
<!-- 강조 채움 버튼의 호버·누름. 흰 글자를 얹으므로 밝히지 않고 어둡게 간다(양 테마 동일값).
|
||||||
이게 없으면 공유 버튼 템플릿의 호버 트리거가 채움을 회색으로 덮어 흰 글자가 사라진다. -->
|
이게 없으면 공유 버튼 템플릿의 호버 트리거가 채움을 회색으로 덮어 흰 글자가 사라진다. -->
|
||||||
<SolidColorBrush x:Key="B.AccentFillHover" Color="#0C5B9C" />
|
<SolidColorBrush x:Key="B.AccentFillHover" Color="#0C5B9C" />
|
||||||
|
|||||||
@@ -81,6 +81,15 @@
|
|||||||
-->
|
-->
|
||||||
<SolidColorBrush x:Key="B.Warning" Color="#B45309" />
|
<SolidColorBrush x:Key="B.Warning" Color="#B45309" />
|
||||||
<SolidColorBrush x:Key="B.Danger" Color="#C4321A" />
|
<SolidColorBrush x:Key="B.Danger" Color="#C4321A" />
|
||||||
|
|
||||||
|
<!-- SQL 구문 색 — 흰 배경 대비 4.5:1 이상.
|
||||||
|
치환 변수는 이 편집기에서 가장 중요한 조각이라 가장 진하게 둔다. -->
|
||||||
|
<SolidColorBrush x:Key="B.SqlKeyword" Color="#0B5FA5" />
|
||||||
|
<SolidColorBrush x:Key="B.SqlFunction" Color="#7A3E9D" />
|
||||||
|
<SolidColorBrush x:Key="B.SqlNumber" Color="#B25000" />
|
||||||
|
<SolidColorBrush x:Key="B.SqlText" Color="#1A7F37" />
|
||||||
|
<SolidColorBrush x:Key="B.SqlComment" Color="#6B7280" />
|
||||||
|
<SolidColorBrush x:Key="B.SqlVariable" Color="#9A3412" />
|
||||||
<!-- 강조 채움 버튼의 호버·누름 — 다크 대응본 주석 참조(양 테마 동일값) -->
|
<!-- 강조 채움 버튼의 호버·누름 — 다크 대응본 주석 참조(양 테마 동일값) -->
|
||||||
<SolidColorBrush x:Key="B.AccentFillHover" Color="#0C5B9C" />
|
<SolidColorBrush x:Key="B.AccentFillHover" Color="#0C5B9C" />
|
||||||
<SolidColorBrush x:Key="B.AccentFillPressed" Color="#0A4C83" />
|
<SolidColorBrush x:Key="B.AccentFillPressed" Color="#0A4C83" />
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
<Window x:Class="SheetMe.Designer.Views.QueryEditorWindow"
|
<Window x:Class="SheetMe.Designer.Views.QueryEditorWindow"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
Title="쿼리 편집" Width="960" Height="560" MinWidth="720" MinHeight="400"
|
xmlns:ctl="clr-namespace:SheetMe.Designer.Controls"
|
||||||
|
Title="쿼리 편집" Width="1060" Height="640" MinWidth="780" MinHeight="440"
|
||||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
|
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
|
||||||
Style="{StaticResource ThemedWindow}">
|
Style="{StaticResource ThemedWindow}">
|
||||||
<DockPanel Margin="12">
|
<DockPanel Margin="12">
|
||||||
<TextBlock x:Name="HintText" DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,8"
|
<TextBlock x:Name="HintText" DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,8"
|
||||||
Text="작성 시점에 <<변수>> 가 실제 값으로 치환됩니다. 변수는 클래스 전체 이름을 포함해야 하므로 우측 목록을 더블클릭해 삽입하세요 — 임의로 줄여 쓰면 치환되지 않고 SQL 에 그대로 남아 오류가 납니다."/>
|
Text="작성 시점에 <<변수>> 가 실제 값으로 치환됩니다. 변수는 클래스 전체 이름을 포함해야 하므로 우측 목록에서 삽입하세요 — 임의로 줄여 쓰면 치환되지 않고 SQL 에 그대로 남아 오류가 납니다."/>
|
||||||
|
|
||||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
|
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
|
||||||
<TextBlock x:Name="LengthText" VerticalAlignment="Center" Margin="0,0,12,0" Foreground="{DynamicResource B.Muted}"/>
|
<TextBlock x:Name="ProblemText" VerticalAlignment="Center" Margin="0,0,12,0"
|
||||||
<Button Content="확인" Padding="20,5" Click="OnConfirm"/>
|
Foreground="{DynamicResource B.Danger}" FontSize="11.5"
|
||||||
|
TextTrimming="CharacterEllipsis" MaxWidth="420"/>
|
||||||
|
<TextBlock x:Name="LengthText" VerticalAlignment="Center" Margin="0,0,12,0" Foreground="{DynamicResource B.Muted}" FontSize="11.5"/>
|
||||||
|
<Button Content="확인" Padding="20,5" Click="OnConfirm" IsDefault="True"/>
|
||||||
<Button Content="취소" Padding="20,5" Margin="8,0,0,0" IsCancel="True"/>
|
<Button Content="취소" Padding="20,5" Margin="8,0,0,0" IsCancel="True"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
@@ -18,32 +22,70 @@
|
|||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="*"/>
|
<ColumnDefinition Width="*"/>
|
||||||
<ColumnDefinition x:Name="SplitterColumn" Width="8"/>
|
<ColumnDefinition x:Name="SplitterColumn" Width="8"/>
|
||||||
<ColumnDefinition x:Name="VariableColumn" Width="330"/>
|
<ColumnDefinition x:Name="VariableColumn" Width="360"/>
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<!-- SQL 편집 영역 -->
|
<!-- SQL 편집 영역.
|
||||||
<TextBox x:Name="SqlBox" Grid.Column="0"
|
색칠은 뒤에 깔린 SqlHighlightLayer 가 하고, 편집은 위에 얹힌 투명 TextBox 가 그대로 한다
|
||||||
|
(캐럿·선택·실행취소·IME 를 직접 만들지 않기 위해서다).
|
||||||
|
두 요소의 글꼴·크기·여백이 어긋나면 글자와 색이 어긋난다 — 반드시 같이 고칠 것. -->
|
||||||
|
<Border Grid.Column="0" BorderThickness="1" CornerRadius="6" ClipToBounds="True"
|
||||||
|
BorderBrush="{DynamicResource B.InputBorder}" Background="{DynamicResource B.Input}">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<!-- 줄 번호 -->
|
||||||
|
<Border Grid.Column="0" Background="{DynamicResource B.Surface}" Padding="6,6,6,6">
|
||||||
|
<TextBlock x:Name="LineNumbers" FontFamily="Consolas, D2Coding, 굴림체" FontSize="13"
|
||||||
|
Foreground="{DynamicResource B.Muted}" TextAlignment="Right" MinWidth="26"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Grid Grid.Column="1">
|
||||||
|
<ctl:SqlHighlightLayer x:Name="Highlight" IsHitTestVisible="False"/>
|
||||||
|
<TextBox x:Name="SqlBox"
|
||||||
FontFamily="Consolas, D2Coding, 굴림체" FontSize="13"
|
FontFamily="Consolas, D2Coding, 굴림체" FontSize="13"
|
||||||
AcceptsReturn="True" AcceptsTab="True"
|
AcceptsReturn="True" AcceptsTab="False"
|
||||||
TextWrapping="NoWrap"
|
TextWrapping="NoWrap" Padding="6"
|
||||||
|
Background="Transparent" BorderThickness="0"
|
||||||
|
Foreground="Transparent"
|
||||||
|
CaretBrush="{DynamicResource B.Ink}"
|
||||||
|
SelectionBrush="{DynamicResource B.Accent}" SelectionOpacity="0.35"
|
||||||
HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"
|
HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"
|
||||||
TextChanged="OnSqlChanged"/>
|
TextChanged="OnSqlChanged" PreviewKeyDown="OnSqlKeyDown"
|
||||||
|
AutomationProperties.Name="SQL 편집"/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- 공용 ColSplitter 스타일을 쓴다 — Background 만 주면 양 테마에서 배경과 1.1~1.3:1 로 붙어 손잡이가 안 보인다 -->
|
|
||||||
<GridSplitter Grid.Column="1" Style="{StaticResource ColSplitter}"/>
|
<GridSplitter Grid.Column="1" Style="{StaticResource ColSplitter}"/>
|
||||||
|
|
||||||
<!-- 치환 변수 목록 (SQL 용도에서만 표시) -->
|
<!-- 치환 변수 (SQL 용도에서만) -->
|
||||||
<DockPanel x:Name="VariablePane" Grid.Column="2">
|
<DockPanel x:Name="VariablePane" Grid.Column="2">
|
||||||
<TextBlock DockPanel.Dock="Top" Text="치환 변수 (더블클릭 삽입)" FontWeight="Bold"
|
<TextBlock DockPanel.Dock="Top" Text="치환 변수" FontWeight="Bold"
|
||||||
Foreground="{DynamicResource B.Muted}" Margin="4,0,0,6"/>
|
Foreground="{DynamicResource B.Muted}" Margin="4,0,0,6"/>
|
||||||
|
<TextBox x:Name="VariableSearch" DockPanel.Dock="Top" Margin="0,0,0,6" Padding="6,3"
|
||||||
|
VerticalContentAlignment="Center" TextChanged="OnVariableSearchChanged"
|
||||||
|
AutomationProperties.Name="치환 변수 검색"
|
||||||
|
ToolTip="이름·설명·초성으로 찾습니다 (예: ㅊㅌㅂㅎ → 차트번호)"/>
|
||||||
|
<TextBlock DockPanel.Dock="Bottom" x:Name="VariableHint" FontSize="10.5" TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource B.Muted}" Margin="4,6,0,0"
|
||||||
|
Text="더블클릭 또는 Enter 로 커서 위치에 삽입합니다."/>
|
||||||
<ListBox x:Name="VariableList" FontSize="11" MouseDoubleClick="OnInsertVariable"
|
<ListBox x:Name="VariableList" FontSize="11" MouseDoubleClick="OnInsertVariable"
|
||||||
|
KeyDown="OnVariableListKeyDown"
|
||||||
FontFamily="Consolas, D2Coding, 굴림체"
|
FontFamily="Consolas, D2Coding, 굴림체"
|
||||||
ScrollViewer.HorizontalScrollBarVisibility="Auto">
|
HorizontalContentAlignment="Stretch"
|
||||||
|
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||||
|
AutomationProperties.Name="치환 변수 목록">
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<StackPanel Margin="0,1" ToolTip="{Binding Token}">
|
<StackPanel Margin="0,1" ToolTip="{Binding Token}">
|
||||||
<TextBlock Text="{Binding Description}" FontFamily="Segoe UI, 맑은 고딕" FontSize="12"/>
|
<TextBlock Text="{Binding Description}" FontFamily="Segoe UI, 맑은 고딕" FontSize="12"
|
||||||
<TextBlock Text="{Binding Token}" Foreground="{DynamicResource B.Muted}" FontSize="10"/>
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
<TextBlock Text="{Binding Token}" Foreground="{DynamicResource B.Muted}" FontSize="10"
|
||||||
|
TextTrimming="CharacterEllipsis"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ListBox.ItemTemplate>
|
</ListBox.ItemTemplate>
|
||||||
|
|||||||
@@ -1,22 +1,31 @@
|
|||||||
using System.ComponentModel;
|
using System.Text;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Controls;
|
using System.Windows.Controls;
|
||||||
using System.Windows.Data;
|
using System.Windows.Data;
|
||||||
|
using System.Windows.Input;
|
||||||
using SheetMe.Core.Catalog;
|
using SheetMe.Core.Catalog;
|
||||||
|
|
||||||
namespace SheetMe.Designer.Views;
|
namespace SheetMe.Designer.Views;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 데이터소스(MDataTable) 쿼리 전용 편집기 — 큰 SQL 편집 영역 + 치환 변수 삽입.
|
/// 데이터소스(MDataTable) 쿼리 전용 편집기 — 구문 강조 + 편집 보조 + 치환 변수 삽입.
|
||||||
|
///
|
||||||
/// 치환 규칙 원본: [014]EMRLoader bzDesignSheetLoader.ConvertQuery.
|
/// 치환 규칙 원본: [014]EMRLoader bzDesignSheetLoader.ConvertQuery.
|
||||||
/// 토큰 접두어는 <b>클래스 전체 이름</b>이어야 하며 런타임이 GetType.FullName 과 완전일치로 비교한다
|
/// 토큰 접두어는 <b>클래스 전체 이름</b>이어야 하며 런타임이 GetType.FullName 과 완전일치로 비교한다
|
||||||
/// (ucLoadSheetBase.vb:7619-7631 이 형을 고정). 목록은 <see cref="LegacyQueryVariableCatalog"/> 참조.
|
/// (ucLoadSheetBase.vb:7619-7631 이 형을 고정). 목록은 <see cref="LegacyQueryVariableCatalog"/> 참조.
|
||||||
///
|
///
|
||||||
|
/// 색칠은 <see cref="Controls.SqlHighlightLayer"/> 가 뒤에서 하고 편집은 투명 TextBox 가 그대로 한다 —
|
||||||
|
/// 서드파티 편집기 컴포넌트를 배포본에 넣지 않기로 해서 직접 만든 구성이다.
|
||||||
|
///
|
||||||
/// 큰 다중행 편집 영역이 필요한 다른 용도(상용구 문구)에도 재사용한다 —
|
/// 큰 다중행 편집 영역이 필요한 다른 용도(상용구 문구)에도 재사용한다 —
|
||||||
/// 그 경우 <c>showVariables: false</c> 로 SQL 변수 패널을 숨긴다.
|
/// 그 경우 <c>showVariables: false</c> 로 SQL 변수 패널을 숨기고 색칠도 끈다.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class QueryEditorWindow : Window
|
public partial class QueryEditorWindow : Window
|
||||||
{
|
{
|
||||||
|
#region Member Fields
|
||||||
|
private readonly bool isSql;
|
||||||
|
#endregion
|
||||||
|
|
||||||
#region Properties
|
#region Properties
|
||||||
/// <summary>편집 결과 텍스트 — 확인 시 채워짐</summary>
|
/// <summary>편집 결과 텍스트 — 확인 시 채워짐</summary>
|
||||||
public string QueryText { get; private set; } = string.Empty;
|
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)
|
public QueryEditorWindow(string ownerLabel, string initialQuery, bool showVariables = true)
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
isSql = showVariables;
|
||||||
SqlBox.Text = initialQuery;
|
SqlBox.Text = initialQuery;
|
||||||
|
|
||||||
if (showVariables)
|
if (showVariables)
|
||||||
{
|
{
|
||||||
Title = $"쿼리 편집 — {ownerLabel}";
|
Title = $"쿼리 편집 — {ownerLabel}";
|
||||||
var view = new CollectionViewSource { Source = LegacyQueryVariableCatalog.All };
|
Highlight.Source = SqlBox;
|
||||||
view.GroupDescriptions.Add(new PropertyGroupDescription(nameof(QueryVariable.Group)));
|
ApplyVariableFilter(string.Empty);
|
||||||
VariableList.ItemsSource = view.View;
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
// SQL 이 아닌 텍스트에 SQL 색을 칠하면 예약어와 겹치는 낱말이 엉뚱하게 물든다
|
||||||
Title = ownerLabel;
|
Title = ownerLabel;
|
||||||
HintText.Text = "내용을 편집한 뒤 확인을 누르세요.";
|
HintText.Text = "내용을 편집한 뒤 확인을 누르세요.";
|
||||||
|
SqlBox.Foreground = (System.Windows.Media.Brush)FindResource("B.Ink");
|
||||||
VariablePane.Visibility = Visibility.Collapsed;
|
VariablePane.Visibility = Visibility.Collapsed;
|
||||||
SplitterColumn.Width = new GridLength(0);
|
SplitterColumn.Width = new GridLength(0);
|
||||||
VariableColumn.Width = new GridLength(0);
|
VariableColumn.Width = new GridLength(0);
|
||||||
@@ -51,15 +62,267 @@ public partial class QueryEditorWindow : Window
|
|||||||
{
|
{
|
||||||
SqlBox.Focus();
|
SqlBox.Focus();
|
||||||
SqlBox.CaretIndex = SqlBox.Text.Length;
|
SqlBox.CaretIndex = SqlBox.Text.Length;
|
||||||
|
Refresh();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Methods
|
#region Methods - Editing
|
||||||
private void OnSqlChanged(object sender, TextChangedEventArgs e)
|
private void OnSqlChanged(object sender, TextChangedEventArgs e) => Refresh();
|
||||||
=> LengthText.Text = $"{SqlBox.Text.Length:N0}자";
|
|
||||||
|
|
||||||
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)
|
if (VariableList.SelectedItem is not QueryVariable variable)
|
||||||
{
|
{
|
||||||
@@ -70,7 +333,9 @@ public partial class QueryEditorWindow : Window
|
|||||||
SqlBox.CaretIndex = caret + variable.Token.Length;
|
SqlBox.CaretIndex = caret + variable.Token.Length;
|
||||||
SqlBox.Focus();
|
SqlBox.Focus();
|
||||||
}
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Methods - Confirm
|
||||||
private void OnConfirm(object sender, RoutedEventArgs e)
|
private void OnConfirm(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
QueryText = SqlBox.Text;
|
QueryText = SqlBox.Text;
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
using SheetMe.Core.Catalog;
|
||||||
|
|
||||||
|
namespace SheetMe.Core.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SQL 조각내기 테스트.
|
||||||
|
///
|
||||||
|
/// 색칠이 어긋나면 편집기가 거짓말을 한다 — 특히 치환 변수와 '깨진 조각' 판정이 그렇다.
|
||||||
|
/// 이 쿼리는 EMR 런타임에서 돌고 실패해도 빈 catch 가 삼키므로,
|
||||||
|
/// 편집기에서 눈에 띄지 않으면 배포된 뒤에야 드러난다.
|
||||||
|
/// </summary>
|
||||||
|
[TestClass]
|
||||||
|
public sealed class SqlTokenizerTests
|
||||||
|
{
|
||||||
|
private static SqlTokenKind KindAt(string text, int index)
|
||||||
|
{
|
||||||
|
foreach (var token in SqlTokenizer.Tokenize(text))
|
||||||
|
{
|
||||||
|
if (index >= token.Start && index < token.End)
|
||||||
|
{
|
||||||
|
return token.Kind;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SqlTokenKind.Plain;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string PieceOf(string text, SqlTokenKind kind)
|
||||||
|
{
|
||||||
|
foreach (var token in SqlTokenizer.Tokenize(text))
|
||||||
|
{
|
||||||
|
if (token.Kind == kind)
|
||||||
|
{
|
||||||
|
return text.Substring(token.Start, token.Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>조각은 겹치지 않고 전 구간을 덮어야 한다 — 빈틈이 있으면 그 글자가 안 그려진다</summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void Tokenize_CoversEveryCharacterExactlyOnce()
|
||||||
|
{
|
||||||
|
const string sql = "SELECT NVL(a,0) /* c */ FROM t WHERE x='y' -- 끝\nAND z=1";
|
||||||
|
|
||||||
|
var tokens = SqlTokenizer.Tokenize(sql);
|
||||||
|
|
||||||
|
var position = 0;
|
||||||
|
foreach (var token in tokens)
|
||||||
|
{
|
||||||
|
Assert.AreEqual(position, token.Start, $"빈틈 또는 겹침: {token.Start} 에서");
|
||||||
|
position = token.End;
|
||||||
|
}
|
||||||
|
Assert.AreEqual(sql.Length, position, "마지막까지 덮지 못했습니다");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void Tokenize_MarksKeywordsAndFunctions()
|
||||||
|
{
|
||||||
|
const string sql = "SELECT NVL(x,0) FROM DUAL";
|
||||||
|
|
||||||
|
Assert.AreEqual(SqlTokenKind.Keyword, KindAt(sql, 0), "SELECT");
|
||||||
|
Assert.AreEqual(SqlTokenKind.Function, KindAt(sql, 7), "NVL");
|
||||||
|
Assert.AreEqual(SqlTokenKind.Keyword, KindAt(sql, 16), "FROM");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>예약어는 대소문자를 가리지 않는다 — 실서식에 소문자 쿼리가 섞여 있다</summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void Tokenize_KeywordsAreCaseInsensitive()
|
||||||
|
=> Assert.AreEqual(SqlTokenKind.Keyword, KindAt("select 1 from dual", 0));
|
||||||
|
|
||||||
|
/// <summary>식별자 안에 든 예약어 조각은 예약어가 아니다(SELECTED 를 SELECT 로 칠하면 안 된다)</summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void Tokenize_DoesNotMatchKeywordInsideIdentifier()
|
||||||
|
=> Assert.AreEqual(SqlTokenKind.Plain, KindAt("SELECTED_FLAG", 0));
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void Tokenize_FindsStringsAndComments()
|
||||||
|
{
|
||||||
|
Assert.AreEqual("'가나'", PieceOf("WHERE nam='가나'", SqlTokenKind.Text));
|
||||||
|
Assert.AreEqual("-- 설명", PieceOf("SELECT 1 -- 설명\nFROM t", SqlTokenKind.Comment));
|
||||||
|
Assert.AreEqual("/* 블록 */", PieceOf("SELECT /* 블록 */ 1", SqlTokenKind.Comment));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Oracle 은 '' 로 작은따옴표를 이스케이프한다 — 거기서 문자열이 끊기면 안 된다</summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void Tokenize_HandlesDoubledQuoteEscape()
|
||||||
|
=> Assert.AreEqual("'it''s'", PieceOf("WHERE x='it''s'", SqlTokenKind.Text));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 치환 변수는 이 편집기의 핵심 조각이다 — 통째로 하나로 잡혀야 한다.
|
||||||
|
/// 안쪽 점·대문자 때문에 여러 조각으로 쪼개지면 색이 얼룩진다.
|
||||||
|
/// </summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void Tokenize_TreatsSubstitutionVariableAsOnePiece()
|
||||||
|
{
|
||||||
|
const string sql = "WHERE ChtNum = <<M.CMM.HISOperatingInfo.bzPatientInfo.ChtNum>>";
|
||||||
|
|
||||||
|
Assert.AreEqual("<<M.CMM.HISOperatingInfo.bzPatientInfo.ChtNum>>",
|
||||||
|
PieceOf(sql, SqlTokenKind.Variable));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void VariablesIn_ListsEveryToken()
|
||||||
|
{
|
||||||
|
const string sql = "SELECT <<A.B.C>> , <<D.E.F>> FROM t";
|
||||||
|
|
||||||
|
CollectionAssert.AreEqual(new[] { "<<A.B.C>>", "<<D.E.F>>" }, SqlTokenizer.VariablesIn(sql).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 닫히지 않은 조각은 Broken 이다. 그대로 저장하면 런타임이 조용히 실패하므로
|
||||||
|
/// 편집기에서 즉시 눈에 띄어야 한다.
|
||||||
|
/// </summary>
|
||||||
|
[DataTestMethod]
|
||||||
|
[DataRow("WHERE x = <<M.CMM.a.b", "변수를 닫지 않음")]
|
||||||
|
[DataRow("WHERE nam='가나", "문자열을 닫지 않음")]
|
||||||
|
public void Tokenize_FlagsUnclosedPieces(string sql, string why)
|
||||||
|
{
|
||||||
|
var hasBroken = SqlTokenizer.Tokenize(sql).Any(t => t.Kind == SqlTokenKind.Broken);
|
||||||
|
|
||||||
|
Assert.IsTrue(hasBroken, why);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>줄바꿈을 넘긴 문자열은 닫는 따옴표를 빠뜨린 것으로 본다</summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void Tokenize_UnclosedStringDoesNotSwallowFollowingLines()
|
||||||
|
{
|
||||||
|
const string sql = "WHERE a='열림\nAND b=1";
|
||||||
|
|
||||||
|
var tokens = SqlTokenizer.Tokenize(sql);
|
||||||
|
|
||||||
|
Assert.IsTrue(tokens.Any(t => t.Kind == SqlTokenKind.Broken));
|
||||||
|
// 다음 줄의 AND 는 정상 예약어로 남아야 한다
|
||||||
|
Assert.AreEqual(SqlTokenKind.Keyword, KindAt(sql, sql.IndexOf("AND", StringComparison.Ordinal)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void Tokenize_EmptyTextYieldsNothing()
|
||||||
|
=> Assert.AreEqual(0, SqlTokenizer.Tokenize(string.Empty).Count);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user