쿼리 편집기 — 서드파티 없이 만든 구문 강조와 편집 보조
배포본에 서드파티 편집기 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,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