치환 변수 전량 이식 — 그리고 이 파일의 주석이 틀렸었다

25개였던 카탈로그를 51개로 늘렸다. 그 과정에서 이 파일이 근거로 삼던 규약 자체가
틀렸다는 것이 드러나 함께 고쳤다.

■ 엔진을 잘못 지목하고 있었다

주석은 bzDesignSheetLoader.ConvertQuery 의
  sReserved.Substring(0, sReserved.LastIndexOf("."))
를 근거로 "DataRow 접근형(...PatInfDR.item("컬럼"))은 접두어를 깨뜨려 반드시 실패한다"고
단정하고 그런 변수를 목록에서 뺐다. 테스트도 그 규칙으로 검사하고 있었다.

실제 엔진은 clsMDataTable.ConvertQuery 다
(C:\MsystechHIS_Ver.2\[003]EMR\[002]UserControl\MDataTable\clsMDataTable.vb).
ucLoadSheetBase 가 이것을 부르고 레거시 쿼리 편집기(fmMDataTable)도 같은 것을 쓴다.
그리고 그 파일에서 LastIndexOf(".") 줄은 주석 처리되어 있다(:32). StartsWith(FullName) 로 대체됐고,
클래스명 뒤 경로는 정규식으로 해석한다(:113):

  ^([a-zA-Z_]\w*)(?:\.item\("([^"]+)"\)|\("([^"]+)"\))?$

즉 DataRow 컬럼 접근이 두 형태로 <b>동작한다</b>. 단 item 은 반드시 소문자다 —
정규식에 IgnoreCase 가 없다. 대문자로 쓰면 값을 못 찾고 빈 문자열로 치환된다.

■ 추가한 것

환자 16 + 외부연계 11 + 서식 13 + 작업자 3 = 스칼라 43종(레거시 세 클래스의 스칼라 전량),
여기에 DataRow 컬럼 채우기 틀 8종(P_PatInf·P_ComInf·P_CodInf·P_CoiInf·P_CowInf·E_ShtMst·E_SdgMst).
컬럼 틀은 삽입하면 '컬럼명' 자리가 선택돼 바로 덮어쓸 수 있다.

다른 biz 객체·컬렉션을 돌려주는 속성(PatientInfoBiz_Refer, SaveSheetInfo, PrintEmrKeyList,
UidMst/HspMst/DepMst, SctMst)은 넣지 않았다. 정규식이 한 단계 경로만 허용해 중첩 접근이 안 되고,
단독으로 쓰면 타입 이름 문자열이 SQL 에 박힌다.

■ 판정을 목록 대조에서 형태 검사로 바꿨다

DataRow 컬럼은 무한히 많아 목록에 담을 수 없다. LegacyQueryVariableCatalog.IsResolvable 이
접두어 StartsWith + 위 정규식으로 판정하고, 편집기 경고도 이것을 쓴다.
목록에 없다고 경고하던 종전 방식이었다면 정상적인 DataRow 사용이 전부 오탐이 됐을 것이다.

■ 토크나이저 결함 — 따옴표 안의 변수를 삼키고 있었다

테스트를 쓰다 발견했다. 엔진은 값에 따옴표를 붙여 주지 않고 Replace 로 원문을 박으므로,
문자열 비교에 쓰려면 SQL 쪽에서 '<<...>>' 로 감싸는 것이 정상 사용법이다.
그런데 토크나이저가 '...' 를 통째로 문자열로 잡아 그 안의 변수를 못 봤다 —
가장 흔한 형태의 변수가 색도 검증도 못 받고 있었다. 문자열 구간 안에서도 <<...>> 를 떼어 내도록 고쳤다.
전 구간 덮기 불변식은 유지된다(테스트로 고정).

단위 테스트 22건 추가/수정(카탈로그 자기일관성·DataRow 2형태·item 대문자 거부·축약 접두어 거부·
중첩 경로 거부·따옴표 안 변수·덮기 불변식). 옛 규칙을 박아 둔 기존 테스트 1건은 실제 규약으로 교체.

회귀: 테스트 196/196, 편집 스모크 실패 0,
DB 왕복 1,271건 diff 0/예외 0, 종이 렌더 P062 바이트 동일.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-13 15:10:20 +09:00
co-authored by Claude Opus 5
parent de46f70035
commit 864f11b0e4
6 changed files with 350 additions and 51 deletions
@@ -3,19 +3,17 @@ using SheetMe.Core.Catalog;
namespace SheetMe.Core.Tests;
/// <summary>
/// 쿼리 치환 변수 카탈로그 테스트 — 레거시 bzDesignSheetLoader.ConvertQuery 의
/// "접두어를 GetType.FullName 과 완전일치 비교" 규칙을 시뮬레이션한다.
/// 쿼리 치환 변수 카탈로그 테스트.
///
/// <b>판정 규칙이 바뀌었다.</b> 예전에는 <c>Substring(0, LastIndexOf("."))</c> 로 접두어를 잘라
/// 완전일치 비교한다고 보고 그렇게 검사했는데, 실제 엔진(clsMDataTable.vb:24-71)에서 그 줄은
/// 주석 처리되어 있고 <c>StartsWith(FullName)</c> + 정규식으로 대체됐다.
/// 그래서 DataRow 접근형(<c>.item("컬럼")</c>)도 유효하다 — 옛 규칙으로 검사하면 그것이 전부 오탐이 된다.
/// 판정은 <see cref="LegacyQueryVariableCatalog.IsResolvable"/> 하나로 모았다.
/// </summary>
[TestClass]
public sealed class LegacyQueryVariableCatalogTests
{
/// <summary>레거시 ConvertQuery 와 동일한 접두어 추출 — <c>Substring(0, LastIndexOf("."))</c></summary>
private static string PrefixOf(string token)
{
var inner = token[2..^2];
return inner[..inner.LastIndexOf('.')];
}
/// <summary>모든 토큰이 &lt;&lt;…&gt;&gt; 로 감싸이고 중복이 없어야 한다</summary>
[TestMethod]
public void Tokens_AreWellFormedAndUnique()
@@ -31,17 +29,16 @@ public sealed class LegacyQueryVariableCatalogTests
}
/// <summary>
/// 레거시 완전일치 시뮬레이션 — 접두어가 런타임 비교 대상 3종 중 하나와 정확히 같아야 한다.
/// 축약형이나 DataRow 접근형(.item("컬럼"))이 섞이면 여기서 걸린다.
/// 모든 토큰이 실제 엔진 규약으로 해석돼야 한다 — 접두어 StartsWith + clsMDataTable 정규식.
/// 축약형이 섞이면 여기서 걸린다(치환되지 않고 SQL 에 그대로 남아 ORA 오류가 난다).
/// </summary>
[TestMethod]
public void EveryToken_ResolvesUnderLegacyExactMatchRule()
public void EveryToken_ResolvesUnderRuntimeRule()
{
foreach (var variable in LegacyQueryVariableCatalog.All)
{
var prefix = PrefixOf(variable.Token);
CollectionAssert.Contains(LegacyQueryVariableCatalog.Prefixes.ToList(), prefix,
$"{variable.Token}: 접두어 '{prefix}' 가 런타임 비교 대상이 아닙니다 → 치환되지 않고 SQL 에 그대로 남습니다.");
Assert.IsTrue(LegacyQueryVariableCatalog.IsResolvable(variable.Token),
$"{variable.Token}: 런타임이 해석할 수 없는 형태입니다 → 치환되지 않고 SQL 에 그대로 남습니다.");
}
}
@@ -0,0 +1,115 @@
using SheetMe.Core.Catalog;
namespace SheetMe.Core.Tests;
/// <summary>
/// 치환 변수 형태 판정 테스트 — 실제 엔진 clsMDataTable.ConvertQuery 규약을 옮긴 것.
///
/// 규약을 어기면 <c>&lt;&lt;...&gt;&gt;</c> 가 SQL 에 그대로 남아 ORA 오류가 나거나
/// 빈 문자열로 조용히 치환된다. 둘 다 화면에는 아무 표시가 없어 배포 후에야 드러난다.
/// </summary>
[TestClass]
public sealed class QueryVariableResolveTests
{
private const string Patient = "M.CMM.HISOperatingInfo.bzPatientInfo";
private const string Sheet = "M.EMR.SheetLoadOperatingInfo.bzSheetInfo";
private const string Work = "M.CMM.HISOperatingInfo.bzWorkInfo";
/// <summary>카탈로그에 실린 토큰은 전부 런타임이 해석할 수 있어야 한다 — 자기모순 방지</summary>
[TestMethod]
public void EveryCatalogTokenIsResolvable()
{
foreach (var variable in LegacyQueryVariableCatalog.All)
{
Assert.IsTrue(LegacyQueryVariableCatalog.IsResolvable(variable.Token),
$"카탈로그 토큰이 규약에 안 맞습니다: {variable.Token}");
}
}
/// <summary>컬럼 채우기 틀은 NeedsColumn 표시가 있어야 편집기가 그 자리를 선택해 준다</summary>
[TestMethod]
public void ColumnTemplatesAreFlagged()
{
foreach (var variable in LegacyQueryVariableCatalog.All)
{
var hasPlaceholder = variable.Token.Contains("컬럼명", StringComparison.Ordinal);
Assert.AreEqual(hasPlaceholder, variable.NeedsColumn,
$"NeedsColumn 표시가 토큰과 어긋납니다: {variable.Token}");
}
}
[DataTestMethod]
[DataRow("ChtNum")]
[DataRow("ComNum_Refer")]
[DataRow("OemVar5")]
public void ScalarPropertyResolves(string property)
=> Assert.IsTrue(LegacyQueryVariableCatalog.IsResolvable($"<<{Patient}.{property}>>"));
/// <summary>
/// DataRow 접근형 2가지가 모두 유효하다 — clsMDataTable.vb:113 정규식이 둘 다 받는다.
/// 이 파일의 예전 주석은 '반드시 실패한다'고 했으나 그 근거였던 LastIndexOf(".") 줄은
/// 실제 엔진에서 주석 처리되어 있다(clsMDataTable.vb:32).
/// </summary>
[DataTestMethod]
[DataRow("PatInfDR.item(\"PatNam\")")]
[DataRow("PatInfDR(\"PatNam\")")]
[DataRow("CowInfDR.item(\"CowWadCod\")")]
public void DataRowAccessResolves(string path)
=> Assert.IsTrue(LegacyQueryVariableCatalog.IsResolvable($"<<{Patient}.{path}>>"));
/// <summary>item 은 반드시 소문자 — 정규식에 IgnoreCase 가 없다</summary>
[DataTestMethod]
[DataRow("PatInfDR.Item(\"PatNam\")")]
[DataRow("PatInfDR.ITEM(\"PatNam\")")]
public void UpperCaseItemDoesNotResolve(string path)
=> Assert.IsFalse(LegacyQueryVariableCatalog.IsResolvable($"<<{Patient}.{path}>>"),
"item 을 대문자로 쓰면 런타임이 값을 못 찾고 빈 문자열로 치환한다");
/// <summary>접두어를 줄여 쓰면 치환되지 않는다 — 가장 흔한 실수다</summary>
[DataTestMethod]
[DataRow("<<PatientInfo.ChtNum>>")]
[DataRow("<<bzPatientInfo.ChtNum>>")]
[DataRow("<<M.CMM.HISOperatingInfo.ChtNum>>")]
public void ShortenedPrefixDoesNotResolve(string token)
=> Assert.IsFalse(LegacyQueryVariableCatalog.IsResolvable(token));
/// <summary>클래스명 뒤에 점이 없으면 안 된다(접두어가 더 긴 이름의 앞부분인 경우 방지)</summary>
[TestMethod]
public void PrefixMustBeFollowedByDot()
=> Assert.IsFalse(LegacyQueryVariableCatalog.IsResolvable($"<<{Patient}Extra.ChtNum>>"));
/// <summary>중첩 경로는 정규식이 받지 않는다 — 한 단계만 허용한다</summary>
[TestMethod]
public void NestedPathDoesNotResolve()
=> Assert.IsFalse(LegacyQueryVariableCatalog.IsResolvable($"<<{Patient}.PatientInfoBiz_Refer.ComNum>>"));
[TestMethod]
public void SheetAndWorkPrefixesResolve()
{
Assert.IsTrue(LegacyQueryVariableCatalog.IsResolvable($"<<{Sheet}.ShtCod>>"));
Assert.IsTrue(LegacyQueryVariableCatalog.IsResolvable($"<<{Work}.WrkUid>>"));
}
[DataTestMethod]
[DataRow("ChtNum")]
[DataRow("<<ChtNum")]
[DataRow("")]
public void MalformedTokenDoesNotResolve(string token)
=> Assert.IsFalse(LegacyQueryVariableCatalog.IsResolvable(token));
/// <summary>
/// 편집기가 실제로 쓰는 흐름 — 쿼리에서 뽑은 변수를 그대로 판정에 넘긴다.
/// </summary>
[TestMethod]
public void TokenizerOutputFeedsResolverDirectly()
{
var sql = $"SELECT * FROM P_PatMst WHERE ChtNum = '<<{Patient}.ChtNum>>' " +
$"AND Nam = '<<{Patient}.PatInfDR.item(\"PatNam\")>>' AND X = '<<Bad.Prefix>>'";
var bad = SqlTokenizer.VariablesIn(sql)
.Where(v => !LegacyQueryVariableCatalog.IsResolvable(v))
.ToList();
CollectionAssert.AreEqual(new[] { "<<Bad.Prefix>>" }, bad.ToArray());
}
}
@@ -99,6 +99,39 @@ public sealed class SqlTokenizerTests
PieceOf(sql, SqlTokenKind.Variable));
}
/// <summary>
/// 따옴표 안의 치환 변수도 변수로 잡아야 한다 — 이게 오히려 정상 사용법이다.
/// 엔진은 값에 따옴표를 붙여 주지 않으므로(Replace 원문 삽입) 문자열 비교에 쓰려면
/// SQL 쪽에서 '&lt;&lt;...&gt;&gt;' 로 감싸야 한다. 통째로 문자열로 칠하면
/// 가장 흔한 형태의 변수가 색도 검증도 못 받는다.
/// </summary>
[TestMethod]
public void Tokenize_FindsVariableInsideStringLiteral()
{
const string sql = "WHERE ChtNum = '<<M.CMM.HISOperatingInfo.bzPatientInfo.ChtNum>>'";
CollectionAssert.AreEqual(
new[] { "<<M.CMM.HISOperatingInfo.bzPatientInfo.ChtNum>>" },
SqlTokenizer.VariablesIn(sql).ToArray());
// 감싼 따옴표는 여전히 문자열로 남는다
Assert.AreEqual(SqlTokenKind.Text, KindAt(sql, sql.IndexOf('\'')));
}
/// <summary>변수를 떼어 내도 전 구간 덮기 불변식은 유지돼야 한다</summary>
[TestMethod]
public void Tokenize_StillCoversEverythingWhenVariableIsInsideString()
{
const string sql = "SELECT '앞<<A.B.C>>뒤' FROM t";
var position = 0;
foreach (var token in SqlTokenizer.Tokenize(sql))
{
Assert.AreEqual(position, token.Start);
position = token.End;
}
Assert.AreEqual(sql.Length, position);
}
[TestMethod]
public void VariablesIn_ListsEveryToken()
{