Files
SheetMe/tests/SheetMe.Core.Tests/QuerySubstitutionTests.cs
MsystechandClaude Opus 5 9bff3dd5f6 MDataTable 을 미리보기에서 실제로 돌린다 — 태그만 채우면 반쯤 빈 것을 못 본다
"태그와 MDataTable도 환자 선택하면 해당 정보로 조회되는지 확인가능해야" 한다는 요구.
확인해 보니 미리보기가 DataTableViewModel 을 아예 건너뛰고 있었다(PrintService:87,143).
서식의 값 상당수가 태그가 아니라 이 관으로 오는데(운영 실측 Rows 형 1,624건)
그쪽은 미리보기에 존재하지 않았다.

## 레거시 구조를 먼저 확인했다

TK_PREVIEW 는 meLoadMode 를 Runtime 으로 바꾸고 TestPatientSetting() 으로
환자 선택 창을 띄운다(frmSheetDesigner.vb:452-462). 고르는 것만으로는 화면이 안 바뀐다 —
moPatInfoBiz 는 필드에 담기기만 하고(:634-674), 서식을 다시 열어야
ucLoadSheetBase(moWrkInfoBiz, moPatInfoBiz, …, EN_LoadType.Edit) 로 로드되면서
그때 태그와 MDataTable 이 실행된다(:216). 탭마다 별도 인스턴스라
환자를 바꿔도 이미 열린 탭은 옛 환자를 계속 들고 있다.

SheetMe 는 1단계다. UsePatient 가 해석기를 갈고 즉시 다시 그린다.
창이 하나뿐이라 잔상도 없다. 이 차이는 의도한 것이다.

## 치환 엔진(QuerySubstitution)

clsMDataTable.ConvertQuery 를 옮겼다. 한 칸이라도 다르면 미리보기가 운영과
다른 SQL 을 돌린다 — "미리보기에서는 나왔는데 실제로는 안 나온다"가 되고
사람이 확인했다고 믿고 넘어가므로 미리보기가 없는 것보다 나쁘다.

처음에 갈래 하나를 반대로 만들었다. 대문자 Item 처럼 정규식이 안 맞는 경우를
"토큰이 남는다"로 단정했는데, GetPropertyInfo 는 정규식 실패에 ""를 돌려주고(:118)
호출부가 그걸 빈 문자열로 치환한다(:66-70) — 즉 지워진다.
토큰이 남는 갈래는 <b>접두어 불일치</b>뿐이다(oBaseObj 가 Nothing 이라 Replace 를 안 한다).
소스를 읽어 고쳤다. 두 갈래를 섞으면 안 되는 이유는 하나는 ORA 구문오류가 되고
다른 하나는 조건이 사라진 SQL 이 조용히 도는 것이라서다.

SQL 에서는 같아지는 것들도 사람에게는 갈라서 말한다 —
Unknown(아직 안 옮긴 속성) / Empty(값이 빔) / NotAVariable(접두어 틀림).
셋은 고칠 곳이 전부 다르다.

## 짐작하지 않는 변수원(PatientQueryVariableSource)

DataRow 접근형이 이 기능의 대부분을 실어 준다 — PatInfDR.item("아무컬럼") 은
SELECT * 결과 사전을 그대로 조회하면 되므로 <b>내가 컬럼을 알 필요가 없다.</b>
짐작할 것이 없으니 조용히 틀릴 일도 없다.
스칼라도 대부분 그 다섯 행의 컬럼 하나라, 컬럼 이름만 적고 있으면 값·없으면 모른다고
답한다. 존재 여부는 DB 가 판정한다. UDF 산출값(Age·Sex)은 넣지 않았다.

## 값이 안 나올 때를 위한 창

종이에는 "[MDataTable1.ALGYON — 조회 결과가 0행입니다]" 한 줄만 나온다.
그것으로는 쿼리가 틀렸는가·치환이 빈 값이 됐는가·이 환자에게 자료가 없는가를 못 가른다.
그래서 데이터소스 창이 <b>치환을 마친 SQL</b>을 그대로 보여 준다.
값이 안 나왔을 때 봐야 하는 것은 결과가 아니라 무엇을 물었는가다.
레거시에는 이걸 볼 수단이 없었다 — 런타임이 빈 catch 로 삼켜 "빈칸"만 남았다.

진단 화면 표본도 <b>실패 상태</b>로 찍는다. 성공 화면만 회귀 대상으로 두면
정작 사람이 오래 들여다보는 화면이 검사에서 빠진다.

## 안전

- 실행은 OracleQueryWorkbench.Trial 을 그대로 쓴다 — SELECT/WITH 문이 이미 거기 있다.
  문을 두 군데 두면 한쪽이 느슨해진다.
- 치환 값은 레거시처럼 원문 그대로 박힌다(그래야 같은 SQL 이다). 따옴표가 섞인 값은
  QuotedValues 로 드러내고 실행은 SELECT/WITH 문이 막는다.
- 데이터소스당 한 번만 실행하고 캐시한다 — 배선 40개면 왕복 40회가 된다.
- 환자를 바꾸면 러너를 새로 만든다. 재사용하면 태그만 바뀌고 표는 옛 환자가 남는데
  그건 아무도 눈치채지 못한다. 서식을 갈아탈 때도 다시 만든다(옛 서식의 쿼리를 쓰게 된다).

## 아직 안 되는 것

Select 형의 필터(DataTable.Select 메모리 문법)는 옮기지 않았다.
무시하고 행 번호만 쓰면 다른 행의 값이 조용히 찍히므로, 값을 내지 않고 사유를 말한다.
운영 다수파인 Rows 형(76%)은 된다.

## 게이트

- dotnet test 323/323 (치환 판정 10건 신규)
- --edit-smoke 실패 0 (배선 판정 7건 추가, 대조군 포함)
- --db-patient ①~⑱ 전건 통과. ⑯ 치환한 SQL 이 실제로 1행을 뽑고,
  ⑱ 대조군은 환자 없이 같은 쿼리가 "WHERE ComNum =  AND ... = ''" 로 깨진다 —
  이 대조가 없으면 ⑯ 은 "쿼리가 원래 환자와 무관했다"와 구분되지 않는다
- --dialog-shots FAIL 0 (08c-datasource-result 추가)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
- --db-smoke 1,271건 diff 0

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 09:08:40 +09:00

162 lines
7.3 KiB
C#

using SheetMe.Core.Catalog;
namespace SheetMe.Core.Tests;
/// <summary>
/// MDataTable 쿼리 치환 고정 — 레거시 <c>clsMDataTable.ConvertQuery</c> 와 같은 SQL 이 나와야 한다.
///
/// 한 칸이라도 다르면 미리보기가 운영과 <b>다른 SQL</b>을 돌린다. 그러면 "미리보기에서는
/// 나왔는데 실제로는 안 나온다"가 되고, 그건 미리보기가 없는 것보다 나쁘다 —
/// 사람이 확인했다고 믿고 넘어간다.
/// </summary>
[TestClass]
public sealed class QuerySubstitutionTests
{
#region Nested Types
private sealed class Source : IQueryVariableSource
{
public string? Scalar(string className, string property) => (className, property) switch
{
(QuerySubstitution.PatientClass, "ChtNum") => "0012345",
(QuerySubstitution.PatientClass, "ComNum") => "1566794",
(QuerySubstitution.PatientClass, "AdpDtm") => "202608181500",
(QuerySubstitution.PatientClass, "PatTyp") => "O",
// 안다, 값이 비었다
(QuerySubstitution.PatientClass, "EmgKTSGrd") => string.Empty,
(QuerySubstitution.WorkClass, "WrkUid") => "011825",
_ => null,
};
public IReadOnlyDictionary<string, string>? Row(string className, string property)
=> (className, property) switch
{
(QuerySubstitution.PatientClass, "PatInfDR") =>
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["PatNam"] = "가상환자", ["PatResNum"] = "8801011234567", ["PatMblPhn"] = string.Empty,
},
_ => null,
};
}
#endregion
#region Methods
private static QuerySubstitutionResult Run(string sql) => QuerySubstitution.Apply(sql, new Source());
[TestMethod]
public void 스칼라를_원문_그대로_박는다()
{
// 따옴표를 붙여 주지 않는다 — SQL 쪽에서 감싸야 한다(clsMDataTable.vb:113)
var r = Run("SELECT * FROM P_ComInf WHERE ComNum = <<M.CMM.HISOperatingInfo.bzPatientInfo.ComNum>>");
Assert.AreEqual("SELECT * FROM P_ComInf WHERE ComNum = 1566794", r.Sql);
Assert.AreEqual(QuerySubstitutionKind.Value, r.Hits[0].Kind);
}
[TestMethod]
public void DataRow_두_형태를_모두_받는다()
{
var withItem = Run("X <<M.CMM.HISOperatingInfo.bzPatientInfo.PatInfDR.item(\"PatNam\")>>");
Assert.AreEqual("X 가상환자", withItem.Sql);
// 괄호만 쓰는 형태도 정규식이 받는다
var bare = Run("X <<M.CMM.HISOperatingInfo.bzPatientInfo.PatInfDR(\"PatNam\")>>");
Assert.AreEqual("X 가상환자", bare.Sql);
}
[TestMethod]
public void item_은_대문자면_값이_되지_않고_지워진다()
{
// 정규식이 대소문자를 구분한다(clsMDataTable.vb:112). 이걸 관대하게 만들면
// 레거시에서 안 되는 서식이 미리보기에서만 되고, 현장에서 빈칸으로 나온다.
//
// 지워지는 것이 맞다 — GetPropertyInfo 가 정규식 실패에 ""를 돌려주고(:118)
// 호출부가 그것을 빈 문자열로 치환한다(:66-70). 접두어 불일치와는 <b>다른</b> 갈래다:
// 그쪽은 oBaseObj 가 Nothing 이라 Replace 를 아예 하지 않아 토큰이 남는다.
var r = Run("X <<M.CMM.HISOperatingInfo.bzPatientInfo.PatInfDR.Item(\"PatNam\")>>");
Assert.AreEqual("X ", r.Sql);
Assert.AreEqual(QuerySubstitutionKind.Unknown, r.Hits[0].Kind,
"SQL 에서는 사라지지만 사람에게는 '규약 밖 경로'라고 말해야 한다");
Assert.AreEqual(1, r.UnknownTokens.Count);
}
[TestMethod]
public void 축약형_접두어는_토큰이_그대로_남는다()
{
// 레거시는 클래스 전체 이름으로만 StartsWith 한다 — 축약형은 SQL 에 남아 ORA 구문오류가 된다.
// 조용히 지우면 조건이 사라진 SQL 이 돌아 엉뚱한 행이 나온다. 남기는 쪽이 안전하다.
var r = Run("SELECT 1 FROM DUAL WHERE X = <<bzPatientInfo.ComNum>>");
StringAssert.Contains(r.Sql, "<<bzPatientInfo.ComNum>>");
Assert.IsTrue(r.HasLeftover);
Assert.AreEqual(QuerySubstitutionKind.NotAVariable, r.Hits[0].Kind);
}
[TestMethod]
public void 모르는_속성과_빈_값을_구분한다()
{
// SQL 에서는 둘 다 빈 문자열이다(레거시와 같게) — 사람에게는 달라야 한다.
var unknown = Run("A<<M.CMM.HISOperatingInfo.bzPatientInfo.Age>>B");
Assert.AreEqual("AB", unknown.Sql);
Assert.AreEqual(QuerySubstitutionKind.Unknown, unknown.Hits[0].Kind);
Assert.AreEqual(1, unknown.UnknownTokens.Count);
var empty = Run("A<<M.CMM.HISOperatingInfo.bzPatientInfo.EmgKTSGrd>>B");
Assert.AreEqual("AB", empty.Sql);
Assert.AreEqual(QuerySubstitutionKind.Empty, empty.Hits[0].Kind);
Assert.AreEqual(0, empty.UnknownTokens.Count);
}
[TestMethod]
public void 행은_있고_컬럼이_없으면_오타로_본다()
{
// 이 경우는 서식이 컬럼명을 틀린 것이다 — 아직 안 옮긴 것과 구분해야 고칠 곳을 안다.
var r = Run("A<<M.CMM.HISOperatingInfo.bzPatientInfo.PatInfDR.item(\"PatNamX\")>>B");
Assert.AreEqual("AB", r.Sql);
Assert.AreEqual(QuerySubstitutionKind.Empty, r.Hits[0].Kind);
}
[TestMethod]
public void 여러_토큰과_사이_글자를_보존한다()
{
var r = Run("WHERE A = '<<M.CMM.HISOperatingInfo.bzPatientInfo.PatTyp>>'"
+ " AND B = <<M.CMM.HISOperatingInfo.bzPatientInfo.ComNum>>"
+ " AND C = '<<M.CMM.HISOperatingInfo.bzWorkInfo.WrkUid>>'");
Assert.AreEqual("WHERE A = 'O' AND B = 1566794 AND C = '011825'", r.Sql);
Assert.AreEqual(3, r.Hits.Count);
}
[TestMethod]
public void 닫히지_않은_토큰은_손대지_않는다()
{
var r = Run("SELECT 1 FROM DUAL WHERE X = <<M.CMM.HISOperatingInfo.bzPatientInfo.ComNum");
StringAssert.Contains(r.Sql, "<<M.CMM.HISOperatingInfo.bzPatientInfo.ComNum");
Assert.AreEqual(0, r.Hits.Count);
}
[TestMethod]
public void 토큰이_없으면_원문_그대로다()
{
const string sql = "SELECT * FROM P_PatInf WHERE ROWNUM <= 10";
Assert.AreEqual(sql, Run(sql).Sql);
Assert.AreEqual(0, Run(sql).Hits.Count);
}
[TestMethod]
public void 따옴표가_섞인_값을_드러낸다()
{
// 레거시는 값을 원문 그대로 박는다 — 이름에 따옴표가 있으면 SQL 이 깨지거나 문장이 바뀐다.
// 막지 않는다(같은 SQL 을 만들어야 미리보기가 거짓말을 안 한다). 대신 보이게 한다.
var source = new QuoteSource();
var r = QuerySubstitution.Apply(
"WHERE Nam = '<<M.CMM.HISOperatingInfo.bzPatientInfo.ChtNum>>'", source);
Assert.AreEqual(1, r.QuotedValues.Count, $"드러나지 않았다: {r.Sql}");
}
private sealed class QuoteSource : IQueryVariableSource
{
public string? Scalar(string className, string property) => "O'Brien";
public IReadOnlyDictionary<string, string>? Row(string className, string property) => null;
}
#endregion
}