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()
{