검증 실행이 치환 변수를 지우던 것을 고친다 — 그 쿼리들이 통과할 수 없었다
select * from P_PATINF where PATCHTNUM = <<…bzPatientInfo.ChtNum>> → ORA-00936: 누락된 표현식 원인은 오라클이 아니라 우리 쪽이었다. 검증 실행이 <<…>> 를 전부 빈 문자열로 지운 뒤 보냈다 — "WHERE PATCHTNUM = " 가 된다. 런타임이 값 없을 때 그렇게 한다는 이유였지만(clsMDataTable.vb:67-71), 그 결과 <b>치환 변수를 값 자리에 쓴 쿼리는 검증 실행을 통과할 수가 없었다.</b> 정작 이 기능이 존재하는 이유가 그런 쿼리들이다. ## 두 갈래로 나눴다 환자를 골랐으면 <b>실제 값</b>으로 치환한다 — 구문뿐 아니라 값까지 확인된다. 안 골랐으면 빈 자리를 NULL 로 채운다. 따옴표 안이든 밖이든 문법이 성립하고 0행이 나오므로 테이블·컬럼 오타와 구문 오류는 그대로 걸린다. 자리표시는 QuerySubstitution.Apply 의 선택 인자다(기본은 레거시와 같이 빈 문자열). 실행 경로(미리보기)는 쓰지 않는다 — 거기서는 레거시와 같은 SQL 이어야 한다. 내역(Hits)에는 실제 판정을 남긴다. 자리표시로 채운 것을 "값이 있었다"로 적으면 화면이 "값이 나왔다"고 말하게 되고, 그게 이 기능에서 가장 위험한 거짓말이다. ## 환자를 세션으로 올렸다 환자는 미리보기 창에서 고르는데 검증 실행도 같은 환자로 돌아야 한다. 창마다 따로 들고 있으면 같은 서식을 두 화면에서 서로 다른 환자로 보게 되고 그건 화면으로 구분할 수 없다. PatientSession 하나만 둔다(디스크에 쓰지 않는다). ## 무엇으로 돌렸는지 말한다 요약 첫 줄이 "환자 …의 실제 값으로 실행" 또는 "환자를 고르지 않아 변수를 NULL 로 두고 구문만 검사"다. 이 줄이 없으면 0행을 보고 "이 환자에게 자료가 없다"로 잘못 읽는다. 따옴표 밖에 놓인 변수도 짚는다 — 값이 원문 그대로 박히므로(clsMDataTable.vb:113) 문자 컬럼과 비교하려면 서식 쪽에서 '<<…>>' 로 감싸야 한다. 이 배선의 가장 흔한 실수다. 질문에 딸려 온 쿼리가 정확히 그 경우였다. ## 대조군이 함정을 잡았다 '따옴표로 감싸면 경고가 없다'가 처음에 통과했는데, 경고가 <b>아예 안 떠서</b> 양쪽이 다 "없음"이었다. 원인은 내 가드다 — 토큰이 문장 끝에 오면 건너뛰게 써 놨고, WHERE 절 마지막이 가장 흔한 자리다. 경계를 '따옴표 없음'으로 보도록 고쳤다. 대조군 없이 긍정 판정만 있었으면 이 기능은 조용히 아무것도 안 하고 있었을 것이다. ## 게이트 - dotnet test 323/323 - --query-popup 실패 0 (⑨-b 5건 신규: 구문 검사가 돌고 · 자리가 비지 않고 · 근거를 밝히고 · 따옴표 밖을 짚고 · 감싸면 안 짚는다) - --edit-smoke 실패 0 · --dialog-shots FAIL 0 - --db-patient ①~⑱ 전건 통과 - --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3ef1937902
commit
8964c662f3
@@ -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 치환 변수를 값 자리에 쓴 쿼리가 검증 실행을 <b>통과할 수 있는가</b>.
|
||||
// 전에는 변수를 빈 문자열로 지워 "WHERE X = " 가 되었고 ORA-00936 이 났다 —
|
||||
// 즉 이 기능이 존재하는 이유인 쿼리들이 하나도 검사되지 못했다.
|
||||
var summary = (System.Windows.Controls.TextBlock)window.FindName("TrialSummary")!;
|
||||
Type("select * from P_PatInf"
|
||||
+ " where PatChtNum = <<M.CMM.HISOperatingInfo.bzPatientInfo.ChtNum>>");
|
||||
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 = '<<M.CMM.HISOperatingInfo.bzPatientInfo.ChtNum>>'");
|
||||
window.RunTrialForSmoke();
|
||||
DrainDispatcher();
|
||||
Check("⑨-b (대조군) 따옴표로 감싸면 그 경고가 없다",
|
||||
!summary.Text.Contains("따옴표", StringComparison.Ordinal), summary.Text);
|
||||
|
||||
// ⑩ 검증 실행 단축키가 실제로 배선돼 있는가.
|
||||
// 합성 키 이벤트로는 Keyboard.Modifiers 가 잡히지 않으므로(실제 키 상태를 읽는다)
|
||||
// 선언된 KeyBinding 을 찾아 그 명령을 직접 실행해 확인한다.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using SheetMe.Core.Catalog;
|
||||
using SheetMe.Data.Stores;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 지금 고른 환자 — 앱 전체가 하나를 공유한다.
|
||||
///
|
||||
/// <b>왜 전역인가.</b> 환자는 미리보기 창에서 고르는데, 그 값이 필요한 곳은 거기만이 아니다 —
|
||||
/// 쿼리 편집기의 '검증 실행'도 같은 환자로 돌려야 의미가 있다.
|
||||
/// 창마다 따로 들고 있으면 <b>같은 서식을 두 화면에서 서로 다른 환자로</b> 보게 되고,
|
||||
/// 그건 화면으로 구분할 수 없다.
|
||||
///
|
||||
/// <b>하나만 둔다.</b> 여러 개를 허용하면 어느 것이 지금 것인지 알 수 없고,
|
||||
/// 환자 정보가 걸린 문제에서 그건 개인정보 사고가 된다.
|
||||
///
|
||||
/// 앱이 닫힐 때까지 메모리에 남는다. 디스크에 쓰지 않는다 —
|
||||
/// 환자 문맥은 로그에도 안 남기기로 한 값이다(<see cref="PatientPreviewPolicy.AuditLine"/>).
|
||||
/// </summary>
|
||||
public static class PatientSession
|
||||
{
|
||||
#region Member Fields
|
||||
/// <summary>환자가 바뀌었다 — 열려 있는 화면들이 다시 그려야 한다</summary>
|
||||
public static event EventHandler? Changed;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>지금 고른 환자의 문맥 — 안 골랐으면 null</summary>
|
||||
public static PatientContext? Current { get; private set; }
|
||||
|
||||
/// <summary>사람에게 보여 줄 한 줄 — 창 머리에 그대로 쓴다</summary>
|
||||
public static string Label { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>고른 환자가 있는가</summary>
|
||||
public static bool HasPatient => Current is not null;
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>환자를 붙이거나(문맥 전달) 뗀다(null 전달)</summary>
|
||||
public static void Set(PatientContext? context, string label)
|
||||
{
|
||||
Current = context;
|
||||
Label = context is null ? string.Empty : label;
|
||||
Changed?.Invoke(null, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 치환 변수원 — 환자가 없으면 null 이고, 호출부는 그때 자리표시로 구문만 검사해야 한다.
|
||||
/// </summary>
|
||||
public static IQueryVariableSource? Variables()
|
||||
=> Current is { } context
|
||||
? new PatientQueryVariableSource(context, UserSession.Current.UidCod)
|
||||
: null;
|
||||
#endregion
|
||||
}
|
||||
@@ -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);
|
||||
// 러너를 <b>새로</b> 만든다. 같은 러너를 재사용하면 옛 환자의 조회 결과가 캐시에 남아
|
||||
// 환자를 바꿨는데 표는 그대로가 된다 — 태그만 바뀌고 표는 안 바뀌면 아무도 눈치채지 못한다.
|
||||
|
||||
@@ -25,6 +25,25 @@ 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;
|
||||
|
||||
@@ -465,10 +484,18 @@ public partial class QueryEditorWindow : Window
|
||||
|
||||
#region Methods - Trial
|
||||
/// <summary>
|
||||
/// 검증 실행 — 치환 변수를 자리표시로 바꾼 뒤 실제로 돌려 본다.
|
||||
/// 검증 실행 — 치환을 <b>실제로</b> 한 뒤 돌려 본다.
|
||||
///
|
||||
/// 디자인 시점에는 환자·서식 문맥이 없어 진짜 값을 넣을 수 없다.
|
||||
/// 목적은 데이터 확인이 아니라 <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)
|
||||
{
|
||||
@@ -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<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("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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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 가 열까지 만들어 주는 형태로 옮긴다.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user