"아직도 안됨, 그리고 글씨도 흐리게보이고" — 둘 다 맞다. ## ① 안 되는 게 아니라 고를 수가 없었다 ORA-00936 은 사라졌고 검증 실행은 성공한다. 그런데 0행이다. 화면이 이유를 말하고 있었다 — "환자를 고르지 않아 변수를 NULL 로 두고 구문만 검사". 그런데 <b>환자 선택이 미리보기 창 안에만 있었다.</b> 쿼리를 고치는 사람은 값 확인을 할 방법이 없고, 늘 0행이면 그건 "안 된다"로 읽힌다. 사유를 정확히 말해도 고칠 수단이 없으면 안내가 아니다. 검증 실행 옆에 '환자 선택...' 을 뒀다. 고르면 치환 변수에 실제 값이 들어가 구문뿐 아니라 값까지 확인된다. 세션 하나를 공유하므로 미리보기와 같은 환자다. 이미 고른 상태에서는 버튼이 '환자 해제 — <이름>' 이 된다 — 버튼 하나에 고르기·바꾸기·해제를 다 넣으면 무엇이 일어날지 예측할 수 없다. ## ② 카드 그림자가 ClearType 을 끄고 있었다 이 세션에서 미뤄 둔 항목이다. Card 스타일이 Effect(그림자)를 걸고, Effect 는 하위 트리 전체를 중간 서피스로 보내 ClearType 을 끈다 — 13px 한글이 회색 안티에일리어싱으로 뭉개진다. 힌트는 Effect 가 걸린 Border 에 걸면 듣지 않는다(--cleartype 표본 C1 이 그것이다). 안쪽의 불투명 배경 요소에 걸어야 한다(C2). 카드 안 DockPanel 에 배경과 힌트를 줬다. ## 기법이 듣는 것과 걸어 둔 것은 다른 일이다 --cleartype 은 그동안 <b>합성 도형</b>만 쟀다. A~J 전부 통과했는데도 실제 창에는 기법을 안 걸어 둬서 글씨가 계속 뭉개졌다 — 게이트가 초록불인 채로 사용자가 보는 화면만 틀려 있었다. 그래서 실제 쿼리 편집 창을 띄워 카드 제목을 재는 표본 G 를 넣었다. 색번짐 150 / 글자픽셀 188 → 켜짐. 고치기 전 조건은 표본 B(Effect + 힌트 없음)와 같고 그건 0 이다. 창은 화면 안(0,0)에 띄운다 — BitBlt 은 실제로 그려진 픽셀만 읽으므로 화면 밖에 두면 아무것도 못 잰다. ## 게이트 - dotnet test 323/323 - --cleartype 실패 0 (A~J + G(실제 창) 신규) - --query-popup 실패 0 · --edit-smoke 실패 0 · --dialog-shots FAIL 0 - --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1158 lines
46 KiB
C#
1158 lines
46 KiB
C#
using System.Text;
|
|
using System.Windows;
|
|
using System.Windows.Controls;
|
|
using System.Windows.Data;
|
|
using System.Windows.Input;
|
|
using SheetMe.Core.Catalog;
|
|
|
|
namespace SheetMe.Designer.Views;
|
|
|
|
/// <summary>
|
|
/// 데이터소스(MDataTable) 쿼리 전용 편집기 — 구문 강조 + 편집 보조 + 치환 변수 삽입.
|
|
///
|
|
/// 치환 규칙 원본: [014]EMRLoader bzDesignSheetLoader.ConvertQuery.
|
|
/// 토큰 접두어는 <b>클래스 전체 이름</b>이어야 하며 런타임이 GetType.FullName 과 완전일치로 비교한다
|
|
/// (ucLoadSheetBase.vb:7619-7631 이 형을 고정). 목록은 <see cref="LegacyQueryVariableCatalog"/> 참조.
|
|
///
|
|
/// 색칠은 <see cref="Controls.SqlHighlightLayer"/> 가 뒤에서 하고 편집은 투명 TextBox 가 그대로 한다 —
|
|
/// 서드파티 편집기 컴포넌트를 배포본에 넣지 않기로 해서 직접 만든 구성이다.
|
|
///
|
|
/// 큰 다중행 편집 영역이 필요한 다른 용도(상용구 문구)에도 재사용한다 —
|
|
/// 그 경우 <c>showVariables: false</c> 로 SQL 변수 패널을 숨기고 색칠도 끈다.
|
|
/// </summary>
|
|
public partial class QueryEditorWindow : Window
|
|
{
|
|
#region Member Fields
|
|
private readonly bool isSql;
|
|
|
|
/// <summary>
|
|
/// 아무 변수도 모르는 원천 — 환자를 안 골랐을 때 쓴다.
|
|
/// 전부 Unknown 이 되고, 자리표시(NULL)가 채워져 구문 검사는 그대로 돌아간다.
|
|
/// </summary>
|
|
private sealed class UnknownVariables : SheetMe.Core.Catalog.IQueryVariableSource
|
|
{
|
|
public static readonly UnknownVariables Instance = new();
|
|
|
|
public string? Scalar(string className, string property) => null;
|
|
|
|
public IReadOnlyDictionary<string, string>? Row(string className, string property) => null;
|
|
}
|
|
|
|
/// <summary>마지막으로 돌린 SQL — 진단이 "무엇을 돌렸는가"를 확인할 때 쓴다</summary>
|
|
private string lastTrialSql = string.Empty;
|
|
|
|
/// <summary>진단 전용 — 검증 실행이 실제로 만든 SQL</summary>
|
|
internal string LastTrialSql => lastTrialSql;
|
|
|
|
/// <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;
|
|
|
|
/// <summary>
|
|
/// 방금 글자를 쳐서 목록이 갱신됐는지 — 커서만 움직인 경우와 구분한다.
|
|
/// 타이핑이면 목록을 다시 계산하고, 그냥 커서를 옮긴 것이면 닫는다.
|
|
/// </summary>
|
|
private bool typing;
|
|
|
|
/// <summary>한글 조합 중인가 — 조합 중에는 색칠을 접고 TextBox 가 제 글자를 그린다</summary>
|
|
private bool composing;
|
|
|
|
/// <summary>목록이 걸려 있는 낱말의 끝 — 커서가 이 범위를 벗어나면 목록은 더 이상 유효하지 않다</summary>
|
|
private int completionEnd;
|
|
|
|
/// <summary>이 서식의 컨트롤 이름 — 《컨트롤명》 치환 후보</summary>
|
|
private readonly IReadOnlyList<string> controlNames;
|
|
|
|
/// <summary>고른 갈래 칩 — 빈 문자열이면 전체</summary>
|
|
private string activeBucket = string.Empty;
|
|
#endregion
|
|
|
|
#region Properties - Commands
|
|
/// <summary>목록 행의 + 버튼 — 더블클릭을 모르는 사람도 넣을 수 있어야 한다</summary>
|
|
public M.Framework.WPF.ICustomCommand InsertCommand { get; }
|
|
|
|
/// <summary>검증 실행 — Ctrl+Enter / F5 단축키가 이것을 부른다</summary>
|
|
public M.Framework.WPF.ICustomCommand TrialCommand { get; }
|
|
#endregion
|
|
|
|
#region Properties
|
|
/// <summary>편집 결과 텍스트 — 확인 시 채워짐</summary>
|
|
public string QueryText { get; private set; } = string.Empty;
|
|
#endregion
|
|
|
|
#region Constructors
|
|
/// <param name="ownerLabel">제목에 붙일 대상 이름</param>
|
|
/// <param name="initialQuery">초기 텍스트</param>
|
|
/// <param name="showVariables">SQL 치환 변수 패널 표시 여부 — 상용구 등 SQL 이 아닌 용도에서는 false</param>
|
|
/// <param name="controlNames">
|
|
/// 같은 서식의 컨트롤 이름 — 《컨트롤명》 치환 후보가 된다.
|
|
/// 이 목록은 문서마다 다르므로 카탈로그가 아니라 호출부가 준다.
|
|
/// </param>
|
|
public QueryEditorWindow(string ownerLabel, string initialQuery, bool showVariables = true,
|
|
IReadOnlyList<string>? controlNames = null)
|
|
{
|
|
InitializeComponent();
|
|
isSql = showVariables;
|
|
this.controlNames = controlNames ?? Array.Empty<string>();
|
|
InsertCommand = new M.Framework.WPF.Command((object parameter) =>
|
|
{
|
|
if (parameter is QueryVariable variable)
|
|
{
|
|
InsertVariable(variable);
|
|
}
|
|
});
|
|
TrialCommand = new M.Framework.WPF.Command((sender, e) =>
|
|
{
|
|
if (!isSql)
|
|
{
|
|
return;
|
|
}
|
|
// 자동완성 목록이 떠 있으면 닫고 실행한다 — Enter 의 뜻(목록 확정)과 겹치지 않게
|
|
CompletionPopup.IsOpen = false;
|
|
OnTrial(this, new RoutedEventArgs());
|
|
});
|
|
DataContext = this;
|
|
SqlBox.Text = initialQuery;
|
|
TargetChipText.Text = ownerLabel;
|
|
|
|
if (showVariables)
|
|
{
|
|
Title = $"쿼리 편집 — {ownerLabel}";
|
|
Highlight.Source = SqlBox;
|
|
LineNumbers.Source = SqlBox;
|
|
// 한글 조합(IME) 동안에는 색칠을 잠시 접고 TextBox 가 제 글자를 그리게 한다 — 아래 설명
|
|
TextCompositionManager.AddTextInputStartHandler(SqlBox, OnComposing);
|
|
TextCompositionManager.AddTextInputUpdateHandler(SqlBox, OnComposing);
|
|
TextCompositionManager.AddTextInputHandler(SqlBox, OnComposed);
|
|
RebuildBucketChips();
|
|
ApplyVariableFilter(string.Empty);
|
|
RefreshPatientButton();
|
|
var connection = Services.ConfigService.Current.ConnectionString;
|
|
workbench = new SheetMe.Data.Stores.OracleQueryWorkbench(connection);
|
|
if (!workbench.CanUseDb)
|
|
{
|
|
TrialButton.IsEnabled = false;
|
|
TrialButton.ToolTip = "DB 접속이 설정되지 않아 검증 실행을 쓸 수 없습니다.";
|
|
}
|
|
ConnText.Text = workbench.CanUseDb ? "연결 상태: 정상" : "연결 상태: 없음(오프라인)";
|
|
ConnDot.Fill = (System.Windows.Media.Brush)FindResource(workbench.CanUseDb ? "B.Ok" : "B.Muted");
|
|
}
|
|
else
|
|
{
|
|
// SQL 이 아닌 텍스트에 SQL 색을 칠하면 예약어와 겹치는 낱말이 엉뚱하게 물든다
|
|
Title = ownerLabel;
|
|
TitleTextBlock.Text = ownerLabel;
|
|
TargetChip.Visibility = Visibility.Collapsed;
|
|
SqlBox.Foreground = (System.Windows.Media.Brush)FindResource("B.Ink");
|
|
VariablePane.Visibility = Visibility.Collapsed;
|
|
SplitterColumn.Width = new GridLength(0);
|
|
VariableColumn.Width = new GridLength(0);
|
|
TrialButton.Visibility = Visibility.Collapsed;
|
|
ConnText.Text = "텍스트 편집";
|
|
}
|
|
|
|
// 편집기가 스크롤되면 목록이 걸린 자리가 화면에서 움직인다 — 따라가게 다시 놓는다.
|
|
// 닫아 버리면 안 된다: 긴 문서 끝에서 타이핑하면 그 입력 자체가 스크롤을 일으켜
|
|
// 목록이 열리자마자 사라진다(진단이 이 상태를 잡았다).
|
|
SqlBox.AddHandler(ScrollViewer.ScrollChangedEvent,
|
|
new ScrollChangedEventHandler((_, _) => RepositionCompletion()));
|
|
// 창을 벗어나면 목록이 다른 창 위에 떠 있게 된다
|
|
Deactivated += (_, _) => CompletionPopup.IsOpen = false;
|
|
|
|
Loaded += (_, _) =>
|
|
{
|
|
SqlBox.Focus();
|
|
SqlBox.CaretIndex = SqlBox.Text.Length;
|
|
Refresh();
|
|
};
|
|
}
|
|
#endregion
|
|
|
|
#region Methods - Completion
|
|
/// <summary>
|
|
/// 진단(--query-popup)에서 목록 갱신을 부르는 통로.
|
|
/// 프로그램으로 Text 를 넣으면 TextChanged 시점의 커서가 아직 옛 자리라
|
|
/// 사용자가 친 것과 같은 상태를 만들 수 없다.
|
|
/// </summary>
|
|
internal void RefreshCompletionForSmoke() => UpdateCompletion();
|
|
|
|
/// <summary>진단(--query-popup)에서 검증 실행을 부르는 통로 — 결과 표가 실제로 차는지 본다</summary>
|
|
internal void RunTrialForSmoke() => OnTrial(this, new RoutedEventArgs());
|
|
|
|
/// <summary>
|
|
/// 환자를 고른다 — 고르면 검증 실행이 <b>실제 값</b>으로 돈다.
|
|
///
|
|
/// 권한 문·감사 기록·문맥 읽기는 전부 <see cref="PatientPickerDialogView.Pick"/> 안에 있다.
|
|
/// 세션 하나를 공유하므로 미리보기와 같은 환자다 — 두 화면이 다른 환자를 보면 구분할 수 없다.
|
|
/// </summary>
|
|
private void OnPickPatient(object sender, RoutedEventArgs e)
|
|
{
|
|
if (Services.PatientSession.HasPatient)
|
|
{
|
|
// 이미 고른 환자가 있으면 이 버튼은 '해제'다. 바꾸려면 해제하고 다시 고른다 —
|
|
// 버튼 하나에 '고르기/바꾸기/해제'를 다 넣으면 무엇이 일어날지 예측할 수 없다.
|
|
Services.PatientSession.Set(null, string.Empty);
|
|
RefreshPatientButton();
|
|
return;
|
|
}
|
|
if (PatientPickerDialogView.Pick(this, out var label) is { } picked)
|
|
{
|
|
Services.PatientSession.Set(picked, label);
|
|
RefreshPatientButton();
|
|
}
|
|
}
|
|
|
|
/// <summary>버튼이 지금 무엇을 하는지 글자로 말한다 — 상태를 색으로만 알리면 못 읽는다</summary>
|
|
private void RefreshPatientButton()
|
|
{
|
|
if (PatientButton is null)
|
|
{
|
|
return;
|
|
}
|
|
PatientButton.Content = Services.PatientSession.HasPatient
|
|
? $"환자 해제 — {Services.PatientSession.Label}"
|
|
: "환자 선택...";
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
|
|
// 팝업은 '완성 중인 낱말의 시작'에 붙인다 — 커서를 따라다니면 글자마다 흔들린다.
|
|
// WPF Popup 은 열려 있는 동안 Placement 를 다시 계산하지 않으므로,
|
|
// 기준점이 바뀐 경우에만 닫았다 다시 연다.
|
|
var anchorChanged = completionStart != query.ReplaceStart;
|
|
completionStart = query.ReplaceStart;
|
|
completionEnd = SqlBox.CaretIndex;
|
|
CompletionList.ItemsSource = items;
|
|
CompletionList.SelectedIndex = 0;
|
|
|
|
if (anchorChanged && CompletionPopup.IsOpen)
|
|
{
|
|
CompletionPopup.IsOpen = false;
|
|
}
|
|
if (!CompletionPopup.IsOpen)
|
|
{
|
|
PlaceCompletionPopup();
|
|
CompletionPopup.IsOpen = true;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 커서가 움직였다 — 타이핑이면 목록을 다시 계산하고, 그냥 옮긴 것이면 닫는다.
|
|
///
|
|
/// 이걸 안 하면 옛 문맥의 목록이 그대로 남는다. 실제로
|
|
/// <c>FROM </c> 에서 테이블 목록을 띄운 뒤 다음 줄로 내려가도 그 목록이 계속 떠 있었다.
|
|
/// </summary>
|
|
private void OnSqlSelectionChanged(object sender, RoutedEventArgs e)
|
|
{
|
|
if (applyingCompletion || typing)
|
|
{
|
|
return;
|
|
}
|
|
if (!CompletionPopup.IsOpen)
|
|
{
|
|
return;
|
|
}
|
|
var caret = SqlBox.CaretIndex;
|
|
// 목록이 걸린 낱말 안에 있으면 유지, 벗어났으면 닫는다
|
|
if (caret < completionStart || caret > completionEnd || SqlBox.SelectionLength > 0)
|
|
{
|
|
CompletionPopup.IsOpen = false;
|
|
}
|
|
}
|
|
|
|
private void OnSqlLostFocus(object sender, RoutedEventArgs e) => CloseOnBlur();
|
|
|
|
private void CloseOnBlur()
|
|
{
|
|
CompletionPopup.IsOpen = false;
|
|
// 조합 도중 포커스를 잃으면 조합 끝 신호가 오지 않는다 — 색칠 없는 상태로 굳지 않게 되돌린다
|
|
ShowRawText(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 스크롤로 자리가 움직였을 때 목록을 새 자리에 다시 놓는다.
|
|
///
|
|
/// WPF Popup 은 열려 있는 동안 Placement 를 다시 계산하지 않으므로 닫았다 다시 연다.
|
|
/// 걸려 있던 낱말을 커서가 벗어났으면 그때는 닫는다 — 더 이상 유효한 목록이 아니다.
|
|
/// </summary>
|
|
private void RepositionCompletion()
|
|
{
|
|
if (!CompletionPopup.IsOpen)
|
|
{
|
|
return;
|
|
}
|
|
var caret = SqlBox.CaretIndex;
|
|
if (caret < completionStart || caret > completionEnd)
|
|
{
|
|
CompletionPopup.IsOpen = false;
|
|
return;
|
|
}
|
|
CompletionPopup.IsOpen = false;
|
|
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:
|
|
// 이 쿼리가 참조하는 테이블의 컬럼을 먼저 준다 — 별칭을 안 쓰는 쪽이 오히려 흔하고,
|
|
// 점을 찍어야만 컬럼이 뜨면 정작 필요한 자리(WHERE 절)에서 아무것도 안 나온다.
|
|
var referenced = SqlCompletion.ReferencedTables(SqlBox.Text)
|
|
.SelectMany(Columns)
|
|
.ToList();
|
|
return referenced.Concat(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>
|
|
/// 완성 중인 낱말 바로 아래에 목록을 놓는다.
|
|
///
|
|
/// GetRectFromCharacterIndex 는 스크롤·범위를 벗어나면 Empty 를 준다 —
|
|
/// 그대로 쓰면 좌표가 무한대가 되어 팝업이 화면 구석으로 날아간다. 그 경우 캐럿으로 물러선다.
|
|
/// </summary>
|
|
private void PlaceCompletionPopup()
|
|
{
|
|
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);
|
|
}
|
|
// 편집 영역 밖으로는 내보내지 않는다. 스크롤 위치에 따라 rect 가 영역을 크게 벗어날 수 있는데,
|
|
// 그대로 쓰면 목록이 창 밖(또는 화면 구석)에 뜬다 — 실제로 그렇게 보였다.
|
|
var maxX = Math.Max(0, SqlBox.ActualWidth - 40);
|
|
var maxY = Math.Max(0, SqlBox.ActualHeight);
|
|
CompletionPopup.PlacementTarget = SqlBox;
|
|
CompletionPopup.Placement = System.Windows.Controls.Primitives.PlacementMode.Relative;
|
|
CompletionPopup.HorizontalOffset = Math.Clamp(rect.X, 0, maxX);
|
|
CompletionPopup.VerticalOffset = Math.Clamp(rect.Bottom + 2, 0, maxY);
|
|
}
|
|
|
|
/// <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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 목록 항목 클릭 — 누르는 즉시 넣는다.
|
|
///
|
|
/// 예전에는 더블클릭이었는데, 클릭이 편집기에서 포커스를 가져가면서 LostFocus 가 팝업을 닫아
|
|
/// 아무것도 입력되지 않고 사라졌다. 여기서 눌린 항목을 직접 찾아 처리하고 이벤트를 소비해
|
|
/// 포커스가 아예 옮겨 가지 않게 한다.
|
|
/// </summary>
|
|
private void OnCompletionPicked(object sender, MouseButtonEventArgs e)
|
|
{
|
|
if (e.OriginalSource is DependencyObject origin && PickCompletionAt(origin))
|
|
{
|
|
// 소비하지 않으면 ListBoxItem 이 포커스를 가져가고 편집기가 LostFocus 를 받는다
|
|
e.Handled = true;
|
|
SqlBox.Focus();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 눌린 지점에서 항목을 찾아 넣는다 — 처리했으면 true.
|
|
/// 클릭 지점은 항목 안의 글자일 수도, 항목 컨테이너 자체일 수도 있다.
|
|
/// </summary>
|
|
private bool PickCompletionAt(DependencyObject origin)
|
|
{
|
|
var container = ItemsControl.ContainerFromElement(CompletionList, origin) as ListBoxItem;
|
|
if (container?.DataContext is not SqlCompletionItem item)
|
|
{
|
|
return false;
|
|
}
|
|
CompletionList.SelectedItem = item;
|
|
AcceptCompletion();
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 진단(--query-popup)에서 '항목 클릭'을 부르는 통로.
|
|
/// WPF 의 마우스 라우팅은 화면 없이 흉내 낼 수 없어(합성 이벤트가 캡처·히트테스트를 못 거친다)
|
|
/// 핸들러 본문을 그대로 태워 삽입 로직만 확인한다.
|
|
/// </summary>
|
|
internal bool PickCompletionForSmoke(int index)
|
|
{
|
|
var container = CompletionList.ItemContainerGenerator.ContainerFromIndex(index);
|
|
return container is DependencyObject origin && PickCompletionAt(origin);
|
|
}
|
|
|
|
/// <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> 한 뒤 돌려 본다.
|
|
///
|
|
/// <b>전에는 변수를 빈 문자열로 지웠다.</b> 런타임이 값 없을 때 그렇게 한다는 이유였지만
|
|
/// (clsMDataTable.vb:67-71), 그 결과 <c>WHERE X = </c> 가 되어 ORA-00936 이 났다 —
|
|
/// 즉 <b>치환 변수를 값 자리에 쓴 쿼리는 검증 실행을 통과할 수가 없었다.</b>
|
|
/// 정작 이 기능이 존재하는 이유가 그런 쿼리들이다.
|
|
///
|
|
/// 이제 두 갈래다:
|
|
/// · 환자를 골랐으면(<see cref="PatientSession"/>) <b>실제 값</b>으로 치환한다 —
|
|
/// 구문뿐 아니라 값까지 확인된다. 미리보기와 같은 환자라 결과도 같다.
|
|
/// · 안 골랐으면 빈 자리를 <c>NULL</c> 로 채운다. 따옴표 안이든 밖이든 문법이 성립하고
|
|
/// 0행이 나오므로 <b>테이블·컬럼 오타와 구문 오류</b>는 그대로 걸린다.
|
|
/// </summary>
|
|
private void OnTrial(object sender, RoutedEventArgs e)
|
|
{
|
|
if (workbench is null)
|
|
{
|
|
return;
|
|
}
|
|
var variables = Services.PatientSession.Variables();
|
|
var converted = SheetMe.Core.Catalog.QuerySubstitution.Apply(
|
|
SqlBox.Text, variables ?? UnknownVariables.Instance,
|
|
// 환자가 있어도 자리표시는 준다 — 아직 안 옮긴 변수(Age·Sex)가 섞여 있으면
|
|
// 그것만 빈 값이 되어 같은 ORA-00936 이 난다.
|
|
emptyPlaceholder: "NULL");
|
|
var probe = converted.Sql;
|
|
lastTrialSql = probe;
|
|
|
|
Mouse.OverrideCursor = Cursors.Wait;
|
|
SheetMe.Data.Stores.QueryTrialResult result;
|
|
try
|
|
{
|
|
result = workbench.Trial(probe);
|
|
}
|
|
finally
|
|
{
|
|
Mouse.OverrideCursor = null;
|
|
}
|
|
|
|
TrialPane.Visibility = Visibility.Visible;
|
|
// 무엇으로 돌렸는지 먼저 말한다 — 실제 값인지 자리표시인지에 따라 결과의 의미가 완전히 다르다.
|
|
// 이 한 줄이 없으면 0행을 보고 "이 환자에게 자료가 없다"로 잘못 읽는다.
|
|
var basis = variables is not null
|
|
? $"환자 {Services.PatientSession.Label} 의 실제 값으로 실행"
|
|
: "환자를 고르지 않아 변수를 NULL 로 두고 구문만 검사";
|
|
var notes = new List<string>();
|
|
// 환자를 안 골랐으면 <b>모든</b> 변수가 Unknown 이다 — 그것을 "아직 옮기지 않았다"고 적으면
|
|
// 거짓이 된다(ChtNum 은 옮겨져 있다). 그 사유는 위 basis 줄이 이미 말한다.
|
|
if (variables is not null && converted.UnknownTokens.Count > 0)
|
|
{
|
|
notes.Add("아직 옮기지 않은 변수(NULL 로 대체): " + string.Join(", ", converted.UnknownTokens));
|
|
}
|
|
if (converted.HasLeftover)
|
|
{
|
|
// 접두어가 클래스 전체 이름이 아니면 토큰이 그대로 남아 ORA 구문오류가 된다 —
|
|
// 오라클 문구보다 이쪽이 고칠 곳을 알려 준다.
|
|
notes.Add("치환되지 않은 변수가 남았습니다(접두어가 클래스 전체 이름이어야 합니다): "
|
|
+ string.Join(", ", converted.Hits
|
|
.Where(h => h.Kind == SheetMe.Core.Catalog.QuerySubstitutionKind.NotAVariable)
|
|
.Select(h => h.Token).Distinct()));
|
|
}
|
|
foreach (var bare in BareVariables(SqlBox.Text))
|
|
{
|
|
// 값은 원문 그대로 박힌다 — 문자 컬럼과 비교하려면 서식 쪽에서 따옴표를 감싸야 한다
|
|
// (clsMDataTable.vb:113). 이게 이 배선의 가장 흔한 실수다.
|
|
notes.Add($"{bare} 가 따옴표 밖에 있습니다 — 문자 컬럼과 비교하려면 '<<…>>' 로 감싸야 합니다");
|
|
}
|
|
|
|
if (result.Ok)
|
|
{
|
|
TrialSummary.Foreground = (System.Windows.Media.Brush)FindResource(
|
|
notes.Count > 0 ? "B.Danger" : "B.Ink");
|
|
var head = result.Rows.Count == 0
|
|
? result.Summary + (variables is null
|
|
? " — 구문은 정상입니다(변수가 NULL 이라 조건에 걸리는 행이 없습니다)."
|
|
: " — 구문은 정상이고, 이 환자에게 해당하는 행이 없습니다.")
|
|
: result.Summary;
|
|
TrialSummary.Text = string.Join(" · ", new[] { head, basis }.Concat(notes));
|
|
TrialGrid.ItemsSource = ToGridRows(result);
|
|
TrialGrid.Visibility = result.Rows.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
|
|
}
|
|
else
|
|
{
|
|
TrialSummary.Foreground = (System.Windows.Media.Brush)FindResource("B.Danger");
|
|
TrialSummary.Text = string.Join(" · ",
|
|
new[] { "실패 — " + result.Error, basis }.Concat(notes));
|
|
TrialGrid.ItemsSource = null;
|
|
TrialGrid.Visibility = Visibility.Collapsed;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 따옴표 밖에 놓인 치환 변수들 — 문자 컬럼과 비교할 때 값이 그대로 박혀 깨진다.
|
|
///
|
|
/// 판정은 <b>같은 줄에서 토큰 앞뒤에 붙은 글자</b>만 본다. 정확한 SQL 파싱이 아니라
|
|
/// 흔한 실수를 짚는 것이고, 애매하면 말하지 않는다 — 틀린 경고를 자주 내면 아무도 안 읽는다.
|
|
/// </summary>
|
|
private static List<string> BareVariables(string sql)
|
|
{
|
|
var found = new List<string>();
|
|
foreach (var token in SqlTokenizer.VariablesIn(sql).Distinct(StringComparer.Ordinal))
|
|
{
|
|
var at = sql.IndexOf(token, StringComparison.Ordinal);
|
|
if (at < 0)
|
|
{
|
|
continue;
|
|
}
|
|
// 경계를 '따옴표 없음'으로 본다. 처음엔 경계를 건너뛰게 썼는데,
|
|
// 그러면 <b>WHERE 절 끝에 온 변수</b>가 전부 빠졌다 — 가장 흔한 자리다.
|
|
var before = at > 0 ? sql[at - 1] : '\0';
|
|
var after = at + token.Length < sql.Length ? sql[at + token.Length] : '\0';
|
|
if (before != '\'' && after != '\'')
|
|
{
|
|
found.Add(token);
|
|
}
|
|
}
|
|
return found;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 결과를 DataGrid 가 열까지 만들어 주는 형태로 옮긴다.
|
|
///
|
|
/// <b>ExpandoObject 로는 안 된다.</b> DataGrid 의 AutoGenerateColumns 는 항목 <i>타입</i>의
|
|
/// 속성을 반사해 열을 만드는데 ExpandoObject 에는 그런 속성이 없다 —
|
|
/// 그래서 요약만 뜨고 표는 비어 보였다. DataTable 은 DataGrid 가 직접 알아본다.
|
|
/// </summary>
|
|
private static System.Data.DataView ToGridRows(SheetMe.Data.Stores.QueryTrialResult result)
|
|
{
|
|
var table = new System.Data.DataTable();
|
|
for (var i = 0; i < result.Columns.Count; i++)
|
|
{
|
|
// 같은 이름이 두 번 나오면 DataTable 이 예외를 낸다(별칭 없는 조인에서 흔하다)
|
|
var name = result.Columns[i];
|
|
if (table.Columns.Contains(name))
|
|
{
|
|
name = $"{name}_{i}";
|
|
}
|
|
table.Columns.Add(name, typeof(string));
|
|
}
|
|
foreach (var values in result.Rows)
|
|
{
|
|
var row = table.NewRow();
|
|
for (var i = 0; i < table.Columns.Count && i < values.Length; i++)
|
|
{
|
|
row[i] = values[i];
|
|
}
|
|
table.Rows.Add(row);
|
|
}
|
|
return table.DefaultView;
|
|
}
|
|
|
|
private void OnCloseTrial(object sender, RoutedEventArgs e) => TrialPane.Visibility = Visibility.Collapsed;
|
|
#endregion
|
|
|
|
#region Methods - Editing
|
|
/// <summary>
|
|
/// 한글을 치는 동안(IME 조합 중) 글자가 사라지던 문제.
|
|
///
|
|
/// 색칠은 "투명한 TextBox 뒤에 우리가 그린다"로 되어 있다. 그런데 조합 중인 글자는
|
|
/// 아직 <see cref="TextBox.Text"/> 에 들어오지 않는다 — TextChanged 가 안 오니 우리는 그릴 수 없고,
|
|
/// TextBox 는 제 Foreground(투명)로 그리니 아무것도 안 보인다. 자음·모음을 맞추는 내내 빈 화면이었다.
|
|
///
|
|
/// 그래서 조합이 시작되면 TextBox 에게 잉크색을 돌려주고 색칠 층을 감춘다. 조합이 끝나
|
|
/// 글자가 확정되면(TextInput → 이어서 TextChanged) 원래대로 되돌린다. 조합 중 잠깐 단색이 되는
|
|
/// 것이 글자가 안 보이는 것보다 낫다.
|
|
/// </summary>
|
|
private void OnComposing(object? sender, TextCompositionEventArgs e)
|
|
// 조합이 취소되면(ESC) CompositionText 가 비면서 여기로 온다 — 그때는 바로 색칠로 돌아간다
|
|
=> ShowRawText(e.TextComposition.CompositionText.Length > 0);
|
|
|
|
private void OnComposed(object? sender, TextCompositionEventArgs e) => ShowRawText(false);
|
|
|
|
private void ShowRawText(bool raw)
|
|
{
|
|
if (composing == raw)
|
|
{
|
|
return;
|
|
}
|
|
composing = raw;
|
|
SqlBox.Foreground = raw
|
|
? (System.Windows.Media.Brush)FindResource("B.Ink")
|
|
: System.Windows.Media.Brushes.Transparent;
|
|
Highlight.Visibility = raw ? Visibility.Hidden : Visibility.Visible;
|
|
}
|
|
|
|
private void OnSqlChanged(object sender, TextChangedEventArgs e)
|
|
{
|
|
Refresh();
|
|
// TextChanged 다음에 SelectionChanged 가 이어서 오는데, 그건 타이핑에 따른 커서 이동이라
|
|
// '커서만 옮겼다'로 오인해 목록을 닫으면 안 된다.
|
|
typing = true;
|
|
try
|
|
{
|
|
UpdateCompletion();
|
|
}
|
|
finally
|
|
{
|
|
Dispatcher.BeginInvoke(new Action(() => typing = false),
|
|
System.Windows.Threading.DispatcherPriority.Input);
|
|
}
|
|
}
|
|
|
|
private void Refresh()
|
|
{
|
|
var text = SqlBox.Text;
|
|
LengthText.Text = $"{text.Length:N0}자 · {CountLines(text)}줄";
|
|
UpdateCaretText();
|
|
UpdateProblems(text);
|
|
}
|
|
|
|
/// <summary>줄·열 표시 — 오류 메시지의 위치를 찾아갈 때 쓴다</summary>
|
|
private void UpdateCaretText()
|
|
{
|
|
var caret = Math.Clamp(SqlBox.CaretIndex, 0, SqlBox.Text.Length);
|
|
var line = SqlBox.GetLineIndexFromCharacterIndex(caret);
|
|
var column = line >= 0 ? caret - SqlBox.GetCharacterIndexFromLineIndex(line) : 0;
|
|
CaretText.Text = $"줄 {Math.Max(0, line) + 1}, 열 {Math.Max(0, column) + 1}";
|
|
}
|
|
|
|
/// <summary>포맷 — 예약어 대문자, 절마다 줄바꿈. 문자열·치환 변수는 건드리지 않는다</summary>
|
|
private void OnFormat(object sender, RoutedEventArgs e)
|
|
{
|
|
if (!isSql)
|
|
{
|
|
return;
|
|
}
|
|
var formatted = SqlFormatter.Format(SqlBox.Text);
|
|
if (!string.Equals(formatted, SqlBox.Text, StringComparison.Ordinal))
|
|
{
|
|
SqlBox.Text = formatted;
|
|
SqlBox.CaretIndex = SqlBox.Text.Length;
|
|
}
|
|
}
|
|
|
|
private void OnToggleComment(object sender, RoutedEventArgs e)
|
|
{
|
|
SqlBox.Focus();
|
|
ToggleComment();
|
|
}
|
|
|
|
private void OnInvokeCompletion(object sender, RoutedEventArgs e)
|
|
{
|
|
SqlBox.Focus();
|
|
UpdateCompletion();
|
|
}
|
|
|
|
/// <summary>전체 지우기 — 되돌릴 수 있게 TextBox 실행취소에 남긴다</summary>
|
|
private void OnClearAll(object sender, RoutedEventArgs e)
|
|
{
|
|
if (SqlBox.Text.Length == 0)
|
|
{
|
|
return;
|
|
}
|
|
SqlBox.SelectAll();
|
|
SqlBox.SelectedText = string.Empty;
|
|
SqlBox.Focus();
|
|
}
|
|
|
|
private static int CountLines(string text)
|
|
{
|
|
var lines = 1;
|
|
foreach (var ch in text)
|
|
{
|
|
if (ch == '\n')
|
|
{
|
|
lines++;
|
|
}
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 저장 전에 눈에 띄어야 하는 것만 알린다 — 닫히지 않은 조각과 축약된 치환 변수.
|
|
///
|
|
/// 런타임은 치환에 실패해도 빈 catch 로 삼켜 화면에 아무 표시가 없다.
|
|
/// 여기서 잡지 못하면 배포된 뒤 임상 화면에서 빈칸으로만 드러난다.
|
|
/// </summary>
|
|
private void UpdateProblems(string text)
|
|
{
|
|
if (!isSql)
|
|
{
|
|
ProblemText.Text = string.Empty;
|
|
return;
|
|
}
|
|
|
|
var problems = new List<string>();
|
|
if (SqlTokenizer.Tokenize(text).Any(t => t.Kind == SqlTokenKind.Broken))
|
|
{
|
|
problems.Add("닫히지 않은 따옴표 또는 << >>");
|
|
}
|
|
|
|
// 목록에 없다고 전부 틀린 것은 아니다 — DataRow 컬럼은 무한히 많다.
|
|
// 런타임이 실제로 해석할 수 있는 '형태'인지로 판정한다(접두어 + clsMDataTable 정규식).
|
|
var bad = SqlTokenizer.VariablesIn(text)
|
|
.Where(v => !LegacyQueryVariableCatalog.IsResolvable(v))
|
|
.Distinct(StringComparer.Ordinal)
|
|
.ToList();
|
|
if (bad.Count > 0)
|
|
{
|
|
problems.Add($"치환되지 않는 변수 {bad.Count}건: {bad[0]}");
|
|
}
|
|
|
|
// 컬럼명을 안 채운 채 저장하면 그 칸은 런타임에서 조용히 빈값이 된다
|
|
if (text.Contains("컬럼명\"", StringComparison.Ordinal))
|
|
{
|
|
problems.Add("컬럼명 자리를 채우세요");
|
|
}
|
|
|
|
ProblemText.Text = problems.Count == 0 ? string.Empty : string.Join(" · ", problems);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 편집 보조 — Tab 들여쓰기, Enter 자동 들여쓰기, Ctrl+/ 주석 토글.
|
|
/// TextBox 가 기본으로 주지 않는 것 중 SQL 을 칠 때 손이 가장 많이 가는 것들만 넣었다.
|
|
/// </summary>
|
|
private void OnSqlKeyDown(object sender, KeyEventArgs e)
|
|
{
|
|
var ctrl = (Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control;
|
|
var shift = (Keyboard.Modifiers & ModifierKeys.Shift) == ModifierKeys.Shift;
|
|
|
|
// 목록이 떠 있으면 방향키·Enter·Tab 은 목록 것이다.
|
|
// 단 Ctrl+Enter 는 검증 실행이라 목록이 가로채면 안 된다 — 그건 KeyBinding 이 받는다.
|
|
if (!(ctrl && e.Key is Key.Return or Key.Enter) && HandleCompletionKey(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);
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
if (ctrl && (e.Key == Key.Oem2 || e.Key == Key.Divide))
|
|
{
|
|
ToggleComment();
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
if (e.Key == Key.Return && !ctrl)
|
|
{
|
|
AutoIndentNewLine();
|
|
e.Handled = true;
|
|
}
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
var text = SqlBox.Text;
|
|
var start = index;
|
|
while (start > 0 && text[start - 1] != '\n')
|
|
{
|
|
start--;
|
|
}
|
|
return start;
|
|
}
|
|
|
|
/// <summary>Enter — 앞 줄의 들여쓰기를 그대로 물려준다</summary>
|
|
private void AutoIndentNewLine()
|
|
{
|
|
var text = SqlBox.Text;
|
|
var caret = SqlBox.SelectionStart;
|
|
var lineStart = LineStartOf(caret);
|
|
var indent = 0;
|
|
while (lineStart + indent < text.Length && lineStart + indent < caret
|
|
&& (text[lineStart + indent] == ' ' || text[lineStart + indent] == '\t'))
|
|
{
|
|
indent++;
|
|
}
|
|
var insert = "\n" + text.Substring(lineStart, indent);
|
|
var selectionLength = SqlBox.SelectionLength;
|
|
SqlBox.Text = text.Remove(caret, selectionLength).Insert(caret, insert);
|
|
SqlBox.CaretIndex = caret + insert.Length;
|
|
}
|
|
|
|
/// <summary>Tab / Shift+Tab — 선택한 줄 전체를 들여쓰거나 내어쓴다</summary>
|
|
private void IndentSelection(bool outdent)
|
|
{
|
|
const string Unit = " ";
|
|
var text = SqlBox.Text;
|
|
var start = SqlBox.SelectionStart;
|
|
var length = SqlBox.SelectionLength;
|
|
|
|
// 선택이 없으면 커서 자리에 공백만 넣는다(내어쓰기는 줄 단위로 동작)
|
|
if (length == 0 && !outdent)
|
|
{
|
|
SqlBox.Text = text.Insert(start, Unit);
|
|
SqlBox.CaretIndex = start + Unit.Length;
|
|
return;
|
|
}
|
|
|
|
var blockStart = LineStartOf(start);
|
|
var blockEnd = start + length;
|
|
while (blockEnd < text.Length && text[blockEnd] != '\n')
|
|
{
|
|
blockEnd++;
|
|
}
|
|
|
|
var lines = text[blockStart..blockEnd].Split('\n');
|
|
for (var i = 0; i < lines.Length; i++)
|
|
{
|
|
if (outdent)
|
|
{
|
|
if (lines[i].StartsWith(Unit, StringComparison.Ordinal))
|
|
{
|
|
lines[i] = lines[i][Unit.Length..];
|
|
}
|
|
else
|
|
{
|
|
lines[i] = lines[i].TrimStart(' ', '\t');
|
|
}
|
|
}
|
|
else
|
|
{
|
|
lines[i] = Unit + lines[i];
|
|
}
|
|
}
|
|
|
|
var replaced = string.Join('\n', lines);
|
|
SqlBox.Text = text[..blockStart] + replaced + text[blockEnd..];
|
|
SqlBox.SelectionStart = blockStart;
|
|
SqlBox.SelectionLength = replaced.Length;
|
|
}
|
|
|
|
/// <summary>Ctrl+/ — 선택한 줄에 -- 를 붙이거나 뗀다</summary>
|
|
private void ToggleComment()
|
|
{
|
|
const string Mark = "-- ";
|
|
var text = SqlBox.Text;
|
|
var start = SqlBox.SelectionStart;
|
|
var blockStart = LineStartOf(start);
|
|
var blockEnd = start + SqlBox.SelectionLength;
|
|
while (blockEnd < text.Length && text[blockEnd] != '\n')
|
|
{
|
|
blockEnd++;
|
|
}
|
|
|
|
var lines = text[blockStart..blockEnd].Split('\n');
|
|
// 한 줄이라도 주석이 아니면 전부 붙인다 — 섞인 상태에서 토글이 예측 가능해진다
|
|
var allCommented = lines.All(l => l.TrimStart().StartsWith("--", StringComparison.Ordinal));
|
|
for (var i = 0; i < lines.Length; i++)
|
|
{
|
|
if (allCommented)
|
|
{
|
|
var trimmed = lines[i].TrimStart();
|
|
var pad = lines[i][..(lines[i].Length - trimmed.Length)];
|
|
trimmed = trimmed[2..];
|
|
if (trimmed.StartsWith(' '))
|
|
{
|
|
trimmed = trimmed[1..];
|
|
}
|
|
lines[i] = pad + trimmed;
|
|
}
|
|
else
|
|
{
|
|
lines[i] = Mark + lines[i];
|
|
}
|
|
}
|
|
|
|
var replaced = string.Join('\n', lines);
|
|
SqlBox.Text = text[..blockStart] + replaced + text[blockEnd..];
|
|
SqlBox.SelectionStart = blockStart;
|
|
SqlBox.SelectionLength = replaced.Length;
|
|
}
|
|
#endregion
|
|
|
|
#region Methods - Variables
|
|
private void OnVariableSearchChanged(object sender, TextChangedEventArgs e)
|
|
{
|
|
SearchPlaceholder.Visibility = VariableSearch.Text.Length == 0 ? Visibility.Visible : Visibility.Collapsed;
|
|
ApplyVariableFilter(VariableSearch.Text);
|
|
}
|
|
|
|
/// <summary>카탈로그 + 이 서식의 컨트롤 — 컨트롤은 문서마다 달라 여기서 합친다</summary>
|
|
private IReadOnlyList<QueryVariable> AllVariables()
|
|
=> LegacyQueryVariableCatalog.All
|
|
.Concat(LegacyQueryVariableCatalog.ControlVariables(controlNames))
|
|
.ToList();
|
|
|
|
/// <summary>상단 칩을 다시 만든다 — 갈래별 개수는 검색과 무관하게 전체 기준으로 둔다</summary>
|
|
private void RebuildBucketChips()
|
|
{
|
|
var all = AllVariables();
|
|
var chips = new List<object>
|
|
{
|
|
new { Name = "전체", Count = all.Count, IsActive = activeBucket.Length == 0 },
|
|
};
|
|
// 순서는 개수가 아니라 고정이다 — 컨트롤 수는 서식마다 달라서, 개수순으로 두면
|
|
// 서식을 바꿀 때마다 칩이 자리를 바꿔 손이 기억하지 못한다.
|
|
var order = new[] { "환자", "서식", "작업자", "컨트롤" };
|
|
var byBucket = all.GroupBy(v => v.Bucket).ToDictionary(g => g.Key, g => g.Count());
|
|
foreach (var name in order)
|
|
{
|
|
if (byBucket.TryGetValue(name, out var count) && count > 0)
|
|
{
|
|
chips.Add(new { Name = name, Count = count, IsActive = activeBucket == name });
|
|
}
|
|
}
|
|
BucketChips.ItemsSource = chips;
|
|
}
|
|
|
|
private void OnBucketClick(object sender, RoutedEventArgs e)
|
|
{
|
|
if (sender is not System.Windows.Controls.Primitives.ToggleButton { Tag: string name })
|
|
{
|
|
return;
|
|
}
|
|
// 같은 칩을 다시 누르면 해제 — '전체'와 같은 뜻이라 따로 누를 필요가 없다
|
|
activeBucket = name == "전체" || activeBucket == name ? string.Empty : name;
|
|
RebuildBucketChips();
|
|
ApplyVariableFilter(VariableSearch.Text);
|
|
}
|
|
|
|
/// <summary>이름·설명·초성으로 좁히고 갈래 칩을 함께 적용한다</summary>
|
|
private void ApplyVariableFilter(string keyword)
|
|
{
|
|
var all = AllVariables();
|
|
var tokens = keyword.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
|
var matched = all
|
|
.Where(v => activeBucket.Length == 0 || v.Bucket == activeBucket)
|
|
.Where(v => tokens.Length == 0 || tokens.All(t =>
|
|
TagSearch.Matches(v.Description, t) || TagSearch.Matches(v.Token, t)))
|
|
.ToList();
|
|
|
|
var view = new CollectionViewSource { Source = matched };
|
|
view.GroupDescriptions.Add(new PropertyGroupDescription(nameof(QueryVariable.Group)));
|
|
VariableList.ItemsSource = view.View;
|
|
|
|
if (matched.Count > 0)
|
|
{
|
|
VariableList.SelectedIndex = 0;
|
|
}
|
|
}
|
|
|
|
private void OnVariableListKeyDown(object sender, KeyEventArgs e)
|
|
{
|
|
if (e.Key == Key.Return)
|
|
{
|
|
InsertSelectedVariable();
|
|
e.Handled = true;
|
|
}
|
|
}
|
|
|
|
private void OnInsertVariable(object sender, MouseButtonEventArgs e) => InsertSelectedVariable();
|
|
|
|
private void InsertSelectedVariable()
|
|
{
|
|
if (VariableList.SelectedItem is QueryVariable variable)
|
|
{
|
|
InsertVariable(variable);
|
|
}
|
|
}
|
|
|
|
private void InsertVariable(QueryVariable variable)
|
|
{
|
|
var caret = SqlBox.CaretIndex;
|
|
SqlBox.Text = SqlBox.Text.Insert(caret, variable.Token);
|
|
SqlBox.Focus();
|
|
|
|
// DataRow 형태는 컬럼명을 채워야 완성된다 — 그 자리를 선택해 두면 바로 덮어쓸 수 있다
|
|
const string Placeholder = "컬럼명";
|
|
var placeholderAt = variable.NeedsColumn
|
|
? variable.Token.IndexOf(Placeholder, StringComparison.Ordinal)
|
|
: -1;
|
|
if (placeholderAt >= 0)
|
|
{
|
|
SqlBox.Select(caret + placeholderAt, Placeholder.Length);
|
|
}
|
|
else
|
|
{
|
|
SqlBox.CaretIndex = caret + variable.Token.Length;
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
#region Methods - Confirm
|
|
private void OnConfirm(object sender, RoutedEventArgs e)
|
|
{
|
|
QueryText = SqlBox.Text;
|
|
DialogResult = true;
|
|
}
|
|
#endregion
|
|
}
|