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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f50a7cff4b
commit
9bff3dd5f6
@@ -25,3 +25,17 @@ public interface ITagValueResolver
|
||||
/// <summary>이 태그의 값 — 못 만들면 <see cref="TagValue.Resolved"/> 가 false 이고 사유가 담긴다</summary>
|
||||
TagValue Resolve(string tag);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>DataTableField</c> 배선의 값 — MDataTable 조회 결과에서 한 칸을 꺼낸다.
|
||||
///
|
||||
/// <b>태그와 다른 관이다.</b> 태그(<c>DataInterfaceTag</c>)는 함수 하나가 값을 만들지만,
|
||||
/// 이쪽은 서식이 들고 있는 SQL 을 <b>실행</b>해서 나온 표에서 행·컬럼을 지목한다.
|
||||
/// 그래서 해석기도 따로 둔다 — 하나로 합치면 "값이 왜 없나"의 답이 두 가지로 섞인다
|
||||
/// (태그를 안 옮겼다 / 쿼리가 0행이다 / 컬럼명이 틀렸다는 서로 다른 고칠 거리다).
|
||||
/// </summary>
|
||||
public interface IDataFieldResolver
|
||||
{
|
||||
/// <summary>이 배선의 값 — 못 만들면 사유가 담긴다</summary>
|
||||
TagValue Resolve(DataTableFieldSpec spec);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace SheetMe.Core.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// 치환 변수의 값을 내주는 쪽 — 미리보기는 환자 문맥이, 시험 실행은 표본이 맡는다.
|
||||
/// </summary>
|
||||
public interface IQueryVariableSource
|
||||
{
|
||||
/// <summary>
|
||||
/// 스칼라 속성 값. <b>null 은 "이 이름을 모른다"</b>, 빈 문자열은 "안다, 값이 비었다"다.
|
||||
/// 둘은 SQL 에서 같게 처리되지만(레거시가 그렇다) 사람에게는 다르게 말해야 한다.
|
||||
/// </summary>
|
||||
string? Scalar(string className, string property);
|
||||
|
||||
/// <summary>DataRow 속성. null 은 그 행을 모른다는 뜻</summary>
|
||||
IReadOnlyDictionary<string, string>? Row(string className, string property);
|
||||
}
|
||||
|
||||
/// <summary>치환 결과 한 건 — 무엇이 무엇으로 바뀌었는가</summary>
|
||||
/// <param name="Token">원문 토큰(<c><<…>></c> 포함)</param>
|
||||
/// <param name="Value">박힌 값</param>
|
||||
/// <param name="Kind">어떻게 판정됐는가</param>
|
||||
public readonly record struct QuerySubstitutionHit(string Token, string Value, QuerySubstitutionKind Kind);
|
||||
|
||||
/// <summary>치환 판정</summary>
|
||||
public enum QuerySubstitutionKind
|
||||
{
|
||||
/// <summary>값이 있었다</summary>
|
||||
Value,
|
||||
|
||||
/// <summary>이름은 아는데 값이 비었다 — 레거시도 빈 문자열로 박는다</summary>
|
||||
Empty,
|
||||
|
||||
/// <summary>이름을 모른다(아직 안 옮긴 속성) — 레거시는 빈 문자열, 우리는 그 사실을 말한다</summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>접두어가 세 클래스 중 어느 것도 아니다 — 레거시는 토큰을 <b>그대로 남긴다</b>(ORA 구문오류)</summary>
|
||||
NotAVariable,
|
||||
}
|
||||
|
||||
/// <summary>치환을 마친 SQL 과 그 내역</summary>
|
||||
public sealed record QuerySubstitutionResult(
|
||||
string Sql, IReadOnlyList<QuerySubstitutionHit> Hits)
|
||||
{
|
||||
/// <summary>SQL 에 남은 토큰이 있는가 — 있으면 실행하면 ORA 구문오류다</summary>
|
||||
public bool HasLeftover => Hits.Any(h => h.Kind == QuerySubstitutionKind.NotAVariable);
|
||||
|
||||
/// <summary>아직 옮기지 않은 속성들 — 화면이 "왜 비었나"를 설명할 때 쓴다</summary>
|
||||
public IReadOnlyList<string> UnknownTokens
|
||||
=> Hits.Where(h => h.Kind == QuerySubstitutionKind.Unknown).Select(h => h.Token).Distinct().ToList();
|
||||
|
||||
/// <summary>
|
||||
/// 따옴표가 섞여 들어간 값들 — <b>레거시는 값을 원문 그대로 박는다</b>.
|
||||
/// 환자 이름에 <c>'</c> 가 있으면 SQL 이 깨지거나 문장이 바뀐다.
|
||||
/// 막지는 않는다(레거시와 같은 SQL 을 만들어야 미리보기가 거짓말을 하지 않는다) —
|
||||
/// 대신 <b>보이게</b> 한다. 실행 쪽은 SELECT/WITH 만 허용하는 문이 따로 있다.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> QuotedValues
|
||||
=> Hits.Where(h => h.Value.Contains('\'') || h.Value.Contains(';'))
|
||||
.Select(h => h.Token).Distinct().ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MDataTable 쿼리의 <c><<…>></c> 치환 — 레거시 <c>clsMDataTable.ConvertQuery</c> 를 옮긴 것
|
||||
/// (<c>[003]EMR\[002]UserControl\MDataTable\clsMDataTable.vb:24-71, :113</c>).
|
||||
///
|
||||
/// <b>규약을 그대로 지킨다.</b> 여기서 한 칸이라도 다르게 만들면 미리보기가 운영과 다른 SQL 을 돌린다 —
|
||||
/// "미리보기에서는 나왔는데 실제로는 안 나온다"가 되고, 그건 미리보기가 없는 것보다 나쁘다.
|
||||
/// <list type="number">
|
||||
/// <item>토큰이 세 클래스 <b>전체 이름</b> 중 하나로 시작하는지 본다(StartsWith).
|
||||
/// 축약형은 치환되지 않고 토큰이 SQL 에 남는다.</item>
|
||||
/// <item>나머지를 <c>^([a-zA-Z_]\w*)(?:\.item\("([^"]+)"\)|\("([^"]+)"\))?$</c> 로 해석한다 —
|
||||
/// 스칼라와 DataRow 접근 2형태.</item>
|
||||
/// <item><c>item</c> 은 <b>소문자만</b>이다(정규식이 대소문자를 구분한다).</item>
|
||||
/// <item>값은 <b>원문 그대로</b> 박는다 — 따옴표를 붙여 주지 않는다.</item>
|
||||
/// <item>값을 못 만들면 <b>빈 문자열</b>로 박는다(:67-71, :138). 조용히 사라진다.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// 순수 함수로 둔다. 갈래가 많고(스칼라/DataRow 2형태/미지원/접두어 불일치) 실DB 로는
|
||||
/// 한 갈래씩 확인할 수 없다.
|
||||
/// </summary>
|
||||
public static class QuerySubstitution
|
||||
{
|
||||
#region Member Fields
|
||||
/// <summary>런타임 호스트의 속성 선언형 — <c>ucLoadSheetBase.vb:7619-7631</c></summary>
|
||||
public const string PatientClass = "M.CMM.HISOperatingInfo.bzPatientInfo";
|
||||
public const string WorkClass = "M.CMM.HISOperatingInfo.bzWorkInfo";
|
||||
public const string SheetClass = "M.EMR.SheetLoadOperatingInfo.bzSheetInfo";
|
||||
|
||||
/// <summary>긴 것부터 본다 — 접두어가 서로 겹치면 짧은 쪽이 먼저 맞아 나머지 해석이 깨진다</summary>
|
||||
private static readonly string[] Classes = { PatientClass, WorkClass, SheetClass };
|
||||
|
||||
/// <summary>레거시와 같은 정규식. <c>item</c> 소문자, 경로 한 단계만</summary>
|
||||
private static readonly Regex Member = new(
|
||||
@"^([a-zA-Z_]\w*)(?:\.item\(""([^""]+)""\)|\(""([^""]+)""\))?$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>치환한다 — SQL 은 실행할 수 있는 문장이 되고, 내역은 왜 그렇게 됐는지 말한다</summary>
|
||||
public static QuerySubstitutionResult Apply(string sql, IQueryVariableSource source)
|
||||
{
|
||||
var text = sql ?? string.Empty;
|
||||
var hits = new List<QuerySubstitutionHit>();
|
||||
var output = new StringBuilder(text.Length);
|
||||
var at = 0;
|
||||
|
||||
while (at < text.Length)
|
||||
{
|
||||
var open = text.IndexOf("<<", at, StringComparison.Ordinal);
|
||||
if (open < 0)
|
||||
{
|
||||
output.Append(text, at, text.Length - at);
|
||||
break;
|
||||
}
|
||||
var close = text.IndexOf(">>", open + 2, StringComparison.Ordinal);
|
||||
if (close < 0)
|
||||
{
|
||||
// 닫히지 않은 토큰 — 손대지 않는다. 레거시도 여기서 멈춘다
|
||||
output.Append(text, at, text.Length - at);
|
||||
break;
|
||||
}
|
||||
|
||||
output.Append(text, at, open - at);
|
||||
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));
|
||||
// 변수가 아니면 토큰을 그대로 남긴다 — 레거시가 그렇고, 남은 토큰은 ORA 구문오류가 된다.
|
||||
// 조용히 지우면 <b>조건이 사라진 SQL</b>이 돌아 엉뚱한 행이 나온다. 그게 더 위험하다.
|
||||
output.Append(kind == QuerySubstitutionKind.NotAVariable ? token : value);
|
||||
at = close + 2;
|
||||
}
|
||||
|
||||
return new QuerySubstitutionResult(output.ToString(), hits);
|
||||
}
|
||||
|
||||
/// <summary>토큰 안쪽을 값으로</summary>
|
||||
private static (string Value, QuerySubstitutionKind Kind) Resolve(string inner, IQueryVariableSource source)
|
||||
{
|
||||
var className = Array.Find(Classes, c => inner.StartsWith(c, StringComparison.Ordinal));
|
||||
if (className is null)
|
||||
{
|
||||
return (string.Empty, QuerySubstitutionKind.NotAVariable);
|
||||
}
|
||||
var rest = inner[className.Length..].TrimStart('.');
|
||||
var match = Member.Match(rest);
|
||||
if (!match.Success)
|
||||
{
|
||||
// 접두어는 맞는데 경로가 규약 밖이다(중첩 접근 등) — 레거시는 빈 문자열로 박는다
|
||||
return (string.Empty, QuerySubstitutionKind.Unknown);
|
||||
}
|
||||
|
||||
var property = match.Groups[1].Value;
|
||||
var column = match.Groups[2].Success ? match.Groups[2].Value
|
||||
: match.Groups[3].Success ? match.Groups[3].Value
|
||||
: null;
|
||||
|
||||
if (column is null)
|
||||
{
|
||||
var scalar = source.Scalar(className, property);
|
||||
return scalar is null
|
||||
? (string.Empty, QuerySubstitutionKind.Unknown)
|
||||
: (scalar, scalar.Length == 0 ? QuerySubstitutionKind.Empty : QuerySubstitutionKind.Value);
|
||||
}
|
||||
|
||||
var row = source.Row(className, property);
|
||||
if (row is null)
|
||||
{
|
||||
return (string.Empty, QuerySubstitutionKind.Unknown);
|
||||
}
|
||||
// 행은 있는데 그 컬럼이 없다 — 레거시는 예외를 먹고 빈 문자열로 박는다.
|
||||
// "행이 없다"와 구분해야 한다: 전자는 서식의 컬럼명 오타, 후자는 아직 안 옮긴 것이다.
|
||||
if (!row.TryGetValue(column, out var cell))
|
||||
{
|
||||
return (string.Empty, QuerySubstitutionKind.Empty);
|
||||
}
|
||||
return (cell, cell.Length == 0 ? QuerySubstitutionKind.Empty : QuerySubstitutionKind.Value);
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user