diff --git a/src/SheetMe.Core/Catalog/QuerySubstitution.cs b/src/SheetMe.Core/Catalog/QuerySubstitution.cs index 63a0985..d7eaf64 100644 --- a/src/SheetMe.Core/Catalog/QuerySubstitution.cs +++ b/src/SheetMe.Core/Catalog/QuerySubstitution.cs @@ -100,7 +100,19 @@ public static class QuerySubstitution #region Methods /// 치환한다 — SQL 은 실행할 수 있는 문장이 되고, 내역은 왜 그렇게 됐는지 말한다 - public static QuerySubstitutionResult Apply(string sql, IQueryVariableSource source) + /// + /// 값이 빈 자리에 대신 넣을 것. 기본은 null 이고 그때는 레거시와 같이 빈 문자열이 된다. + /// + /// 구문 검사용 탈출구다. 빈 문자열로 치우면 WHERE X = 가 되어 + /// ORA-00936(누락된 표현식)이 난다 — 즉 치환 변수를 값 자리에 쓴 쿼리는 검증 실행을 + /// 통과할 수가 없다. 정작 그 기능이 존재하는 이유가 그런 쿼리들이다. + /// "NULL" 을 주면 따옴표 안(''<<…>>'')이든 밖이든 문법이 성립하고 + /// 0행이 나오므로 테이블·컬럼 오타와 구문 오류만 걸러 낼 수 있다. + /// + /// 실행 경로(미리보기)는 이것을 쓰지 않는다 — 거기서는 레거시와 같은 SQL 이어야 한다. + /// + public static QuerySubstitutionResult Apply(string sql, IQueryVariableSource source, + string? emptyPlaceholder = null) { var text = sql ?? string.Empty; var hits = new List(); @@ -127,7 +139,14 @@ public static class QuerySubstitution var token = text[open..(close + 2)]; var inner = text[(open + 2)..close]; var (value, kind) = Resolve(inner, source); + // 내역은 실제 판정을 그대로 남긴다 — 자리표시로 채운 것을 "값이 있었다"로 적으면 + // 화면이 "값이 나왔다"고 말하게 되고, 그게 이 기능에서 가장 위험한 거짓말이다. hits.Add(new QuerySubstitutionHit(token, value, kind)); + if (value.Length == 0 && emptyPlaceholder is { Length: > 0 } placeholder + && kind != QuerySubstitutionKind.NotAVariable) + { + value = placeholder; + } // 변수가 아니면 토큰을 그대로 남긴다 — 레거시가 그렇고, 남은 토큰은 ORA 구문오류가 된다. // 조용히 지우면 조건이 사라진 SQL이 돌아 엉뚱한 행이 나온다. 그게 더 위험하다. output.Append(kind == QuerySubstitutionKind.NotAVariable ? token : value); diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs index 2ed562f..6bf4bfd 100644 --- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs @@ -778,6 +778,39 @@ public static class DbSmoke Check("결과 표에 열이 생긴다", grid.Columns.Count >= 2, $"열 {grid.Columns.Count}개"); Check("결과 표에 행이 채워진다", grid.Items.Count >= 1, $"행 {grid.Items.Count}개"); + // ⑨-b 치환 변수를 값 자리에 쓴 쿼리가 검증 실행을 통과할 수 있는가. + // 전에는 변수를 빈 문자열로 지워 "WHERE X = " 가 되었고 ORA-00936 이 났다 — + // 즉 이 기능이 존재하는 이유인 쿼리들이 하나도 검사되지 못했다. + var summary = (System.Windows.Controls.TextBlock)window.FindName("TrialSummary")!; + Type("select * from P_PatInf" + + " where PatChtNum = <>"); + window.RunTrialForSmoke(); + DrainDispatcher(); + Check("⑨-b 치환 변수를 값 자리에 써도 구문 검사가 돈다", + !summary.Text.Contains("ORA-00936", StringComparison.Ordinal), + summary.Text); + Check("⑨-b 변수 자리가 비지 않는다(NULL 로 채운다)", + window.LastTrialSql.Contains("NULL", StringComparison.Ordinal) + && !window.LastTrialSql.TrimEnd().EndsWith("=", StringComparison.Ordinal), + window.LastTrialSql); + // 무엇으로 돌렸는지 말해야 한다 — 0행을 보고 "이 환자에게 자료가 없다"로 잘못 읽는다 + Check("⑨-b 무엇으로 돌렸는지 요약이 밝힌다", + summary.Text.Contains("NULL", StringComparison.Ordinal) + || summary.Text.Contains("환자", StringComparison.Ordinal), + summary.Text); + // 따옴표 밖 변수는 이 배선의 가장 흔한 실수다 — 값이 원문 그대로 박히기 때문이다 + Check("⑨-b 따옴표 밖 변수를 짚어 준다", + summary.Text.Contains("따옴표", StringComparison.Ordinal), summary.Text); + + // 대조군 — 따옴표로 감싼 같은 쿼리에는 그 경고가 없어야 한다. + // 없으면 위 판정은 "항상 경고한다"와 구분되지 않는다. + Type("select * from P_PatInf" + + " where PatChtNum = '<>'"); + window.RunTrialForSmoke(); + DrainDispatcher(); + Check("⑨-b (대조군) 따옴표로 감싸면 그 경고가 없다", + !summary.Text.Contains("따옴표", StringComparison.Ordinal), summary.Text); + // ⑩ 검증 실행 단축키가 실제로 배선돼 있는가. // 합성 키 이벤트로는 Keyboard.Modifiers 가 잡히지 않으므로(실제 키 상태를 읽는다) // 선언된 KeyBinding 을 찾아 그 명령을 직접 실행해 확인한다. diff --git a/src/SheetMe.Designer/Services/PatientSession.cs b/src/SheetMe.Designer/Services/PatientSession.cs new file mode 100644 index 0000000..539ac0d --- /dev/null +++ b/src/SheetMe.Designer/Services/PatientSession.cs @@ -0,0 +1,55 @@ +using SheetMe.Core.Catalog; +using SheetMe.Data.Stores; + +namespace SheetMe.Designer.Services; + +/// +/// 지금 고른 환자 — 앱 전체가 하나를 공유한다. +/// +/// 왜 전역인가. 환자는 미리보기 창에서 고르는데, 그 값이 필요한 곳은 거기만이 아니다 — +/// 쿼리 편집기의 '검증 실행'도 같은 환자로 돌려야 의미가 있다. +/// 창마다 따로 들고 있으면 같은 서식을 두 화면에서 서로 다른 환자로 보게 되고, +/// 그건 화면으로 구분할 수 없다. +/// +/// 하나만 둔다. 여러 개를 허용하면 어느 것이 지금 것인지 알 수 없고, +/// 환자 정보가 걸린 문제에서 그건 개인정보 사고가 된다. +/// +/// 앱이 닫힐 때까지 메모리에 남는다. 디스크에 쓰지 않는다 — +/// 환자 문맥은 로그에도 안 남기기로 한 값이다(). +/// +public static class PatientSession +{ + #region Member Fields + /// 환자가 바뀌었다 — 열려 있는 화면들이 다시 그려야 한다 + public static event EventHandler? Changed; + #endregion + + #region Properties + /// 지금 고른 환자의 문맥 — 안 골랐으면 null + public static PatientContext? Current { get; private set; } + + /// 사람에게 보여 줄 한 줄 — 창 머리에 그대로 쓴다 + public static string Label { get; private set; } = string.Empty; + + /// 고른 환자가 있는가 + public static bool HasPatient => Current is not null; + #endregion + + #region Methods + /// 환자를 붙이거나(문맥 전달) 뗀다(null 전달) + public static void Set(PatientContext? context, string label) + { + Current = context; + Label = context is null ? string.Empty : label; + Changed?.Invoke(null, EventArgs.Empty); + } + + /// + /// 치환 변수원 — 환자가 없으면 null 이고, 호출부는 그때 자리표시로 구문만 검사해야 한다. + /// + public static IQueryVariableSource? Variables() + => Current is { } context + ? new PatientQueryVariableSource(context, UserSession.Current.UidCod) + : null; + #endregion +} diff --git a/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs b/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs index aeda793..f49a16d 100644 --- a/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs +++ b/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs @@ -206,6 +206,9 @@ public partial class PreviewWindow : Window internal void UsePatient(SheetMe.Data.Stores.PatientContext? picked, string label) { patient = picked; + // 세션에도 올린다 — 쿼리 편집기의 '검증 실행'이 같은 환자로 돌아야 한다. + // 창마다 따로 들고 있으면 같은 서식을 두 화면에서 서로 다른 환자로 보게 된다. + PatientSession.Set(picked, label); tags = picked is null ? session : new PatientTagResolver(picked, session); // 러너를 새로 만든다. 같은 러너를 재사용하면 옛 환자의 조회 결과가 캐시에 남아 // 환자를 바꿨는데 표는 그대로가 된다 — 태그만 바뀌고 표는 안 바뀌면 아무도 눈치채지 못한다. diff --git a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs index 25e87a0..36574d7 100644 --- a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs +++ b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs @@ -25,6 +25,25 @@ public partial class QueryEditorWindow : Window #region Member Fields private readonly bool isSql; + /// + /// 아무 변수도 모르는 원천 — 환자를 안 골랐을 때 쓴다. + /// 전부 Unknown 이 되고, 자리표시(NULL)가 채워져 구문 검사는 그대로 돌아간다. + /// + private sealed class UnknownVariables : SheetMe.Core.Catalog.IQueryVariableSource + { + public static readonly UnknownVariables Instance = new(); + + public string? Scalar(string className, string property) => null; + + public IReadOnlyDictionary? Row(string className, string property) => null; + } + + /// 마지막으로 돌린 SQL — 진단이 "무엇을 돌렸는가"를 확인할 때 쓴다 + private string lastTrialSql = string.Empty; + + /// 진단 전용 — 검증 실행이 실제로 만든 SQL + internal string LastTrialSql => lastTrialSql; + /// 스키마·검증 실행 원천 — DB 가 없으면 null 이고 그 기능만 꺼진다 private readonly SheetMe.Data.Stores.OracleQueryWorkbench? workbench; @@ -465,10 +484,18 @@ public partial class QueryEditorWindow : Window #region Methods - Trial /// - /// 검증 실행 — 치환 변수를 자리표시로 바꾼 뒤 실제로 돌려 본다. + /// 검증 실행 — 치환을 실제로 한 뒤 돌려 본다. /// - /// 디자인 시점에는 환자·서식 문맥이 없어 진짜 값을 넣을 수 없다. - /// 목적은 데이터 확인이 아니라 테이블·컬럼 오타와 구문 오류를 배포 전에 잡는 것이다. + /// 전에는 변수를 빈 문자열로 지웠다. 런타임이 값 없을 때 그렇게 한다는 이유였지만 + /// (clsMDataTable.vb:67-71), 그 결과 WHERE X = 가 되어 ORA-00936 이 났다 — + /// 즉 치환 변수를 값 자리에 쓴 쿼리는 검증 실행을 통과할 수가 없었다. + /// 정작 이 기능이 존재하는 이유가 그런 쿼리들이다. + /// + /// 이제 두 갈래다: + /// · 환자를 골랐으면() 실제 값으로 치환한다 — + /// 구문뿐 아니라 값까지 확인된다. 미리보기와 같은 환자라 결과도 같다. + /// · 안 골랐으면 빈 자리를 NULL 로 채운다. 따옴표 안이든 밖이든 문법이 성립하고 + /// 0행이 나오므로 테이블·컬럼 오타와 구문 오류는 그대로 걸린다. /// private void OnTrial(object sender, RoutedEventArgs e) { @@ -476,12 +503,14 @@ public partial class QueryEditorWindow : Window { 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); - } + 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; @@ -495,26 +524,85 @@ public partial class QueryEditorWindow : Window } TrialPane.Visibility = Visibility.Visible; + // 무엇으로 돌렸는지 먼저 말한다 — 실제 값인지 자리표시인지에 따라 결과의 의미가 완전히 다르다. + // 이 한 줄이 없으면 0행을 보고 "이 환자에게 자료가 없다"로 잘못 읽는다. + var basis = variables is not null + ? $"환자 {Services.PatientSession.Label} 의 실제 값으로 실행" + : "환자를 고르지 않아 변수를 NULL 로 두고 구문만 검사"; + var notes = new List(); + // 환자를 안 골랐으면 모든 변수가 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("B.Ink"); - // 행이 0인 것과 실패는 다르다 — 구문은 맞는데 조건에 걸리는 데이터가 없을 뿐이다. - // 치환 변수를 빈 값으로 바꿔 실행하므로 조건이 빡빡하면 0행이 정상이다. - TrialSummary.Text = result.Rows.Count == 0 - ? result.Summary + " — 구문은 정상입니다. 치환 변수를 빈 값으로 실행하므로 조건에 걸리는 행이 없을 수 있습니다." + 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 = "실패 — " + result.Error; + TrialSummary.Text = string.Join(" · ", + new[] { "실패 — " + result.Error, basis }.Concat(notes)); TrialGrid.ItemsSource = null; TrialGrid.Visibility = Visibility.Collapsed; } } + /// + /// 따옴표 밖에 놓인 치환 변수들 — 문자 컬럼과 비교할 때 값이 그대로 박혀 깨진다. + /// + /// 판정은 같은 줄에서 토큰 앞뒤에 붙은 글자만 본다. 정확한 SQL 파싱이 아니라 + /// 흔한 실수를 짚는 것이고, 애매하면 말하지 않는다 — 틀린 경고를 자주 내면 아무도 안 읽는다. + /// + private static List BareVariables(string sql) + { + var found = new List(); + foreach (var token in SqlTokenizer.VariablesIn(sql).Distinct(StringComparer.Ordinal)) + { + var at = sql.IndexOf(token, StringComparison.Ordinal); + if (at < 0) + { + continue; + } + // 경계를 '따옴표 없음'으로 본다. 처음엔 경계를 건너뛰게 썼는데, + // 그러면 WHERE 절 끝에 온 변수가 전부 빠졌다 — 가장 흔한 자리다. + 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; + } + /// /// 결과를 DataGrid 가 열까지 만들어 주는 형태로 옮긴다. ///