쿼리 편집기 — 서드파티 없이 만든 구문 강조와 편집 보조
배포본에 서드파티 편집기 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
|
||||
}
|
||||
Reference in New Issue
Block a user