쿼리 편집기 고도화 — 자동완성·검증 실행·편집 기본기 (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
+211
View File
@@ -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>&lt;&lt; 를 친 직후 — 치환 변수</summary>
Variable,
}
/// <summary>커서 위치 분석 결과</summary>
public sealed record SqlCompletionQuery(
SqlCompletionContext Context,
string Prefix,
int ReplaceStart,
string Qualifier = "");
/// <summary>
/// SQL 자동완성 — 커서 앞 텍스트만 보고 무엇을 제안할지 정한다.
///
/// 완전한 파서를 만들지 않는다. 실제로 손이 가는 자리는 몇 군데뿐이다:
/// <list type="bullet">
/// <item><c>&lt;&lt;</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
}
@@ -0,0 +1,231 @@
using System.Data;
using System.Text.RegularExpressions;
using Oracle.ManagedDataAccess.Client;
namespace SheetMe.Data.Stores;
/// <summary>테이블 1건 — 자동완성 후보</summary>
public sealed record SchemaTable(string Name, string Kind);
/// <summary>검증 실행 결과</summary>
public sealed class QueryTrialResult
{
/// <summary>성공 여부</summary>
public bool Ok { get; init; }
/// <summary>실패 사유(ORA 메시지 등)</summary>
public string? Error { get; init; }
/// <summary>결과 컬럼명</summary>
public IReadOnlyList<string> Columns { get; init; } = Array.Empty<string>();
/// <summary>표본 행 — 컬럼 순서와 같은 문자열 배열</summary>
public IReadOnlyList<string[]> Rows { get; init; } = Array.Empty<string[]>();
/// <summary>사람이 읽는 요약</summary>
public string Summary { get; init; } = string.Empty;
}
/// <summary>
/// 쿼리 편집기 지원 — 스키마 목록과 검증 실행(Oracle).
///
/// <b>왜 필요한가.</b> MDataTable 쿼리는 저장한 뒤 EMR 런타임에서야 처음 실행되고,
/// 실패하면 빈 catch 가 삼켜 화면에 아무 표시가 없다. 오타 하나가 배포 후에
/// '그 칸만 비어 있다'는 형태로 드러난다. 편집기에서 미리 돌려 볼 수 있어야 한다.
///
/// <b>안전 장치.</b> 검증 실행은 SELECT/WITH 로 시작하는 문장만 받는다.
/// 여기는 운영 DB 에 붙을 수 있는 도구이고, 편집 중인 텍스트를 그대로 실행하므로
/// DML·DDL 이 섞여 들어오면 되돌릴 수 없다. 행 수도 제한한다.
/// </summary>
public sealed class OracleQueryWorkbench
{
#region Member Fields
private readonly string connectionString;
/// <summary>검증 실행이 가져오는 최대 행 수 — 미리보기지 조회 도구가 아니다</summary>
public const int MaxPreviewRows = 20;
/// <summary>SELECT 또는 WITH 로 시작하는지 — 주석과 공백을 걷어내고 본다</summary>
private static readonly Regex ReadOnlyStart =
new(@"^\s*(SELECT|WITH)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled);
#endregion
#region Constructors
public OracleQueryWorkbench(string connectionString) => this.connectionString = connectionString;
#endregion
#region Properties
/// <summary>DB 에 붙을 수 있는지 — 접속 문자열이 없으면 스키마·검증 기능이 꺼진다</summary>
public bool CanUseDb => connectionString.Length > 0;
#endregion
#region Methods
/// <summary>
/// 접근 가능한 테이블·뷰 이름 — 자동완성 후보.
/// 전부 가져오면 수만 건이 될 수 있어 상한을 둔다.
/// </summary>
public IReadOnlyList<SchemaTable> ListTables(int max = 4000)
{
var found = new List<SchemaTable>();
if (!CanUseDb)
{
return found;
}
try
{
using var connection = new OracleConnection(connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText =
"SELECT TABLE_NAME, 'TABLE' FROM USER_TABLES " +
"UNION ALL SELECT VIEW_NAME, 'VIEW' FROM USER_VIEWS " +
"ORDER BY 1";
using var reader = command.ExecuteReader();
while (reader.Read() && found.Count < max)
{
found.Add(new SchemaTable(reader.GetString(0), reader.GetString(1)));
}
}
catch
{
// 스키마 조회 실패는 자동완성만 비는 것이라 편집을 막지 않는다
}
return found;
}
/// <summary>특정 테이블의 컬럼명 — 없거나 조회 실패면 빈 목록</summary>
public IReadOnlyList<string> ListColumns(string tableName)
{
var found = new List<string>();
if (!CanUseDb || !Regex.IsMatch(tableName, "^[A-Za-z0-9_$#]{1,128}$"))
{
return found;
}
try
{
using var connection = new OracleConnection(connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText =
"SELECT COLUMN_NAME FROM USER_TAB_COLUMNS WHERE TABLE_NAME = UPPER(:t) ORDER BY COLUMN_ID";
command.Parameters.Add(new OracleParameter("t", tableName));
using var reader = command.ExecuteReader();
while (reader.Read())
{
found.Add(reader.GetString(0));
}
}
catch
{
// 위와 같다
}
return found;
}
/// <summary>
/// 검증 실행 — 구문과 이름이 맞는지 실제로 돌려 본다.
///
/// 치환 변수는 디자인 시점에 값을 알 수 없으므로 호출부가 미리 치환해 넘긴다
/// (보통 빈 문자열이나 0). 목적은 데이터 확인이 아니라 <b>테이블·컬럼 오타와 구문 오류</b>를
/// 배포 전에 잡는 것이다.
/// </summary>
public QueryTrialResult Trial(string sql)
{
if (!CanUseDb)
{
return new QueryTrialResult { Ok = false, Error = "DB 접속이 설정되지 않아 검증할 수 없습니다." };
}
var trimmed = StripLeadingComments(sql);
if (trimmed.Length == 0)
{
return new QueryTrialResult { Ok = false, Error = "쿼리가 비어 있습니다." };
}
if (!ReadOnlyStart.IsMatch(trimmed))
{
// 운영 DB 에 붙을 수 있는 도구다 — 편집 중인 텍스트를 그대로 실행하므로 조회만 허용한다
return new QueryTrialResult
{
Ok = false,
Error = "검증 실행은 SELECT 또는 WITH 로 시작하는 조회만 지원합니다.",
};
}
try
{
using var connection = new OracleConnection(connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = trimmed.TrimEnd().TrimEnd(';');
using var reader = command.ExecuteReader(CommandBehavior.SingleResult);
var columns = new List<string>();
for (var i = 0; i < reader.FieldCount; i++)
{
columns.Add(reader.GetName(i));
}
var rows = new List<string[]>();
while (reader.Read() && rows.Count < MaxPreviewRows)
{
var row = new string[reader.FieldCount];
for (var i = 0; i < reader.FieldCount; i++)
{
var value = reader.IsDBNull(i) ? "(null)" : reader.GetValue(i).ToString() ?? string.Empty;
row[i] = value.Length > 60 ? value[..60] + "…" : value;
}
rows.Add(row);
}
return new QueryTrialResult
{
Ok = true,
Columns = columns,
Rows = rows,
Summary = $"성공 — 컬럼 {columns.Count}개, 표본 {rows.Count}행" +
(rows.Count == MaxPreviewRows ? $" (최대 {MaxPreviewRows}행까지만 봅니다)" : string.Empty),
};
}
catch (Exception ex)
{
return new QueryTrialResult { Ok = false, Error = ex.Message };
}
}
/// <summary>앞쪽 주석·공백을 걷어낸 나머지 — SELECT 판정을 주석이 가리지 않도록</summary>
private static string StripLeadingComments(string sql)
{
var i = 0;
while (i < sql.Length)
{
if (char.IsWhiteSpace(sql[i]))
{
i++;
continue;
}
if (sql[i] == '-' && i + 1 < sql.Length && sql[i + 1] == '-')
{
var nl = sql.IndexOf('\n', i);
if (nl < 0)
{
return string.Empty;
}
i = nl + 1;
continue;
}
if (sql[i] == '/' && i + 1 < sql.Length && sql[i + 1] == '*')
{
var close = sql.IndexOf("*/", i + 2, StringComparison.Ordinal);
if (close < 0)
{
return string.Empty;
}
i = close + 2;
continue;
}
break;
}
return sql[i..];
}
#endregion
}
+5
View File
@@ -222,6 +222,11 @@ public partial class App : Application
return Diagnostics.DbSmoke.RunTableSearch(args[1], args[2]); return Diagnostics.DbSmoke.RunTableSearch(args[1], args[2]);
} }
if (args.Length >= 2 && args[0] == "--db-trial")
{
return Diagnostics.DbSmoke.RunQueryTrial(args[1]);
}
if (args.Length >= 2 && args[0] == "--dialog-shots") if (args.Length >= 2 && args[0] == "--dialog-shots")
{ {
return Diagnostics.DialogShots.Run(args[1]); return Diagnostics.DialogShots.Run(args[1]);
@@ -615,6 +615,68 @@ public static class DbSmoke
} }
} }
/// <summary>
/// 쿼리 검증 실행 점검(--db-trial) — 편집기의 '검증 실행'이 실제 DB 에서 무엇을 돌려주는지.
///
/// 이 기능은 오타를 배포 전에 잡으라고 만든 것이라, 정상 쿼리는 통과하고
/// 틀린 쿼리는 <b>사람이 읽을 수 있는 이유</b>와 함께 실패해야 한다.
/// 조회 외 문장을 막는 안전 장치도 함께 검사한다 — 운영 DB 에 붙을 수 있는 도구다.
/// </summary>
public static int RunQueryTrial(string reportPath)
{
var lines = new List<string>();
try
{
var workbench = new SheetMe.Data.Stores.OracleQueryWorkbench(ConfigService.Current.ConnectionString);
lines.Add($"DB 사용 가능: {workbench.CanUseDb}");
var cases = new (string Label, string Sql, bool ExpectOk)[]
{
("정상 조회", "SELECT ShtCod, ShtKorNam FROM E_ShtMst WHERE ROWNUM <= 3", true),
("없는 테이블", "SELECT * FROM 없는테이블_XYZ", false),
("없는 컬럼", "SELECT 없는컬럼_XYZ FROM E_ShtMst", false),
("구문 오류", "SELECT FROM WHERE", false),
("앞 주석 뒤 정상", "-- 설명\nSELECT 1 FROM DUAL", true),
("조회 아님(INSERT)", "INSERT INTO E_ShtMst (ShtCod) VALUES ('X')", false),
("조회 아님(DELETE)", "DELETE FROM E_ShtMst", false),
("조회 아님(DROP)", "DROP TABLE E_ShtMst", false),
("주석으로 가린 DELETE", "/* SELECT */ DELETE FROM E_ShtMst", false),
("빈 쿼리", " ", false),
};
var failures = 0;
foreach (var (label, sql, expectOk) in cases)
{
var result = workbench.Trial(sql);
var ok = result.Ok == expectOk;
if (!ok)
{
failures++;
}
lines.Add($"{(ok ? "PASS" : "FAIL")} {label,-18} → " +
(result.Ok ? result.Summary : "실패: " + result.Error));
}
var tables = workbench.ListTables();
var columns = workbench.ListColumns("E_ShtMst");
lines.Add(string.Empty);
lines.Add($"자동완성 원천 — 테이블·뷰 {tables.Count}건, E_ShtMst 컬럼 {columns.Count}건");
lines.Add(" 표본 테이블: " + string.Join(", ", tables.Take(8).Select(t => t.Name)));
lines.Add(" 표본 컬럼: " + string.Join(", ", columns.Take(10)));
lines.Add(string.Empty);
lines.Add($"결과: 실패 {failures}건");
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return failures == 0 ? 0 : 1;
}
catch (Exception ex)
{
lines.Add("실패: " + ex);
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 1;
}
}
/// <summary> /// <summary>
/// 데이터·액션 태그 실사용 분포(--db-tags) — 태그 피커를 어떻게 정렬·큐레이션할지 근거(읽기 전용). /// 데이터·액션 태그 실사용 분포(--db-tags) — 태그 피커를 어떻게 정렬·큐레이션할지 근거(읽기 전용).
/// ///
@@ -9,14 +9,36 @@
<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="작성 시점에 &lt;&lt;변수&gt;&gt; 가 실제 값으로 치환됩니다. 변수는 클래스 전체 이름을 포함해야 하므로 우측 목록에서 삽입하세요 — 임의로 줄여 쓰면 치환되지 않고 SQL 에 그대로 남아 오류가 납니다."/> Text="작성 시점에 &lt;&lt;변수&gt;&gt; 가 실제 값으로 치환됩니다. 변수는 클래스 전체 이름을 포함해야 하므로 우측 목록에서 삽입하세요 — 임의로 줄여 쓰면 치환되지 않고 SQL 에 그대로 남아 오류가 납니다."/>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0"> <DockPanel DockPanel.Dock="Bottom" Margin="0,10,0,0">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<TextBlock x:Name="LengthText" VerticalAlignment="Center" Margin="0,0,12,0"
Foreground="{DynamicResource B.Muted}" FontSize="11.5"/>
<Button x:Name="TrialButton" Content="검증 실행" Padding="14,5" Click="OnTrial"
ToolTip="DB 에서 실제로 돌려 봅니다 (F5). 조회만 실행되며 표본 몇 행만 가져옵니다."/>
<Button Content="확인" Padding="20,5" Margin="8,0,0,0" Click="OnConfirm" IsDefault="True"/>
<Button Content="취소" Padding="20,5" Margin="8,0,0,0" IsCancel="True"/>
</StackPanel>
<TextBlock x:Name="ProblemText" VerticalAlignment="Center" Margin="0,0,12,0" <TextBlock x:Name="ProblemText" VerticalAlignment="Center" Margin="0,0,12,0"
Foreground="{DynamicResource B.Danger}" FontSize="11.5" Foreground="{DynamicResource B.Danger}" FontSize="11.5"
TextTrimming="CharacterEllipsis" MaxWidth="420"/> TextTrimming="CharacterEllipsis"/>
<TextBlock x:Name="LengthText" VerticalAlignment="Center" Margin="0,0,12,0" Foreground="{DynamicResource B.Muted}" FontSize="11.5"/> </DockPanel>
<Button Content="확인" Padding="20,5" Click="OnConfirm" IsDefault="True"/>
<Button Content="취소" Padding="20,5" Margin="8,0,0,0" IsCancel="True"/> <!-- 검증 결과 — 실행했을 때만 뜬다 -->
</StackPanel> <Border x:Name="TrialPane" DockPanel.Dock="Bottom" Visibility="Collapsed" Margin="0,10,0,0"
MaxHeight="200" CornerRadius="6" BorderThickness="1"
BorderBrush="{DynamicResource B.InputBorder}" Background="{DynamicResource B.Surface}">
<DockPanel Margin="10,8">
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,6">
<Button DockPanel.Dock="Right" Content="닫기" Padding="8,1" Style="{StaticResource Subtle}"
Click="OnCloseTrial"/>
<TextBlock x:Name="TrialSummary" FontSize="11.5" TextWrapping="Wrap"/>
</DockPanel>
<DataGrid x:Name="TrialGrid" AutoGenerateColumns="True" IsReadOnly="True"
HeadersVisibility="Column" FontSize="11.5" MaxHeight="150"
GridLinesVisibility="Horizontal" Background="Transparent"
AutomationProperties.Name="검증 결과 표본"/>
</DockPanel>
</Border>
<Grid> <Grid>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
@@ -45,6 +67,29 @@
<Grid Grid.Column="1"> <Grid Grid.Column="1">
<ctl:SqlHighlightLayer x:Name="Highlight" IsHitTestVisible="False"/> <ctl:SqlHighlightLayer x:Name="Highlight" IsHitTestVisible="False"/>
<!-- 자동완성 목록 — 커서 아래에 띄운다. StaysOpen 이라 목록을 눌러도 편집 포커스를 잃지 않는다 -->
<Popup x:Name="CompletionPopup" Placement="Relative" AllowsTransparency="True"
StaysOpen="True" PopupAnimation="None">
<Border Background="{DynamicResource B.Surface}" BorderThickness="1"
BorderBrush="{DynamicResource B.InputBorder}" CornerRadius="6" Padding="2">
<ListBox x:Name="CompletionList" MaxHeight="240" MinWidth="260" FontSize="12"
BorderThickness="0" Background="Transparent"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
MouseDoubleClick="OnCompletionPicked">
<ListBox.ItemTemplate>
<DataTemplate>
<DockPanel MinWidth="240">
<TextBlock DockPanel.Dock="Right" Text="{Binding KindLabel}" FontSize="10"
Foreground="{DynamicResource B.Muted}" Margin="10,0,0,0"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding Text}" FontFamily="Consolas, D2Coding, 굴림체"
TextTrimming="CharacterEllipsis"/>
</DockPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
</Popup>
<TextBox x:Name="SqlBox" <TextBox x:Name="SqlBox"
FontFamily="Consolas, D2Coding, 굴림체" FontSize="13" FontFamily="Consolas, D2Coding, 굴림체" FontSize="13"
AcceptsReturn="True" AcceptsTab="False" AcceptsReturn="True" AcceptsTab="False"
@@ -24,6 +24,22 @@ public partial class QueryEditorWindow : Window
{ {
#region Member Fields #region Member Fields
private readonly bool isSql; private readonly bool isSql;
/// <summary>스키마·검증 실행 원천 — DB 가 없으면 null 이고 그 기능만 꺼진다</summary>
private readonly SheetMe.Data.Stores.OracleQueryWorkbench? workbench;
/// <summary>테이블 목록 — 처음 필요할 때 한 번만 읽는다(수천 건이라 매번 조회하면 입력이 끊긴다)</summary>
private IReadOnlyList<SqlCompletionItem>? tableItems;
/// <summary>테이블별 컬럼 캐시</summary>
private readonly Dictionary<string, IReadOnlyList<SqlCompletionItem>> columnCache =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>자동완성이 바꿔 넣을 구간 시작 — 목록에서 고를 때 여기부터 지운다</summary>
private int completionStart;
/// <summary>자동완성이 텍스트를 고치는 중 — TextChanged 재진입을 막는다</summary>
private bool applyingCompletion;
#endregion #endregion
#region Properties #region Properties
@@ -46,6 +62,13 @@ public partial class QueryEditorWindow : Window
Title = $"쿼리 편집 — {ownerLabel}"; Title = $"쿼리 편집 — {ownerLabel}";
Highlight.Source = SqlBox; Highlight.Source = SqlBox;
ApplyVariableFilter(string.Empty); ApplyVariableFilter(string.Empty);
var connection = Services.ConfigService.Current.ConnectionString;
workbench = new SheetMe.Data.Stores.OracleQueryWorkbench(connection);
if (!workbench.CanUseDb)
{
TrialButton.IsEnabled = false;
TrialButton.ToolTip = "DB 접속이 설정되지 않아 검증 실행을 쓸 수 없습니다.";
}
} }
else else
{ {
@@ -67,8 +90,244 @@ public partial class QueryEditorWindow : Window
} }
#endregion #endregion
#region Methods - Completion
/// <summary>커서 위치를 보고 목록을 띄우거나 닫는다</summary>
private void UpdateCompletion()
{
if (!isSql || applyingCompletion)
{
return;
}
var query = SqlCompletion.Analyze(SqlBox.Text, SqlBox.CaretIndex);
if (query is null)
{
CompletionPopup.IsOpen = false;
return;
}
var items = SqlCompletion.Filter(CandidatesFor(query), query.Prefix);
if (items.Count == 0)
{
CompletionPopup.IsOpen = false;
return;
}
completionStart = query.ReplaceStart;
CompletionList.ItemsSource = items;
CompletionList.SelectedIndex = 0;
PlaceCompletionPopup();
CompletionPopup.IsOpen = true;
}
private IEnumerable<SqlCompletionItem> CandidatesFor(SqlCompletionQuery query)
{
switch (query.Context)
{
case SqlCompletionContext.Variable:
// 토큰에서 << >> 를 뗀 알맹이를 후보로 준다 — 커서 앞의 << 는 이미 쳐 놓았다
return LegacyQueryVariableCatalog.All.Select(v =>
new SqlCompletionItem(v.Token[2..^2], SqlCompletionKind.Variable, v.Description));
case SqlCompletionContext.TableName:
return Tables();
case SqlCompletionContext.ColumnOf:
var table = SqlCompletion.ResolveTable(SqlBox.Text, query.Qualifier);
return Columns(table);
default:
return SqlCompletion.BuiltIns().Concat(Tables());
}
}
private IReadOnlyList<SqlCompletionItem> Tables()
{
if (tableItems is not null)
{
return tableItems;
}
tableItems = workbench is null
? (IReadOnlyList<SqlCompletionItem>)Array.Empty<SqlCompletionItem>()
: workbench.ListTables()
.Select(t => new SqlCompletionItem(t.Name, SqlCompletionKind.Table, t.Kind))
.ToList();
return tableItems;
}
private IReadOnlyList<SqlCompletionItem> Columns(string table)
{
if (columnCache.TryGetValue(table, out var cached))
{
return cached;
}
var items = workbench is null
? (IReadOnlyList<SqlCompletionItem>)Array.Empty<SqlCompletionItem>()
: workbench.ListColumns(table)
.Select(c => new SqlCompletionItem(c, SqlCompletionKind.Column, table))
.ToList();
columnCache[table] = items;
return items;
}
/// <summary>캐럿 바로 아래에 목록을 놓는다</summary>
private void PlaceCompletionPopup()
{
var rect = SqlBox.GetRectFromCharacterIndex(SqlBox.CaretIndex);
CompletionPopup.PlacementTarget = SqlBox;
CompletionPopup.HorizontalOffset = rect.X;
CompletionPopup.VerticalOffset = rect.Bottom + 2;
}
/// <summary>고른 후보를 커서 앞 낱말과 바꿔 넣는다</summary>
private void AcceptCompletion()
{
if (CompletionList.SelectedItem is not SqlCompletionItem item)
{
return;
}
applyingCompletion = true;
try
{
var caret = SqlBox.CaretIndex;
var text = SqlBox.Text;
var replaceLength = Math.Max(0, caret - completionStart);
var inserted = item.Text;
// 변수는 << 를 이미 쳤으므로 알맹이만 넣고 닫는 >> 를 붙여 준다
if (item.Kind == SqlCompletionKind.Variable)
{
completionStart += 2; // '<<' 는 남긴다
replaceLength = Math.Max(0, caret - completionStart);
var alreadyClosed = text.IndexOf(">>", caret, StringComparison.Ordinal) == caret;
if (!alreadyClosed)
{
inserted += ">>";
}
}
SqlBox.Text = text.Remove(completionStart, replaceLength).Insert(completionStart, inserted);
SqlBox.CaretIndex = completionStart + inserted.Length;
}
finally
{
applyingCompletion = false;
}
CompletionPopup.IsOpen = false;
Refresh();
}
private void OnCompletionPicked(object sender, MouseButtonEventArgs e) => AcceptCompletion();
/// <summary>목록이 떠 있는 동안의 키 처리 — 처리했으면 true</summary>
private bool HandleCompletionKey(KeyEventArgs e)
{
if (!CompletionPopup.IsOpen)
{
return false;
}
switch (e.Key)
{
case Key.Escape:
CompletionPopup.IsOpen = false;
return true;
case Key.Tab:
case Key.Return:
AcceptCompletion();
return true;
case Key.Down:
CompletionList.SelectedIndex = Math.Min(CompletionList.SelectedIndex + 1, CompletionList.Items.Count - 1);
CompletionList.ScrollIntoView(CompletionList.SelectedItem);
return true;
case Key.Up:
CompletionList.SelectedIndex = Math.Max(CompletionList.SelectedIndex - 1, 0);
CompletionList.ScrollIntoView(CompletionList.SelectedItem);
return true;
default:
return false;
}
}
#endregion
#region Methods - Trial
/// <summary>
/// 검증 실행 — 치환 변수를 자리표시로 바꾼 뒤 실제로 돌려 본다.
///
/// 디자인 시점에는 환자·서식 문맥이 없어 진짜 값을 넣을 수 없다.
/// 목적은 데이터 확인이 아니라 <b>테이블·컬럼 오타와 구문 오류</b>를 배포 전에 잡는 것이다.
/// </summary>
private void OnTrial(object sender, RoutedEventArgs e)
{
if (workbench is null)
{
return;
}
var probe = SqlBox.Text;
foreach (var token in SqlTokenizer.VariablesIn(probe).Distinct(StringComparer.Ordinal))
{
// 런타임도 값이 없으면 빈 문자열로 치환한다(clsMDataTable.vb:67-71) — 같은 모양으로 맞춘다
probe = probe.Replace(token, string.Empty, StringComparison.Ordinal);
}
Mouse.OverrideCursor = Cursors.Wait;
SheetMe.Data.Stores.QueryTrialResult result;
try
{
result = workbench.Trial(probe);
}
finally
{
Mouse.OverrideCursor = null;
}
TrialPane.Visibility = Visibility.Visible;
if (result.Ok)
{
TrialSummary.Foreground = (System.Windows.Media.Brush)FindResource("B.Ink");
TrialSummary.Text = result.Summary;
TrialGrid.ItemsSource = ToGridRows(result);
TrialGrid.Visibility = Visibility.Visible;
}
else
{
TrialSummary.Foreground = (System.Windows.Media.Brush)FindResource("B.Danger");
TrialSummary.Text = "실패 — " + result.Error;
TrialGrid.ItemsSource = null;
TrialGrid.Visibility = Visibility.Collapsed;
}
}
/// <summary>DataGrid 는 속성으로 열을 만든다 — 컬럼명이 동적이라 사전 행으로 넘긴다</summary>
private static List<System.Dynamic.ExpandoObject> ToGridRows(SheetMe.Data.Stores.QueryTrialResult result)
{
var rows = new List<System.Dynamic.ExpandoObject>();
foreach (var values in result.Rows)
{
var row = new System.Dynamic.ExpandoObject();
var bag = (IDictionary<string, object?>)row;
for (var i = 0; i < result.Columns.Count && i < values.Length; i++)
{
// 같은 이름이 두 번 나오면 DataGrid 가 열을 하나만 만든다 — 뒤에 번호를 붙인다
var name = result.Columns[i];
if (bag.ContainsKey(name))
{
name += "_" + i;
}
bag[name] = values[i];
}
rows.Add(row);
}
return rows;
}
private void OnCloseTrial(object sender, RoutedEventArgs e) => TrialPane.Visibility = Visibility.Collapsed;
#endregion
#region Methods - Editing #region Methods - Editing
private void OnSqlChanged(object sender, TextChangedEventArgs e) => Refresh(); private void OnSqlChanged(object sender, TextChangedEventArgs e)
{
Refresh();
UpdateCompletion();
}
private void Refresh() private void Refresh()
{ {
@@ -151,6 +410,32 @@ public partial class QueryEditorWindow : Window
var ctrl = (Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control; var ctrl = (Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control;
var shift = (Keyboard.Modifiers & ModifierKeys.Shift) == ModifierKeys.Shift; var shift = (Keyboard.Modifiers & ModifierKeys.Shift) == ModifierKeys.Shift;
// 목록이 떠 있으면 방향키·Enter·Tab 은 목록 것이다
if (HandleCompletionKey(e))
{
e.Handled = true;
return;
}
if (e.Key == Key.F5 && isSql)
{
OnTrial(sender, e);
e.Handled = true;
return;
}
// Ctrl+Space — 목록을 손으로 부른다
if (ctrl && e.Key == Key.Space)
{
UpdateCompletion();
e.Handled = true;
return;
}
if (AutoClosePair(e.Key, shift))
{
e.Handled = true;
return;
}
if (e.Key == Key.Tab) if (e.Key == Key.Tab)
{ {
IndentSelection(outdent: shift); IndentSelection(outdent: shift);
@@ -170,6 +455,44 @@ public partial class QueryEditorWindow : Window
} }
} }
/// <summary>
/// 따옴표·괄호 자동 닫기 — 선택이 있으면 그 선택을 감싼다.
///
/// SQL 에서 <c>'…'</c> 와 <c>(…)</c> 는 짝을 빠뜨리기 쉽고, 빠뜨리면 런타임에서 조용히 실패한다.
/// 여는 쪽을 칠 때 닫는 쪽을 같이 넣고 커서를 사이에 둔다.
/// </summary>
private bool AutoClosePair(Key key, bool shift)
{
// 작은따옴표(Shift 없는 OemQuotes) 와 여는 괄호(Shift+9)
var pair = (key, shift) switch
{
(Key.OemQuotes, false) => ("'", "'"),
(Key.D9, true) => ("(", ")"),
_ => (string.Empty, string.Empty),
};
if (pair.Item1.Length == 0)
{
return false;
}
var start = SqlBox.SelectionStart;
var length = SqlBox.SelectionLength;
var inner = length > 0 ? SqlBox.Text.Substring(start, length) : string.Empty;
var insert = pair.Item1 + inner + pair.Item2;
SqlBox.Text = SqlBox.Text.Remove(start, length).Insert(start, insert);
// 선택을 감쌌으면 감싼 내용을 다시 선택하고, 아니면 따옴표 사이에 커서를 둔다
if (length > 0)
{
SqlBox.Select(start + 1, inner.Length);
}
else
{
SqlBox.CaretIndex = start + 1;
}
return true;
}
/// <summary>현재 줄의 시작 위치</summary> /// <summary>현재 줄의 시작 위치</summary>
private int LineStartOf(int index) private int LineStartOf(int index)
{ {
@@ -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));
}
}