쿼리 편집기 고도화 — 자동완성·검증 실행·편집 기본기 (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:
co-authored by
Claude Opus 5
parent
864f11b0e4
commit
79db31cb4d
@@ -0,0 +1,211 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace SheetMe.Core.Catalog;
|
||||
|
||||
/// <summary>자동완성 후보가 어떤 성격인지 — 목록에 아이콘·설명을 다르게 붙인다</summary>
|
||||
public enum SqlCompletionKind
|
||||
{
|
||||
/// <summary>예약어</summary>
|
||||
Keyword,
|
||||
/// <summary>내장 함수</summary>
|
||||
Function,
|
||||
/// <summary>테이블·뷰</summary>
|
||||
Table,
|
||||
/// <summary>컬럼</summary>
|
||||
Column,
|
||||
/// <summary>치환 변수</summary>
|
||||
Variable,
|
||||
}
|
||||
|
||||
/// <summary>자동완성 후보 1건</summary>
|
||||
public sealed record SqlCompletionItem(string Text, SqlCompletionKind Kind, string Hint = "")
|
||||
{
|
||||
/// <summary>목록에 붙는 짧은 종류 표시</summary>
|
||||
public string KindLabel => Kind switch
|
||||
{
|
||||
SqlCompletionKind.Keyword => "예약어",
|
||||
SqlCompletionKind.Function => "함수",
|
||||
SqlCompletionKind.Table => "테이블",
|
||||
SqlCompletionKind.Column => "컬럼",
|
||||
_ => "변수",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>커서 위치에서 무엇을 제안해야 하는지</summary>
|
||||
public enum SqlCompletionContext
|
||||
{
|
||||
/// <summary>일반 — 예약어·함수·테이블</summary>
|
||||
General,
|
||||
/// <summary>FROM/JOIN/UPDATE/INTO 뒤 — 테이블만</summary>
|
||||
TableName,
|
||||
/// <summary>식별자 뒤의 점 — 그 테이블(또는 별칭)의 컬럼</summary>
|
||||
ColumnOf,
|
||||
/// <summary><< 를 친 직후 — 치환 변수</summary>
|
||||
Variable,
|
||||
}
|
||||
|
||||
/// <summary>커서 위치 분석 결과</summary>
|
||||
public sealed record SqlCompletionQuery(
|
||||
SqlCompletionContext Context,
|
||||
string Prefix,
|
||||
int ReplaceStart,
|
||||
string Qualifier = "");
|
||||
|
||||
/// <summary>
|
||||
/// SQL 자동완성 — 커서 앞 텍스트만 보고 무엇을 제안할지 정한다.
|
||||
///
|
||||
/// 완전한 파서를 만들지 않는다. 실제로 손이 가는 자리는 몇 군데뿐이다:
|
||||
/// <list type="bullet">
|
||||
/// <item><c><<</c> 를 친 직후 — 치환 변수. 이 편집기에서만 있는 문법이라 외워서 칠 수 없다.</item>
|
||||
/// <item><c>FROM</c>/<c>JOIN</c> 뒤 — 테이블 이름.</item>
|
||||
/// <item><c>별칭.</c> 뒤 — 그 테이블의 컬럼. 별칭은 같은 쿼리의 FROM 절에서 찾는다.</item>
|
||||
/// <item>그 밖 — 예약어·함수·테이블을 섞어 제안한다.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static class SqlCompletion
|
||||
{
|
||||
#region Member Fields
|
||||
/// <summary>FROM/JOIN 등 뒤에 테이블이 오는 키워드</summary>
|
||||
private static readonly HashSet<string> TableIntroducers = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"FROM", "JOIN", "INTO", "UPDATE", "TABLE",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// FROM 절의 <c>테이블 별칭</c> 짝 — <c>FROM P_PatMst p</c>, <c>JOIN P_ComInf c ON ...</c>,
|
||||
/// <c>FROM P_PatMst AS p</c> 를 잡는다.
|
||||
/// </summary>
|
||||
private static readonly Regex AliasPattern = new(
|
||||
@"\b(?:FROM|JOIN)\s+([A-Za-z_][\w$#]*)\s+(?:AS\s+)?([A-Za-z_][\w$#]*)",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>
|
||||
/// 커서 앞을 보고 무엇을 제안할지 정한다 — 제안할 게 없으면 null.
|
||||
/// </summary>
|
||||
public static SqlCompletionQuery? Analyze(string text, int caret)
|
||||
{
|
||||
if (caret < 0 || caret > text.Length)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var before = text[..caret];
|
||||
|
||||
// 1) 치환 변수 — 가장 가까운 << 가 아직 안 닫혔으면
|
||||
var openVar = before.LastIndexOf("<<", StringComparison.Ordinal);
|
||||
if (openVar >= 0)
|
||||
{
|
||||
var closeVar = before.IndexOf(">>", openVar, StringComparison.Ordinal);
|
||||
if (closeVar < 0)
|
||||
{
|
||||
return new SqlCompletionQuery(SqlCompletionContext.Variable,
|
||||
before[(openVar + 2)..], openVar);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 지금 치고 있는 낱말과 그 앞의 한정자
|
||||
var wordStart = caret;
|
||||
while (wordStart > 0 && IsWordChar(before[wordStart - 1]))
|
||||
{
|
||||
wordStart--;
|
||||
}
|
||||
var prefix = before[wordStart..];
|
||||
|
||||
if (wordStart > 0 && before[wordStart - 1] == '.')
|
||||
{
|
||||
var qualEnd = wordStart - 1;
|
||||
var qualStart = qualEnd;
|
||||
while (qualStart > 0 && IsWordChar(before[qualStart - 1]))
|
||||
{
|
||||
qualStart--;
|
||||
}
|
||||
var qualifier = before[qualStart..qualEnd];
|
||||
if (qualifier.Length > 0)
|
||||
{
|
||||
return new SqlCompletionQuery(SqlCompletionContext.ColumnOf, prefix, wordStart, qualifier);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 앞 낱말이 FROM/JOIN 이면 테이블
|
||||
var priorEnd = wordStart;
|
||||
while (priorEnd > 0 && char.IsWhiteSpace(before[priorEnd - 1]))
|
||||
{
|
||||
priorEnd--;
|
||||
}
|
||||
var priorStart = priorEnd;
|
||||
while (priorStart > 0 && IsWordChar(before[priorStart - 1]))
|
||||
{
|
||||
priorStart--;
|
||||
}
|
||||
if (priorEnd > priorStart && TableIntroducers.Contains(before[priorStart..priorEnd]))
|
||||
{
|
||||
return new SqlCompletionQuery(SqlCompletionContext.TableName, prefix, wordStart);
|
||||
}
|
||||
|
||||
// 4) 한 글자도 안 쳤으면 제안하지 않는다 — 아무 데서나 목록이 뜨면 방해가 된다
|
||||
return prefix.Length == 0
|
||||
? null
|
||||
: new SqlCompletionQuery(SqlCompletionContext.General, prefix, wordStart);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 별칭 → 테이블 이름 — <c>FROM P_PatMst p</c> 를 보고 <c>p.</c> 가 무엇의 컬럼인지 알아낸다.
|
||||
/// 별칭이 없으면 테이블 이름 자체가 한정자로 쓰인다.
|
||||
/// </summary>
|
||||
public static string ResolveTable(string text, string qualifier)
|
||||
{
|
||||
foreach (Match match in AliasPattern.Matches(text))
|
||||
{
|
||||
var table = match.Groups[1].Value;
|
||||
var alias = match.Groups[2].Value;
|
||||
// ON·WHERE 같은 키워드가 별칭 자리로 잡히는 것을 막는다
|
||||
if (SqlTokenizer.Keywords.Contains(alias))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (string.Equals(alias, qualifier, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return table;
|
||||
}
|
||||
}
|
||||
return qualifier;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 후보를 걸러 정렬한다 — 접두 일치를 부분 일치보다 앞에 둔다.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<SqlCompletionItem> Filter(
|
||||
IEnumerable<SqlCompletionItem> candidates, string prefix, int max = 40)
|
||||
{
|
||||
if (prefix.Length == 0)
|
||||
{
|
||||
return candidates.Take(max).ToList();
|
||||
}
|
||||
return candidates
|
||||
.Where(c => c.Text.Contains(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(c => c.Text.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
.ThenBy(c => c.Text.Length)
|
||||
.ThenBy(c => c.Text, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(max)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>예약어·함수 후보 — 스키마가 없어도 항상 쓸 수 있다</summary>
|
||||
public static IReadOnlyList<SqlCompletionItem> BuiltIns()
|
||||
{
|
||||
var items = new List<SqlCompletionItem>();
|
||||
foreach (var word in SqlTokenizer.Keywords)
|
||||
{
|
||||
items.Add(new SqlCompletionItem(word, SqlCompletionKind.Keyword));
|
||||
}
|
||||
foreach (var word in SqlTokenizer.Functions)
|
||||
{
|
||||
items.Add(new SqlCompletionItem(word, SqlCompletionKind.Function));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private static bool IsWordChar(char ch) => char.IsLetterOrDigit(ch) || ch == '_' || ch == '$' || ch == '#';
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user