From f7c90979b9ea584a1513ac9e27c195ad03d4a0e0 Mon Sep 17 00:00:00 2001 From: Msystech Date: Thu, 13 Aug 2026 15:28:10 +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=E2=80=94=20=EC=9E=90=EB=8F=99=EC=99=84=EC=84=B1=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EB=B2=84=EA=B7=B8=EC=99=80=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EA=B0=80=EB=8F=85=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ■ 팝업이 화면 구석으로 날아가던 문제 두 가지가 겹쳐 있었다. 1. WPF Popup 은 열려 있는 동안 Placement 를 다시 계산하지 않는다. 글자를 칠 때마다 HorizontalOffset/VerticalOffset 만 바꿔서는 처음 열린 자리에 그대로 머문다. 2. GetRectFromCharacterIndex 는 스크롤 밖이나 범위를 벗어난 위치에서 Empty 를 준다. 그대로 쓰면 좌표가 무한대가 되어 팝업이 화면 밖으로 나간다. 기준점을 커서가 아니라 완성 중인 낱말의 시작으로 바꾸고(글자마다 흔들리지 않는다), 그 기준점이 달라졌을 때만 닫았다 다시 연다. Empty·무한대는 캐럿 → 원점 순으로 물러선다. ■ 별칭 없이 컬럼을 칠 때 아무것도 안 뜨던 문제 select * FROM P_COMINF where comcht| 점을 찍어야만 컬럼을 제안하고 있었다. 그런데 별칭을 안 쓰는 쪽이 오히려 흔하고, WHERE 절이야말로 컬럼 이름이 가장 많이 필요한 자리다. 이제 일반 위치에서도 이 쿼리가 참조하는 테이블(FROM/JOIN)의 컬럼을 먼저 제안한다. FROM 뒤에 키워드가 오면 테이블로 보지 않고, 같은 테이블이 두 번 조인돼도 후보는 한 번만 만든다. ■ 치환 변수 목록 가독성 토큰이 60자가 넘고 앞 40자(M.CMM.HISOperatingInfo.bzPatientInfo.)가 모든 줄에서 똑같았다. 그대로 두 줄로 깔면 정작 다른 부분인 속성명이 오른쪽 끝에서 잘려 무엇이 무엇인지 구분되지 않는다. 설명 + 속성명 한 줄로 바꾸고 전체 토큰은 툴팁으로 옮겼다. 줄 수도 절반이 됐다. DataRow 형태는 꼬리(.item("컬럼명"))를 빼고 속성명만 남긴다 — 컬럼 채우기라는 사실은 설명이 말해 준다. ■ 현재 줄 강조 긴 쿼리에서 지금 어디를 고치고 있는지 잃지 않도록 커서 줄에 옅은 띠를 깐다. 선택 중일 때는 끈다 — 선택 색과 겹치면 오히려 읽기 어렵다. 단위 테스트 6건 추가(별칭 없는 테이블 참조·중복 조인·FROM 뒤 키워드 배제· 짧은 형태에 클래스 이름 없음·길이 상한·DataRow 속성명). 회귀: 테스트 216/216, 편집 스모크 실패 0, 검증 실행 점검 10/10, DB 왕복 1,271건 diff 0/예외 0, 종이 렌더 P062 바이트 동일. Co-Authored-By: Claude Opus 5 --- .../Catalog/LegacyQueryVariableCatalog.cs | 29 +++++++++++- src/SheetMe.Core/Catalog/SqlCompletion.cs | 30 ++++++++++++ .../Controls/SqlHighlightLayer.cs | 28 +++++++++++ .../Views/QueryEditorWindow.xaml | 13 ++++-- .../Views/QueryEditorWindow.xaml.cs | 46 ++++++++++++++++--- .../LegacyQueryVariableCatalogTests.cs | 26 +++++++++++ .../SheetMe.Core.Tests/SqlCompletionTests.cs | 27 +++++++++++ 7 files changed, 186 insertions(+), 13 deletions(-) diff --git a/src/SheetMe.Core/Catalog/LegacyQueryVariableCatalog.cs b/src/SheetMe.Core/Catalog/LegacyQueryVariableCatalog.cs index 0184589..9895b1c 100644 --- a/src/SheetMe.Core/Catalog/LegacyQueryVariableCatalog.cs +++ b/src/SheetMe.Core/Catalog/LegacyQueryVariableCatalog.cs @@ -6,7 +6,34 @@ namespace SheetMe.Core.Catalog; /// 이 true 면 컬럼명을 채워야 완성된다(DataRow 접근형). /// 삽입 후 사용자가 컬럼명 자리를 고쳐야 하므로 편집기가 그 부분을 선택해 준다. /// -public sealed record QueryVariable(string Group, string Token, string Description, bool NeedsColumn = false); +public sealed record QueryVariable(string Group, string Token, string Description, bool NeedsColumn = false) +{ + /// + /// 목록에 띄우는 짧은 형태 — 클래스 전체 이름을 뺀 나머지. + /// + /// 토큰은 60자가 넘고 앞 40자가 모든 줄에서 똑같다. 그대로 늘어놓으면 + /// 정작 다른 부분(속성명)이 오른쪽 끝에서 잘려 무엇이 무엇인지 구분되지 않는다. + /// 전체 문자열은 툴팁과 삽입 결과로 확인할 수 있다. + /// + public string ShortForm + { + get + { + var inner = Token.Length >= 4 ? Token[2..^2] : Token; + var lastDot = inner.LastIndexOf('.'); + // DataRow 형태(.item("컬럼명"))는 속성명만 남긴다 — + // 꼬리까지 붙이면 다시 길어져 오른쪽에서 잘린다. 컬럼 채우기라는 사실은 설명이 말해 준다. + var itemAt = inner.IndexOf(".item(", StringComparison.Ordinal); + if (itemAt > 0) + { + var head = inner[..itemAt]; + var propDot = head.LastIndexOf('.'); + return propDot >= 0 ? head[(propDot + 1)..] : head; + } + return lastDot >= 0 ? inner[(lastDot + 1)..] : inner; + } + } +} /// /// MDataTable 쿼리의 치환 변수 카탈로그. diff --git a/src/SheetMe.Core/Catalog/SqlCompletion.cs b/src/SheetMe.Core/Catalog/SqlCompletion.cs index 96daa6d..6017e51 100644 --- a/src/SheetMe.Core/Catalog/SqlCompletion.cs +++ b/src/SheetMe.Core/Catalog/SqlCompletion.cs @@ -78,6 +78,11 @@ public static class SqlCompletion 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); + + /// FROM/JOIN 뒤의 테이블 이름 — 별칭이 있든 없든 + private static readonly Regex TableRefPattern = new( + @"\b(?:FROM|JOIN)\s+([A-Za-z_][\w$#]*)", + RegexOptions.IgnoreCase | RegexOptions.Compiled); #endregion #region Methods @@ -172,6 +177,31 @@ public static class SqlCompletion return qualifier; } + /// + /// 이 쿼리가 참조하는 테이블 전부 — 별칭 없이 컬럼을 칠 때 후보를 어디서 가져올지 정한다. + /// + /// SELECT * FROM P_COMINF WHERE comcht| 처럼 별칭을 안 쓰는 쪽이 오히려 흔하다. + /// 점을 찍어야만 컬럼을 제안하면 정작 필요한 자리에서 아무것도 안 뜬다. + /// + public static IReadOnlyList ReferencedTables(string text) + { + var found = new List(); + foreach (Match match in TableRefPattern.Matches(text)) + { + var name = match.Groups[1].Value; + // FROM ( 서브쿼리 처럼 이름이 아닌 것과, 키워드가 잡히는 경우를 걸러낸다 + if (SqlTokenizer.Keywords.Contains(name)) + { + continue; + } + if (!found.Contains(name, StringComparer.OrdinalIgnoreCase)) + { + found.Add(name); + } + } + return found; + } + /// /// 후보를 걸러 정렬한다 — 접두 일치를 부분 일치보다 앞에 둔다. /// diff --git a/src/SheetMe.Designer/Controls/SqlHighlightLayer.cs b/src/SheetMe.Designer/Controls/SqlHighlightLayer.cs index 6aaba0f..e725129 100644 --- a/src/SheetMe.Designer/Controls/SqlHighlightLayer.cs +++ b/src/SheetMe.Designer/Controls/SqlHighlightLayer.cs @@ -54,6 +54,8 @@ public sealed class SqlHighlightLayer : FrameworkElement source.TextChanged += OnSourceChanged; source.SizeChanged += OnSourceChanged; source.Loaded += OnSourceLoaded; + // 커서가 움직이면 현재 줄 띠도 따라와야 한다 + source.SelectionChanged += OnSourceChanged; HookScroller(); } @@ -64,6 +66,7 @@ public sealed class SqlHighlightLayer : FrameworkElement source.TextChanged -= OnSourceChanged; source.SizeChanged -= OnSourceChanged; source.Loaded -= OnSourceLoaded; + source.SelectionChanged -= OnSourceChanged; } if (scroller is not null) { @@ -153,6 +156,9 @@ public sealed class SqlHighlightLayer : FrameworkElement typeface, source.FontSize, Brushes.Black, dpi); var lineHeight = probe.Height; + // 커서가 있는 줄 — 긴 쿼리에서 지금 어디를 고치고 있는지 잃지 않게 옅은 띠를 깐다 + var caretLine = LineIndexOf(text, source.CaretIndex); + var lineIndex = 0; var lineStart = 0; var height = ActualHeight; @@ -163,6 +169,13 @@ public sealed class SqlHighlightLayer : FrameworkElement var lineEnd = newline < 0 ? text.Length : newline; var y = offsetY + lineIndex * lineHeight; + if (lineIndex == caretLine && y + lineHeight >= 0 && y <= height + && source.SelectionLength == 0 + && TryFindResource("B.Hover") is Brush band) + { + dc.DrawRectangle(band, null, new Rect(0, y, Math.Max(0, ActualWidth), lineHeight)); + } + // 화면 밖 줄은 그리지 않는다 — 긴 쿼리에서 매 입력마다 전부 그리면 눈에 띄게 느려진다 if (y > height) { @@ -182,6 +195,21 @@ public sealed class SqlHighlightLayer : FrameworkElement } } + /// 주어진 위치가 몇 번째 줄인지(0부터) + private static int LineIndexOf(string text, int index) + { + var line = 0; + var upto = Math.Clamp(index, 0, text.Length); + for (var i = 0; i < upto; i++) + { + if (text[i] == '\n') + { + line++; + } + } + return line; + } + /// /// 한 줄을 조각별 색으로 그린다. /// 조각 경계마다 를 새로 만들되, x 위치는 앞부분 전체를 다시 재서 잡는다 — diff --git a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml index e8d3f53..a8010c7 100644 --- a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml +++ b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml @@ -124,14 +124,17 @@ HorizontalContentAlignment="Stretch" ScrollViewer.HorizontalScrollBarVisibility="Disabled" AutomationProperties.Name="치환 변수 목록"> + - + + - - + TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/> + diff --git a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs index d3b6377..0a330bf 100644 --- a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs +++ b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs @@ -112,11 +112,23 @@ public partial class QueryEditorWindow : Window return; } + // 팝업은 '완성 중인 낱말의 시작'에 붙인다 — 커서를 따라다니면 글자마다 흔들린다. + // WPF Popup 은 열려 있는 동안 Placement 를 다시 계산하지 않으므로, + // 기준점이 바뀐 경우에만 닫았다 다시 연다. + var anchorChanged = completionStart != query.ReplaceStart; completionStart = query.ReplaceStart; CompletionList.ItemsSource = items; CompletionList.SelectedIndex = 0; - PlaceCompletionPopup(); - CompletionPopup.IsOpen = true; + + if (anchorChanged && CompletionPopup.IsOpen) + { + CompletionPopup.IsOpen = false; + } + if (!CompletionPopup.IsOpen) + { + PlaceCompletionPopup(); + CompletionPopup.IsOpen = true; + } } private IEnumerable CandidatesFor(SqlCompletionQuery query) @@ -136,7 +148,12 @@ public partial class QueryEditorWindow : Window return Columns(table); default: - return SqlCompletion.BuiltIns().Concat(Tables()); + // 이 쿼리가 참조하는 테이블의 컬럼을 먼저 준다 — 별칭을 안 쓰는 쪽이 오히려 흔하고, + // 점을 찍어야만 컬럼이 뜨면 정작 필요한 자리(WHERE 절)에서 아무것도 안 나온다. + var referenced = SqlCompletion.ReferencedTables(SqlBox.Text) + .SelectMany(Columns) + .ToList(); + return referenced.Concat(SqlCompletion.BuiltIns()).Concat(Tables()); } } @@ -169,13 +186,28 @@ public partial class QueryEditorWindow : Window return items; } - /// 캐럿 바로 아래에 목록을 놓는다 + /// + /// 완성 중인 낱말 바로 아래에 목록을 놓는다. + /// + /// GetRectFromCharacterIndex 는 스크롤·범위를 벗어나면 Empty 를 준다 — + /// 그대로 쓰면 좌표가 무한대가 되어 팝업이 화면 구석으로 날아간다. 그 경우 캐럿으로 물러선다. + /// private void PlaceCompletionPopup() { - var rect = SqlBox.GetRectFromCharacterIndex(SqlBox.CaretIndex); + var index = Math.Clamp(completionStart, 0, SqlBox.Text.Length); + var rect = SqlBox.GetRectFromCharacterIndex(index); + if (rect.IsEmpty || double.IsInfinity(rect.Bottom) || double.IsNaN(rect.Bottom)) + { + rect = SqlBox.GetRectFromCharacterIndex(SqlBox.CaretIndex); + } + if (rect.IsEmpty || double.IsInfinity(rect.Bottom) || double.IsNaN(rect.Bottom)) + { + rect = new Rect(0, 0, 0, SqlBox.FontSize * 1.4); + } CompletionPopup.PlacementTarget = SqlBox; - CompletionPopup.HorizontalOffset = rect.X; - CompletionPopup.VerticalOffset = rect.Bottom + 2; + CompletionPopup.Placement = System.Windows.Controls.Primitives.PlacementMode.Relative; + CompletionPopup.HorizontalOffset = Math.Max(0, rect.X); + CompletionPopup.VerticalOffset = Math.Max(0, rect.Bottom) + 2; } /// 고른 후보를 커서 앞 낱말과 바꿔 넣는다 diff --git a/tests/SheetMe.Core.Tests/LegacyQueryVariableCatalogTests.cs b/tests/SheetMe.Core.Tests/LegacyQueryVariableCatalogTests.cs index c36cf2e..c382351 100644 --- a/tests/SheetMe.Core.Tests/LegacyQueryVariableCatalogTests.cs +++ b/tests/SheetMe.Core.Tests/LegacyQueryVariableCatalogTests.cs @@ -42,6 +42,32 @@ public sealed class LegacyQueryVariableCatalogTests } } + /// + /// 목록에 띄우는 짧은 형태는 클래스 이름을 빼고 속성만 남겨야 한다. + /// 토큰은 60자가 넘고 앞 40자가 모든 줄에서 같아, 그대로 깔면 정작 다른 부분이 잘린다. + /// + [TestMethod] + public void ShortForm_DropsSharedClassPrefix() + { + foreach (var variable in LegacyQueryVariableCatalog.All) + { + Assert.IsFalse(variable.ShortForm.Contains("M.CMM", StringComparison.Ordinal), + $"{variable.Token}: 짧은 형태에 클래스 이름이 남았습니다 → {variable.ShortForm}"); + Assert.IsFalse(string.IsNullOrWhiteSpace(variable.ShortForm), variable.Token); + Assert.IsTrue(variable.ShortForm.Length <= 24, + $"{variable.Token}: 짧은 형태가 너무 깁니다 → {variable.ShortForm}"); + } + } + + /// DataRow 형태는 속성명부터 보여 준다 — 점이 여러 개라 마지막 조각만 쓰면 컬럼명만 남는다 + [TestMethod] + public void ShortForm_KeepsPropertyNameForDataRowTemplates() + { + var row = LegacyQueryVariableCatalog.All.First(v => v.Token.Contains("PatInfDR", StringComparison.Ordinal)); + + Assert.AreEqual("PatInfDR", row.ShortForm, "꼬리(.item(\"컬럼명\"))는 빼고 속성명만 남아야 한다"); + } + /// 접두어는 클래스 전체 이름이어야 한다 — 축약형 회귀 방지(이 버그가 실제로 있었다) [TestMethod] public void Prefixes_AreFullyQualifiedTypeNames() diff --git a/tests/SheetMe.Core.Tests/SqlCompletionTests.cs b/tests/SheetMe.Core.Tests/SqlCompletionTests.cs index 4983f38..0bc2414 100644 --- a/tests/SheetMe.Core.Tests/SqlCompletionTests.cs +++ b/tests/SheetMe.Core.Tests/SqlCompletionTests.cs @@ -126,6 +126,33 @@ public sealed class SqlCompletionTests Assert.AreEqual("XPrint", filtered[^1].Text, "부분 일치는 뒤로"); } + /// + /// 별칭 없이 컬럼을 칠 때도 후보가 나와야 한다 — 별칭을 안 쓰는 쪽이 오히려 흔하다. + /// 이걸 못 잡으면 WHERE 절에서 아무것도 안 뜬다. + /// + [TestMethod] + public void ReferencedTables_FindsTablesWithoutAlias() + { + var tables = SqlCompletion.ReferencedTables("select * FROM P_COMINF where comcht"); + + CollectionAssert.AreEqual(new[] { "P_COMINF" }, tables.ToArray()); + } + + [TestMethod] + public void ReferencedTables_FindsJoinedTablesOnce() + { + var tables = SqlCompletion.ReferencedTables( + "SELECT * FROM P_PatMst p JOIN P_ComInf c ON p.a=c.a JOIN P_ComInf d ON p.b=d.b"); + + CollectionAssert.AreEqual(new[] { "P_PatMst", "P_ComInf" }, tables.ToArray(), + "같은 테이블이 두 번 조인돼도 후보는 한 번만"); + } + + /// FROM 뒤에 키워드가 오면 테이블로 보지 않는다 + [TestMethod] + public void ReferencedTables_IgnoresKeywordAfterFrom() + => Assert.AreEqual(0, SqlCompletion.ReferencedTables("SELECT * FROM SELECT").Count); + [TestMethod] public void BuiltIns_CoverKeywordsAndFunctions() {