검증 실행이 치환 변수를 지우던 것을 고친다 — 그 쿼리들이 통과할 수 없었다

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:
Msystech
2026-08-19 10:08:07 +09:00
co-authored by Claude Opus 5
parent 3ef1937902
commit 8964c662f3
5 changed files with 214 additions and 16 deletions
+20 -1
View File
@@ -100,7 +100,19 @@ public static class QuerySubstitution
#region Methods #region Methods
/// <summary>치환한다 — SQL 은 실행할 수 있는 문장이 되고, 내역은 왜 그렇게 됐는지 말한다</summary> /// <summary>치환한다 — SQL 은 실행할 수 있는 문장이 되고, 내역은 왜 그렇게 됐는지 말한다</summary>
public static QuerySubstitutionResult Apply(string sql, IQueryVariableSource source) /// <param name="emptyPlaceholder">
/// 값이 빈 자리에 대신 넣을 것. 기본은 null 이고 그때는 <b>레거시와 같이 빈 문자열</b>이 된다.
///
/// <b>구문 검사용 탈출구다.</b> 빈 문자열로 치우면 <c>WHERE X = </c> 가 되어
/// ORA-00936(누락된 표현식)이 난다 — 즉 <b>치환 변수를 값 자리에 쓴 쿼리는 검증 실행을
/// 통과할 수가 없다.</b> 정작 그 기능이 존재하는 이유가 그런 쿼리들이다.
/// <c>"NULL"</c> 을 주면 따옴표 안('<c>'&lt;&lt;…&gt;&gt;'</c>')이든 밖이든 문법이 성립하고
/// 0행이 나오므로 <b>테이블·컬럼 오타와 구문 오류</b>만 걸러 낼 수 있다.
///
/// 실행 경로(미리보기)는 이것을 쓰지 않는다 — 거기서는 레거시와 같은 SQL 이어야 한다.
/// </param>
public static QuerySubstitutionResult Apply(string sql, IQueryVariableSource source,
string? emptyPlaceholder = null)
{ {
var text = sql ?? string.Empty; var text = sql ?? string.Empty;
var hits = new List<QuerySubstitutionHit>(); var hits = new List<QuerySubstitutionHit>();
@@ -127,7 +139,14 @@ public static class QuerySubstitution
var token = text[open..(close + 2)]; var token = text[open..(close + 2)];
var inner = text[(open + 2)..close]; var inner = text[(open + 2)..close];
var (value, kind) = Resolve(inner, source); var (value, kind) = Resolve(inner, source);
// 내역은 <b>실제 판정</b>을 그대로 남긴다 — 자리표시로 채운 것을 "값이 있었다"로 적으면
// 화면이 "값이 나왔다"고 말하게 되고, 그게 이 기능에서 가장 위험한 거짓말이다.
hits.Add(new QuerySubstitutionHit(token, value, kind)); hits.Add(new QuerySubstitutionHit(token, value, kind));
if (value.Length == 0 && emptyPlaceholder is { Length: > 0 } placeholder
&& kind != QuerySubstitutionKind.NotAVariable)
{
value = placeholder;
}
// 변수가 아니면 토큰을 그대로 남긴다 — 레거시가 그렇고, 남은 토큰은 ORA 구문오류가 된다. // 변수가 아니면 토큰을 그대로 남긴다 — 레거시가 그렇고, 남은 토큰은 ORA 구문오류가 된다.
// 조용히 지우면 <b>조건이 사라진 SQL</b>이 돌아 엉뚱한 행이 나온다. 그게 더 위험하다. // 조용히 지우면 <b>조건이 사라진 SQL</b>이 돌아 엉뚱한 행이 나온다. 그게 더 위험하다.
output.Append(kind == QuerySubstitutionKind.NotAVariable ? token : value); output.Append(kind == QuerySubstitutionKind.NotAVariable ? token : value);
@@ -778,6 +778,39 @@ public static class DbSmoke
Check("결과 표에 열이 생긴다", grid.Columns.Count >= 2, $"열 {grid.Columns.Count}개"); Check("결과 표에 열이 생긴다", grid.Columns.Count >= 2, $"열 {grid.Columns.Count}개");
Check("결과 표에 행이 채워진다", grid.Items.Count >= 1, $"행 {grid.Items.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 가 잡히지 않으므로(실제 키 상태를 읽는다) // 합성 키 이벤트로는 Keyboard.Modifiers 가 잡히지 않으므로(실제 키 상태를 읽는다)
// 선언된 KeyBinding 을 찾아 그 명령을 직접 실행해 확인한다. // 선언된 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) internal void UsePatient(SheetMe.Data.Stores.PatientContext? picked, string label)
{ {
patient = picked; patient = picked;
// 세션에도 올린다 — 쿼리 편집기의 '검증 실행'이 같은 환자로 돌아야 한다.
// 창마다 따로 들고 있으면 같은 서식을 두 화면에서 서로 다른 환자로 보게 된다.
PatientSession.Set(picked, label);
tags = picked is null ? session : new PatientTagResolver(picked, session); tags = picked is null ? session : new PatientTagResolver(picked, session);
// 러너를 <b>새로</b> 만든다. 같은 러너를 재사용하면 옛 환자의 조회 결과가 캐시에 남아 // 러너를 <b>새로</b> 만든다. 같은 러너를 재사용하면 옛 환자의 조회 결과가 캐시에 남아
// 환자를 바꿨는데 표는 그대로가 된다 — 태그만 바뀌고 표는 안 바뀌면 아무도 눈치채지 못한다. // 환자를 바꿨는데 표는 그대로가 된다 — 태그만 바뀌고 표는 안 바뀌면 아무도 눈치채지 못한다.
@@ -25,6 +25,25 @@ public partial class QueryEditorWindow : Window
#region Member Fields #region Member Fields
private readonly bool isSql; 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> /// <summary>스키마·검증 실행 원천 — DB 가 없으면 null 이고 그 기능만 꺼진다</summary>
private readonly SheetMe.Data.Stores.OracleQueryWorkbench? workbench; private readonly SheetMe.Data.Stores.OracleQueryWorkbench? workbench;
@@ -465,10 +484,18 @@ public partial class QueryEditorWindow : Window
#region Methods - Trial #region Methods - Trial
/// <summary> /// <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> /// </summary>
private void OnTrial(object sender, RoutedEventArgs e) private void OnTrial(object sender, RoutedEventArgs e)
{ {
@@ -476,12 +503,14 @@ public partial class QueryEditorWindow : Window
{ {
return; return;
} }
var probe = SqlBox.Text; var variables = Services.PatientSession.Variables();
foreach (var token in SqlTokenizer.VariablesIn(probe).Distinct(StringComparer.Ordinal)) var converted = SheetMe.Core.Catalog.QuerySubstitution.Apply(
{ SqlBox.Text, variables ?? UnknownVariables.Instance,
// 런타임도 값이 없으면 빈 문자열로 치환한다(clsMDataTable.vb:67-71) — 같은 모양으로 맞춘다 // 환자가 있어도 자리표시는 준다 — 아직 안 옮긴 변수(Age·Sex)가 섞여 있으면
probe = probe.Replace(token, string.Empty, StringComparison.Ordinal); // 그것만 빈 값이 되어 같은 ORA-00936 이 난다.
} emptyPlaceholder: "NULL");
var probe = converted.Sql;
lastTrialSql = probe;
Mouse.OverrideCursor = Cursors.Wait; Mouse.OverrideCursor = Cursors.Wait;
SheetMe.Data.Stores.QueryTrialResult result; SheetMe.Data.Stores.QueryTrialResult result;
@@ -495,26 +524,85 @@ public partial class QueryEditorWindow : Window
} }
TrialPane.Visibility = Visibility.Visible; 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) if (result.Ok)
{ {
TrialSummary.Foreground = (System.Windows.Media.Brush)FindResource("B.Ink"); TrialSummary.Foreground = (System.Windows.Media.Brush)FindResource(
// 행이 0인 것과 실패는 다르다 — 구문은 맞는데 조건에 걸리는 데이터가 없을 뿐이다. notes.Count > 0 ? "B.Danger" : "B.Ink");
// 치환 변수를 빈 값으로 바꿔 실행하므로 조건이 빡빡하면 0행이 정상이다. var head = result.Rows.Count == 0
TrialSummary.Text = result.Rows.Count == 0 ? result.Summary + (variables is null
? result.Summary + " — 구문은 정상입니다. 치환 변수를 빈 값으로 실행하므로 조건에 걸리는 행이 없을 수 있습니다." ? " — 구문은 정상입니다(변수가 NULL 이라 조건에 걸리는 행이 없습니다)."
: " — 구문은 정상이고, 이 환자에게 해당하는 행이 없습니다.")
: result.Summary; : result.Summary;
TrialSummary.Text = string.Join(" · ", new[] { head, basis }.Concat(notes));
TrialGrid.ItemsSource = ToGridRows(result); TrialGrid.ItemsSource = ToGridRows(result);
TrialGrid.Visibility = result.Rows.Count > 0 ? Visibility.Visible : Visibility.Collapsed; TrialGrid.Visibility = result.Rows.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
} }
else else
{ {
TrialSummary.Foreground = (System.Windows.Media.Brush)FindResource("B.Danger"); 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.ItemsSource = null;
TrialGrid.Visibility = Visibility.Collapsed; 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> /// <summary>
/// 결과를 DataGrid 가 열까지 만들어 주는 형태로 옮긴다. /// 결과를 DataGrid 가 열까지 만들어 주는 형태로 옮긴다.
/// ///