diff --git a/src/SheetMe.Core/Catalog/SqlCompletion.cs b/src/SheetMe.Core/Catalog/SqlCompletion.cs
new file mode 100644
index 0000000..96daa6d
--- /dev/null
+++ b/src/SheetMe.Core/Catalog/SqlCompletion.cs
@@ -0,0 +1,211 @@
+using System.Text.RegularExpressions;
+
+namespace SheetMe.Core.Catalog;
+
+/// 자동완성 후보가 어떤 성격인지 — 목록에 아이콘·설명을 다르게 붙인다
+public enum SqlCompletionKind
+{
+ /// 예약어
+ Keyword,
+ /// 내장 함수
+ Function,
+ /// 테이블·뷰
+ Table,
+ /// 컬럼
+ Column,
+ /// 치환 변수
+ Variable,
+}
+
+/// 자동완성 후보 1건
+public sealed record SqlCompletionItem(string Text, SqlCompletionKind Kind, string Hint = "")
+{
+ /// 목록에 붙는 짧은 종류 표시
+ public string KindLabel => Kind switch
+ {
+ SqlCompletionKind.Keyword => "예약어",
+ SqlCompletionKind.Function => "함수",
+ SqlCompletionKind.Table => "테이블",
+ SqlCompletionKind.Column => "컬럼",
+ _ => "변수",
+ };
+}
+
+/// 커서 위치에서 무엇을 제안해야 하는지
+public enum SqlCompletionContext
+{
+ /// 일반 — 예약어·함수·테이블
+ General,
+ /// FROM/JOIN/UPDATE/INTO 뒤 — 테이블만
+ TableName,
+ /// 식별자 뒤의 점 — 그 테이블(또는 별칭)의 컬럼
+ ColumnOf,
+ /// << 를 친 직후 — 치환 변수
+ Variable,
+}
+
+/// 커서 위치 분석 결과
+public sealed record SqlCompletionQuery(
+ SqlCompletionContext Context,
+ string Prefix,
+ int ReplaceStart,
+ string Qualifier = "");
+
+///
+/// SQL 자동완성 — 커서 앞 텍스트만 보고 무엇을 제안할지 정한다.
+///
+/// 완전한 파서를 만들지 않는다. 실제로 손이 가는 자리는 몇 군데뿐이다:
+///
+/// - << 를 친 직후 — 치환 변수. 이 편집기에서만 있는 문법이라 외워서 칠 수 없다.
+/// - FROM/JOIN 뒤 — 테이블 이름.
+/// - 별칭. 뒤 — 그 테이블의 컬럼. 별칭은 같은 쿼리의 FROM 절에서 찾는다.
+/// - 그 밖 — 예약어·함수·테이블을 섞어 제안한다.
+///
+///
+public static class SqlCompletion
+{
+ #region Member Fields
+ /// FROM/JOIN 등 뒤에 테이블이 오는 키워드
+ private static readonly HashSet TableIntroducers = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "FROM", "JOIN", "INTO", "UPDATE", "TABLE",
+ };
+
+ ///
+ /// FROM 절의 테이블 별칭 짝 — FROM P_PatMst p, JOIN P_ComInf c ON ...,
+ /// FROM P_PatMst AS p 를 잡는다.
+ ///
+ 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
+ ///
+ /// 커서 앞을 보고 무엇을 제안할지 정한다 — 제안할 게 없으면 null.
+ ///
+ 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);
+ }
+
+ ///
+ /// 별칭 → 테이블 이름 — FROM P_PatMst p 를 보고 p. 가 무엇의 컬럼인지 알아낸다.
+ /// 별칭이 없으면 테이블 이름 자체가 한정자로 쓰인다.
+ ///
+ 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;
+ }
+
+ ///
+ /// 후보를 걸러 정렬한다 — 접두 일치를 부분 일치보다 앞에 둔다.
+ ///
+ public static IReadOnlyList Filter(
+ IEnumerable 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();
+ }
+
+ /// 예약어·함수 후보 — 스키마가 없어도 항상 쓸 수 있다
+ public static IReadOnlyList BuiltIns()
+ {
+ var items = new List();
+ 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
+}
diff --git a/src/SheetMe.Data/Stores/OracleQueryWorkbench.cs b/src/SheetMe.Data/Stores/OracleQueryWorkbench.cs
new file mode 100644
index 0000000..cb2c51c
--- /dev/null
+++ b/src/SheetMe.Data/Stores/OracleQueryWorkbench.cs
@@ -0,0 +1,231 @@
+using System.Data;
+using System.Text.RegularExpressions;
+using Oracle.ManagedDataAccess.Client;
+
+namespace SheetMe.Data.Stores;
+
+/// 테이블 1건 — 자동완성 후보
+public sealed record SchemaTable(string Name, string Kind);
+
+/// 검증 실행 결과
+public sealed class QueryTrialResult
+{
+ /// 성공 여부
+ public bool Ok { get; init; }
+
+ /// 실패 사유(ORA 메시지 등)
+ public string? Error { get; init; }
+
+ /// 결과 컬럼명
+ public IReadOnlyList Columns { get; init; } = Array.Empty();
+
+ /// 표본 행 — 컬럼 순서와 같은 문자열 배열
+ public IReadOnlyList Rows { get; init; } = Array.Empty();
+
+ /// 사람이 읽는 요약
+ public string Summary { get; init; } = string.Empty;
+}
+
+///
+/// 쿼리 편집기 지원 — 스키마 목록과 검증 실행(Oracle).
+///
+/// 왜 필요한가. MDataTable 쿼리는 저장한 뒤 EMR 런타임에서야 처음 실행되고,
+/// 실패하면 빈 catch 가 삼켜 화면에 아무 표시가 없다. 오타 하나가 배포 후에
+/// '그 칸만 비어 있다'는 형태로 드러난다. 편집기에서 미리 돌려 볼 수 있어야 한다.
+///
+/// 안전 장치. 검증 실행은 SELECT/WITH 로 시작하는 문장만 받는다.
+/// 여기는 운영 DB 에 붙을 수 있는 도구이고, 편집 중인 텍스트를 그대로 실행하므로
+/// DML·DDL 이 섞여 들어오면 되돌릴 수 없다. 행 수도 제한한다.
+///
+public sealed class OracleQueryWorkbench
+{
+ #region Member Fields
+ private readonly string connectionString;
+
+ /// 검증 실행이 가져오는 최대 행 수 — 미리보기지 조회 도구가 아니다
+ public const int MaxPreviewRows = 20;
+
+ /// SELECT 또는 WITH 로 시작하는지 — 주석과 공백을 걷어내고 본다
+ 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
+ /// DB 에 붙을 수 있는지 — 접속 문자열이 없으면 스키마·검증 기능이 꺼진다
+ public bool CanUseDb => connectionString.Length > 0;
+ #endregion
+
+ #region Methods
+ ///
+ /// 접근 가능한 테이블·뷰 이름 — 자동완성 후보.
+ /// 전부 가져오면 수만 건이 될 수 있어 상한을 둔다.
+ ///
+ public IReadOnlyList ListTables(int max = 4000)
+ {
+ var found = new List();
+ 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;
+ }
+
+ /// 특정 테이블의 컬럼명 — 없거나 조회 실패면 빈 목록
+ public IReadOnlyList ListColumns(string tableName)
+ {
+ var found = new List();
+ 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;
+ }
+
+ ///
+ /// 검증 실행 — 구문과 이름이 맞는지 실제로 돌려 본다.
+ ///
+ /// 치환 변수는 디자인 시점에 값을 알 수 없으므로 호출부가 미리 치환해 넘긴다
+ /// (보통 빈 문자열이나 0). 목적은 데이터 확인이 아니라 테이블·컬럼 오타와 구문 오류를
+ /// 배포 전에 잡는 것이다.
+ ///
+ 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();
+ for (var i = 0; i < reader.FieldCount; i++)
+ {
+ columns.Add(reader.GetName(i));
+ }
+
+ var rows = new List();
+ 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 };
+ }
+ }
+
+ /// 앞쪽 주석·공백을 걷어낸 나머지 — SELECT 판정을 주석이 가리지 않도록
+ 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
+}
diff --git a/src/SheetMe.Designer/App.xaml.cs b/src/SheetMe.Designer/App.xaml.cs
index d5c2ef7..56da725 100644
--- a/src/SheetMe.Designer/App.xaml.cs
+++ b/src/SheetMe.Designer/App.xaml.cs
@@ -222,6 +222,11 @@ public partial class App : Application
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")
{
return Diagnostics.DialogShots.Run(args[1]);
diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
index c8efdcd..b028c00 100644
--- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
+++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
@@ -615,6 +615,68 @@ public static class DbSmoke
}
}
+ ///
+ /// 쿼리 검증 실행 점검(--db-trial) — 편집기의 '검증 실행'이 실제 DB 에서 무엇을 돌려주는지.
+ ///
+ /// 이 기능은 오타를 배포 전에 잡으라고 만든 것이라, 정상 쿼리는 통과하고
+ /// 틀린 쿼리는 사람이 읽을 수 있는 이유와 함께 실패해야 한다.
+ /// 조회 외 문장을 막는 안전 장치도 함께 검사한다 — 운영 DB 에 붙을 수 있는 도구다.
+ ///
+ public static int RunQueryTrial(string reportPath)
+ {
+ var lines = new List();
+ 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;
+ }
+ }
+
///
/// 데이터·액션 태그 실사용 분포(--db-tags) — 태그 피커를 어떻게 정렬·큐레이션할지 근거(읽기 전용).
///
diff --git a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml
index 17bef0e..e8d3f53 100644
--- a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml
+++ b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml
@@ -9,14 +9,36 @@
-
+
+
+
+
+
+
+
-
-
-
-
+ TextTrimming="CharacterEllipsis"/>
+
+
+
+
+
+
+
+
+
+
+
+
@@ -45,6 +67,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
스키마·검증 실행 원천 — DB 가 없으면 null 이고 그 기능만 꺼진다
+ private readonly SheetMe.Data.Stores.OracleQueryWorkbench? workbench;
+
+ /// 테이블 목록 — 처음 필요할 때 한 번만 읽는다(수천 건이라 매번 조회하면 입력이 끊긴다)
+ private IReadOnlyList? tableItems;
+
+ /// 테이블별 컬럼 캐시
+ private readonly Dictionary> columnCache =
+ new(StringComparer.OrdinalIgnoreCase);
+
+ /// 자동완성이 바꿔 넣을 구간 시작 — 목록에서 고를 때 여기부터 지운다
+ private int completionStart;
+
+ /// 자동완성이 텍스트를 고치는 중 — TextChanged 재진입을 막는다
+ private bool applyingCompletion;
#endregion
#region Properties
@@ -46,6 +62,13 @@ public partial class QueryEditorWindow : Window
Title = $"쿼리 편집 — {ownerLabel}";
Highlight.Source = SqlBox;
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
{
@@ -67,8 +90,244 @@ public partial class QueryEditorWindow : Window
}
#endregion
+ #region Methods - Completion
+ /// 커서 위치를 보고 목록을 띄우거나 닫는다
+ 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 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 Tables()
+ {
+ if (tableItems is not null)
+ {
+ return tableItems;
+ }
+ tableItems = workbench is null
+ ? (IReadOnlyList)Array.Empty()
+ : workbench.ListTables()
+ .Select(t => new SqlCompletionItem(t.Name, SqlCompletionKind.Table, t.Kind))
+ .ToList();
+ return tableItems;
+ }
+
+ private IReadOnlyList Columns(string table)
+ {
+ if (columnCache.TryGetValue(table, out var cached))
+ {
+ return cached;
+ }
+ var items = workbench is null
+ ? (IReadOnlyList)Array.Empty()
+ : workbench.ListColumns(table)
+ .Select(c => new SqlCompletionItem(c, SqlCompletionKind.Column, table))
+ .ToList();
+ columnCache[table] = items;
+ return items;
+ }
+
+ /// 캐럿 바로 아래에 목록을 놓는다
+ private void PlaceCompletionPopup()
+ {
+ var rect = SqlBox.GetRectFromCharacterIndex(SqlBox.CaretIndex);
+ CompletionPopup.PlacementTarget = SqlBox;
+ CompletionPopup.HorizontalOffset = rect.X;
+ CompletionPopup.VerticalOffset = rect.Bottom + 2;
+ }
+
+ /// 고른 후보를 커서 앞 낱말과 바꿔 넣는다
+ 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();
+
+ /// 목록이 떠 있는 동안의 키 처리 — 처리했으면 true
+ 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
+ ///
+ /// 검증 실행 — 치환 변수를 자리표시로 바꾼 뒤 실제로 돌려 본다.
+ ///
+ /// 디자인 시점에는 환자·서식 문맥이 없어 진짜 값을 넣을 수 없다.
+ /// 목적은 데이터 확인이 아니라 테이블·컬럼 오타와 구문 오류를 배포 전에 잡는 것이다.
+ ///
+ 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;
+ }
+ }
+
+ /// DataGrid 는 속성으로 열을 만든다 — 컬럼명이 동적이라 사전 행으로 넘긴다
+ private static List ToGridRows(SheetMe.Data.Stores.QueryTrialResult result)
+ {
+ var rows = new List();
+ foreach (var values in result.Rows)
+ {
+ var row = new System.Dynamic.ExpandoObject();
+ var bag = (IDictionary)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
- private void OnSqlChanged(object sender, TextChangedEventArgs e) => Refresh();
+ private void OnSqlChanged(object sender, TextChangedEventArgs e)
+ {
+ Refresh();
+ UpdateCompletion();
+ }
private void Refresh()
{
@@ -151,6 +410,32 @@ public partial class QueryEditorWindow : Window
var ctrl = (Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control;
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)
{
IndentSelection(outdent: shift);
@@ -170,6 +455,44 @@ public partial class QueryEditorWindow : Window
}
}
+ ///
+ /// 따옴표·괄호 자동 닫기 — 선택이 있으면 그 선택을 감싼다.
+ ///
+ /// SQL 에서 '…' 와 (…) 는 짝을 빠뜨리기 쉽고, 빠뜨리면 런타임에서 조용히 실패한다.
+ /// 여는 쪽을 칠 때 닫는 쪽을 같이 넣고 커서를 사이에 둔다.
+ ///
+ 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;
+ }
+
/// 현재 줄의 시작 위치
private int LineStartOf(int index)
{
diff --git a/tests/SheetMe.Core.Tests/SqlCompletionTests.cs b/tests/SheetMe.Core.Tests/SqlCompletionTests.cs
new file mode 100644
index 0000000..4983f38
--- /dev/null
+++ b/tests/SheetMe.Core.Tests/SqlCompletionTests.cs
@@ -0,0 +1,137 @@
+using SheetMe.Core.Catalog;
+
+namespace SheetMe.Core.Tests;
+
+///
+/// 자동완성 문맥 판단 테스트.
+///
+/// 문맥을 잘못 잡으면 목록이 엉뚱한 것을 보여 주고, 그러면 안 쓰느니만 못하다.
+/// 특히 << 직후는 이 편집기에만 있는 문법이라 여기서 놓치면 손으로 외워 쳐야 한다.
+///
+[TestClass]
+public sealed class SqlCompletionTests
+{
+ /// 커서를 | 로 표시한 텍스트를 받아 분석한다 — 테스트가 읽히게
+ 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 = <이미 닫힌 변수 뒤에서는 변수 문맥이 아니다
+ [TestMethod]
+ public void Analyze_AfterClosedVariable_IsNotVariableContext()
+ {
+ var query = At("WHERE a = <> 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);
+ }
+
+ /// FROM 바로 뒤(아직 한 글자도 안 침)에서도 테이블을 제안해야 한다
+ [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);
+ }
+
+ /// 아무것도 안 친 일반 위치에서는 목록을 띄우지 않는다 — 방해가 된다
+ [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);
+ }
+
+ /// 별칭을 실제 테이블로 풀어야 컬럼을 찾을 수 있다
+ [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));
+
+ /// 별칭이 없으면 한정자 자체가 테이블 이름이다
+ [TestMethod]
+ public void ResolveTable_FallsBackToQualifier()
+ => Assert.AreEqual("P_PatMst", SqlCompletion.ResolveTable("SELECT * FROM P_PatMst", "P_PatMst"));
+
+ ///
+ /// 별칭 자리에 키워드가 오면 별칭이 아니다 — FROM P_PatMst WHERE ... 에서
+ /// WHERE 를 별칭으로 잡으면 엉뚱한 테이블을 가리킨다.
+ ///
+ [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));
+ }
+}