쿼리 편집기 고도화 — 자동완성·검증 실행·편집 기본기 (Oracle)

색칠만으로는 편집기라 하기 어렵다. 실제로 쿼리를 칠 때 손이 가는 곳을 메웠다.

■ 자동완성 (Ctrl+Space / 치는 대로)

문맥을 보고 후보를 바꾼다(SqlCompletion — Core 순수 함수라 테스트로 고정했다):
  · << 직후          → 치환 변수 51종. 이 편집기에만 있는 문법이라 외워서 칠 수 없다.
                        고르면 알맹이를 넣고 닫는 >> 를 붙여 준다.
  · FROM/JOIN 뒤     → 테이블·뷰 이름(운영 스키마에서 2,494건)
  · 별칭. 뒤          → 그 테이블의 컬럼. 별칭은 같은 쿼리 FROM 절에서 푼다
                        (FROM P_PatMst p → p.  는 P_PatMst 의 컬럼).
  · 그 밖             → 예약어·함수·테이블

일반 위치에서 한 글자도 안 쳤으면 목록을 띄우지 않는다 — 아무 데서나 뜨면 방해가 된다.
테이블 목록은 처음 한 번만 읽고, 컬럼은 테이블별로 캐시한다(매번 조회하면 입력이 끊긴다).
DB 가 없으면 예약어·함수·변수만 나오고 나머지는 조용히 빠진다.

별칭 자리에 예약어가 오면 별칭으로 보지 않는다 — FROM P_PatMst WHERE 에서 WHERE 를
별칭으로 잡으면 엉뚱한 테이블의 컬럼을 제안하게 된다.

■ 검증 실행 (F5)

저장 전에 실제로 돌려 본다. 치환 변수는 디자인 시점에 값이 없으므로 런타임과 같은 방식으로
빈 문자열로 바꾼 뒤 실행한다(clsMDataTable.vb:67-71). 목적은 데이터 확인이 아니라
테이블·컬럼 오타와 구문 오류를 배포 전에 잡는 것이다 — 지금까지는 EMR 런타임에서야
처음 실행됐고 실패해도 빈 catch 가 삼켰다.

성공하면 컬럼 목록과 표본 행(최대 20)을 보여 주고, 실패하면 ORA 메시지를 그대로 띄운다.

안전 장치: SELECT/WITH 로 시작하는 조회만 실행한다. 운영 DB 에 붙을 수 있는 도구이고
편집 중인 텍스트를 그대로 실행하므로 DML·DDL 이 섞이면 되돌릴 수 없다.
앞쪽 주석을 걷어내고 판정하므로 /* SELECT */ DELETE ... 같은 우회도 막힌다.

--db-trial 진단을 신설해 실DB 로 확인했다(10/10 통과):
  정상 조회 통과 · 없는 테이블 ORA-00942 · 없는 컬럼 ORA-00904 · 구문 오류 ORA-00936 ·
  앞 주석 뒤 정상 통과 · INSERT/DELETE/DROP/주석우회 전부 차단 · 빈 쿼리 차단.

■ 편집 기본기

따옴표·괄호 자동 닫기(선택이 있으면 그 선택을 감싼다) — SQL 에서 짝을 빠뜨리면
런타임에서 조용히 실패하는 종류다.
앞서 넣은 줄번호·Tab 들여쓰기·Enter 자동 들여쓰기·Ctrl+/ 주석 토글은 그대로.

단위 테스트 15건 추가(문맥 판정 6종·별칭 풀기 4종·키워드 별칭 배제·후보 정렬·내장 목록).
회귀: 테스트 211/211, 편집 스모크 실패 0, 검증 실행 점검 10/10,
DB 왕복 1,271건 diff 0/예외 0, 종이 렌더 P062 바이트 동일.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-13 15:21:08 +09:00
co-authored by Claude Opus 5
parent 864f11b0e4
commit 79db31cb4d
7 changed files with 1021 additions and 7 deletions
@@ -0,0 +1,137 @@
using SheetMe.Core.Catalog;
namespace SheetMe.Core.Tests;
/// <summary>
/// 자동완성 문맥 판단 테스트.
///
/// 문맥을 잘못 잡으면 목록이 엉뚱한 것을 보여 주고, 그러면 안 쓰느니만 못하다.
/// 특히 <c>&lt;&lt;</c> 직후는 이 편집기에만 있는 문법이라 여기서 놓치면 손으로 외워 쳐야 한다.
/// </summary>
[TestClass]
public sealed class SqlCompletionTests
{
/// <summary>커서를 | 로 표시한 텍스트를 받아 분석한다 — 테스트가 읽히게</summary>
private static SqlCompletionQuery? At(string marked)
{
var caret = marked.IndexOf('|');
return SqlCompletion.Analyze(marked.Remove(caret, 1), caret);
}
[TestMethod]
public void Analyze_AfterOpenAngle_SuggestsVariables()
{
var query = At("WHERE a = <<M.CMM|");
Assert.IsNotNull(query);
Assert.AreEqual(SqlCompletionContext.Variable, query.Context);
Assert.AreEqual("M.CMM", query.Prefix);
}
/// <summary>이미 닫힌 변수 뒤에서는 변수 문맥이 아니다</summary>
[TestMethod]
public void Analyze_AfterClosedVariable_IsNotVariableContext()
{
var query = At("WHERE a = <<A.B.C>> AND na|");
Assert.IsNotNull(query);
Assert.AreNotEqual(SqlCompletionContext.Variable, query.Context);
}
[DataTestMethod]
[DataRow("SELECT * FROM P_|")]
[DataRow("SELECT * FROM t JOIN P_|")]
public void Analyze_AfterFromOrJoin_SuggestsTables(string marked)
{
var query = At(marked);
Assert.IsNotNull(query);
Assert.AreEqual(SqlCompletionContext.TableName, query.Context);
Assert.AreEqual("P_", query.Prefix);
}
/// <summary>FROM 바로 뒤(아직 한 글자도 안 침)에서도 테이블을 제안해야 한다</summary>
[TestMethod]
public void Analyze_ImmediatelyAfterFrom_SuggestsTablesWithEmptyPrefix()
{
var query = At("SELECT * FROM |");
Assert.IsNotNull(query);
Assert.AreEqual(SqlCompletionContext.TableName, query.Context);
Assert.AreEqual(string.Empty, query.Prefix);
}
[TestMethod]
public void Analyze_AfterDot_SuggestsColumnsOfQualifier()
{
var query = At("SELECT p.Cht| FROM P_PatMst p");
Assert.IsNotNull(query);
Assert.AreEqual(SqlCompletionContext.ColumnOf, query.Context);
Assert.AreEqual("p", query.Qualifier);
Assert.AreEqual("Cht", query.Prefix);
}
/// <summary>아무것도 안 친 일반 위치에서는 목록을 띄우지 않는다 — 방해가 된다</summary>
[TestMethod]
public void Analyze_EmptyPrefixInGeneralPosition_ReturnsNull()
=> Assert.IsNull(At("SELECT a, |"));
[TestMethod]
public void Analyze_TypingWord_SuggestsGeneral()
{
var query = At("SEL|");
Assert.IsNotNull(query);
Assert.AreEqual(SqlCompletionContext.General, query.Context);
Assert.AreEqual("SEL", query.Prefix);
Assert.AreEqual(0, query.ReplaceStart);
}
/// <summary>별칭을 실제 테이블로 풀어야 컬럼을 찾을 수 있다</summary>
[DataTestMethod]
[DataRow("SELECT p.x FROM P_PatMst p", "p", "P_PatMst")]
[DataRow("SELECT p.x FROM P_PatMst AS p", "p", "P_PatMst")]
[DataRow("SELECT c.x FROM P_PatMst p JOIN P_ComInf c ON p.a=c.a", "c", "P_ComInf")]
public void ResolveTable_FindsAliasTarget(string sql, string qualifier, string expected)
=> Assert.AreEqual(expected, SqlCompletion.ResolveTable(sql, qualifier));
/// <summary>별칭이 없으면 한정자 자체가 테이블 이름이다</summary>
[TestMethod]
public void ResolveTable_FallsBackToQualifier()
=> Assert.AreEqual("P_PatMst", SqlCompletion.ResolveTable("SELECT * FROM P_PatMst", "P_PatMst"));
/// <summary>
/// 별칭 자리에 키워드가 오면 별칭이 아니다 — <c>FROM P_PatMst WHERE ...</c> 에서
/// WHERE 를 별칭으로 잡으면 엉뚱한 테이블을 가리킨다.
/// </summary>
[TestMethod]
public void ResolveTable_IgnoresKeywordAsAlias()
=> Assert.AreEqual("WHERE", SqlCompletion.ResolveTable("SELECT * FROM P_PatMst WHERE a=1", "WHERE"));
[TestMethod]
public void Filter_PutsPrefixMatchesFirst()
{
var items = new[]
{
new SqlCompletionItem("PrintBackColor", SqlCompletionKind.Column),
new SqlCompletionItem("PrintOutPut", SqlCompletionKind.Column),
new SqlCompletionItem("XPrint", SqlCompletionKind.Column),
};
var filtered = SqlCompletion.Filter(items, "Print");
Assert.AreEqual(3, filtered.Count, "부분 일치도 포함해야 한다");
Assert.AreEqual("PrintOutPut", filtered[0].Text, "접두 일치 중 짧은 것이 먼저");
Assert.AreEqual("XPrint", filtered[^1].Text, "부분 일치는 뒤로");
}
[TestMethod]
public void BuiltIns_CoverKeywordsAndFunctions()
{
var items = SqlCompletion.BuiltIns();
Assert.IsTrue(items.Any(i => i.Text == "SELECT" && i.Kind == SqlCompletionKind.Keyword));
Assert.IsTrue(items.Any(i => i.Text == "NVL" && i.Kind == SqlCompletionKind.Function));
}
}