쿼리 편집기 고도화 — 자동완성·검증 실행·편집 기본기 (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:
co-authored by
Claude Opus 5
parent
864f11b0e4
commit
79db31cb4d
@@ -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]);
|
||||
|
||||
@@ -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>
|
||||
/// 데이터·액션 태그 실사용 분포(--db-tags) — 태그 피커를 어떻게 정렬·큐레이션할지 근거(읽기 전용).
|
||||
///
|
||||
|
||||
@@ -9,14 +9,36 @@
|
||||
<TextBlock x:Name="HintText" DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,8"
|
||||
Text="작성 시점에 <<변수>> 가 실제 값으로 치환됩니다. 변수는 클래스 전체 이름을 포함해야 하므로 우측 목록에서 삽입하세요 — 임의로 줄여 쓰면 치환되지 않고 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"
|
||||
Foreground="{DynamicResource B.Danger}" FontSize="11.5"
|
||||
TextTrimming="CharacterEllipsis" MaxWidth="420"/>
|
||||
<TextBlock x:Name="LengthText" VerticalAlignment="Center" Margin="0,0,12,0" Foreground="{DynamicResource B.Muted}" FontSize="11.5"/>
|
||||
<Button Content="확인" Padding="20,5" Click="OnConfirm" IsDefault="True"/>
|
||||
<Button Content="취소" Padding="20,5" Margin="8,0,0,0" IsCancel="True"/>
|
||||
</StackPanel>
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- 검증 결과 — 실행했을 때만 뜬다 -->
|
||||
<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.ColumnDefinitions>
|
||||
@@ -45,6 +67,29 @@
|
||||
|
||||
<Grid Grid.Column="1">
|
||||
<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"
|
||||
FontFamily="Consolas, D2Coding, 굴림체" FontSize="13"
|
||||
AcceptsReturn="True" AcceptsTab="False"
|
||||
|
||||
@@ -24,6 +24,22 @@ public partial class QueryEditorWindow : Window
|
||||
{
|
||||
#region Member Fields
|
||||
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
|
||||
|
||||
#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
|
||||
/// <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
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>
|
||||
private int LineStartOf(int index)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user