From 79db31cb4d63b7e23d9d7cb462fc4367e8b15d4d Mon Sep 17 00:00:00 2001 From: Msystech Date: Thu, 13 Aug 2026 15:21:08 +0900 Subject: [PATCH] =?UTF-8?q?=EC=BF=BC=EB=A6=AC=20=ED=8E=B8=EC=A7=91?= =?UTF-8?q?=EA=B8=B0=20=EA=B3=A0=EB=8F=84=ED=99=94=20=E2=80=94=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=EC=99=84=EC=84=B1=C2=B7=EA=B2=80=EC=A6=9D=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=C2=B7=ED=8E=B8=EC=A7=91=20=EA=B8=B0=EB=B3=B8=EA=B8=B0?= =?UTF-8?q?=20(Oracle)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 색칠만으로는 편집기라 하기 어렵다. 실제로 쿼리를 칠 때 손이 가는 곳을 메웠다. ■ 자동완성 (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 --- src/SheetMe.Core/Catalog/SqlCompletion.cs | 211 ++++++++++++ .../Stores/OracleQueryWorkbench.cs | 231 +++++++++++++ src/SheetMe.Designer/App.xaml.cs | 5 + src/SheetMe.Designer/Diagnostics/DbSmoke.cs | 62 ++++ .../Views/QueryEditorWindow.xaml | 57 ++- .../Views/QueryEditorWindow.xaml.cs | 325 +++++++++++++++++- .../SheetMe.Core.Tests/SqlCompletionTests.cs | 137 ++++++++ 7 files changed, 1021 insertions(+), 7 deletions(-) create mode 100644 src/SheetMe.Core/Catalog/SqlCompletion.cs create mode 100644 src/SheetMe.Data/Stores/OracleQueryWorkbench.cs create mode 100644 tests/SheetMe.Core.Tests/SqlCompletionTests.cs 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 @@ - + + + + + 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)); + } +}