From 864f11b0e4e17b9d456d24a43e055add567c1579 Mon Sep 17 00:00:00 2001 From: Msystech Date: Thu, 13 Aug 2026 15:10:20 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B9=98=ED=99=98=20=EB=B3=80=EC=88=98=20?= =?UTF-8?q?=EC=A0=84=EB=9F=89=20=EC=9D=B4=EC=8B=9D=20=E2=80=94=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=AC=EA=B3=A0=20=EC=9D=B4=20=ED=8C=8C=EC=9D=BC=EC=9D=98=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=EC=9D=B4=20=ED=8B=80=EB=A0=B8=EC=97=88?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 컬럼 접근이 두 형태로 동작한다. 단 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 --- .../Catalog/LegacyQueryVariableCatalog.cs | 153 ++++++++++++++---- src/SheetMe.Core/Catalog/SqlTokenizer.cs | 41 ++++- .../Views/QueryEditorWindow.xaml.cs | 32 +++- .../LegacyQueryVariableCatalogTests.cs | 27 ++-- .../QueryVariableResolveTests.cs | 115 +++++++++++++ tests/SheetMe.Core.Tests/SqlTokenizerTests.cs | 33 ++++ 6 files changed, 350 insertions(+), 51 deletions(-) create mode 100644 tests/SheetMe.Core.Tests/QueryVariableResolveTests.cs diff --git a/src/SheetMe.Core/Catalog/LegacyQueryVariableCatalog.cs b/src/SheetMe.Core/Catalog/LegacyQueryVariableCatalog.cs index b518f52..0184589 100644 --- a/src/SheetMe.Core/Catalog/LegacyQueryVariableCatalog.cs +++ b/src/SheetMe.Core/Catalog/LegacyQueryVariableCatalog.cs @@ -1,25 +1,47 @@ namespace SheetMe.Core.Catalog; -/// 치환 변수 1건 — 이 SQL 에 그대로 삽입되는 문자열이다. -public sealed record QueryVariable(string Group, string Token, string Description); +/// +/// 치환 변수 1건 — 이 SQL 에 그대로 삽입되는 문자열이다. +/// +/// 이 true 면 컬럼명을 채워야 완성된다(DataRow 접근형). +/// 삽입 후 사용자가 컬럼명 자리를 고쳐야 하므로 편집기가 그 부분을 선택해 준다. +/// +public sealed record QueryVariable(string Group, string Token, string Description, bool NeedsColumn = false); /// /// MDataTable 쿼리의 치환 변수 카탈로그. /// -/// 레거시 런타임 bzDesignSheetLoader.ConvertQuery 는 토큰에서 -/// sReserved.Substring(0, sReserved.LastIndexOf(".")) 를 잘라 -/// PatientInfo/SheetInfo/WorkInfo 객체의 GetType.FullName 과 -/// Select Case 완전일치로 비교한다. 즉 접두어는 클래스 전체 이름이어야 하며, -/// 축약형(<<PatientInfo.X>>)은 치환되지 않고 <<...>> 가 -/// SQL 에 리터럴로 남아 ORA 구문오류를 낸다(= 해당 데이터소스를 참조하는 컨트롤이 전부 공백). +/// 치환 엔진은 clsMDataTable.ConvertQuery +/// (C:\MsystechHIS_Ver.2\[003]EMR\[002]UserControl\MDataTable\clsMDataTable.vb). +/// 실행 경로에서 ucLoadSheetBase 가 이것을 부르고, 레거시 쿼리 편집기(fmMDataTable)도 같은 것을 쓴다. +/// +/// 규약(clsMDataTable.vb:24-71, :113): +/// +/// <<>> 로 잘라낸 토큰이 세 biz 객체의 GetType.FullName 중 +/// 하나로 시작하는지 본다(StartsWith). 즉 접두어는 클래스 전체 이름이어야 한다 — +/// 축약형은 치환되지 않고 <<...>> 가 SQL 에 남아 ORA 구문오류를 낸다 +/// (= 그 데이터소스를 참조하는 컨트롤이 전부 공백). +/// 클래스명 뒤 나머지를 정규식 +/// ^([a-zA-Z_]\w*)(?:\.item\("([^"]+)"\)|\("([^"]+)"\))?$ 로 해석한다. +/// 그래서 스칼라 속성DataRow 컬럼 접근 2형태가 모두 유효하다. +/// 값은 Replace원문 그대로 박힌다 — 따옴표를 붙여 주지 않는다. +/// 문자열 비교에 쓰려면 SQL 쪽에 '<<...>>' 처럼 직접 감싸야 한다. +/// 속성 getter 가 예외를 내거나 값이 비면 빈 문자열로 치환된다(:67-71, :138). +/// 조용히 사라지므로 편집기가 미리 걸러 주지 않으면 현장에서만 드러난다. +/// +/// +/// 주의 — 예전 주석이 틀렸다. 이 파일에는 bzDesignSheetLoader 의 +/// LastIndexOf(".") 규칙 때문에 DataRow 접근형이 반드시 실패한다고 적혀 있었으나, +/// 실제 엔진인 clsMDataTable 에서 그 줄은 주석 처리되어 있고(:32) StartsWith 로 대체됐다. +/// DataRow 접근형은 동작한다. 단 item반드시 소문자다 — 정규식이 대소문자를 구분한다. /// /// 접두어는 런타임 호스트의 속성 선언형으로 고정된다 /// (ucLoadSheetBase.vb:7619-7631 — PatientInfo/WorkInfo 는 M.CMM.HISOperatingInfo, /// SheetInfo 는 M.EMR.SheetLoadOperatingInfo). /// -/// 속성명은 리플렉션(GetProperty(...).GetValue(...))으로 해석되므로 실존하는 공개 스칼라 -/// 속성만 등재한다. DataRow 접근형(...PatInfDR.item("컬럼"))은 위 LastIndexOf(".") 규칙이 -/// 접두어를 깨뜨려 이 경로에서 반드시 실패하므로 목록에 넣지 않는다. +/// 등재 기준: 실존하는 공개 속성 중 스칼라는 그대로, DataRow는 컬럼 채우기 틀로 올린다. +/// 다른 biz 객체·컬렉션을 돌려주는 속성은 뺀다 — 정규식이 한 단계 경로만 허용해 중첩 접근이 안 되고, +/// 단독으로 쓰면 타입 이름 문자열이 SQL 에 박힌다. /// public static class LegacyQueryVariableCatalog { @@ -31,36 +53,111 @@ public static class LegacyQueryVariableCatalog /// 그룹별 치환 변수 — 인스펙터 쿼리 편집기의 삽입 목록 public static readonly IReadOnlyList All = new QueryVariable[] { - new("환자", $"<<{Patient}.ChtNum>>", "차트번호"), + // ── 환자(bzPatientInfo) — 스칼라 ── + new("환자", $"<<{Patient}.ChtNum>>", "차트번호(등록번호)"), new("환자", $"<<{Patient}.ComNum>>", "내원번호"), - new("환자", $"<<{Patient}.ComNum_Refer>>", "참조 내원번호"), - new("환자", $"<<{Patient}.ComCvtCom>>", "전환 내원번호"), - new("환자", $"<<{Patient}.PatTyp>>", "입퇴원 구분"), - new("환자", $"<<{Patient}.AdpDtm>>", "접수일시"), - new("환자", $"<<{Patient}.OrderAdpDtm>>", "처방 접수일시"), - new("환자", $"<<{Patient}.Age>>", "나이"), - new("환자", $"<<{Patient}.AgeInMonth>>", "월령"), + new("환자", $"<<{Patient}.ComNum_Refer>>", "참조 내원번호(없으면 0)"), + new("환자", $"<<{Patient}.ComCvtCom>>", "전환 내원번호(외래↔입원 연결)"), + new("환자", $"<<{Patient}.PatTyp>>", "환자구분 O 외래 / I 입원"), + new("환자", $"<<{Patient}.PatTyp_Refer>>", "참조 내원의 환자구분"), + new("환자", $"<<{Patient}.AdpDtm>>", "적용일시 yyyyMMddHHmm"), + new("환자", $"<<{Patient}.OrderAdpDtm>>", "처방 적용일시"), + new("환자", $"<<{Patient}.Age>>", "나이(산출 불가 시 ?)"), + new("환자", $"<<{Patient}.AgeInMonth>>", "개월수"), new("환자", $"<<{Patient}.Sex>>", "성별"), - new("환자", $"<<{Patient}.PatMblPhn>>", "휴대전화"), - new("환자", $"<<{Patient}.ComDayCar>>", "낮병동 여부"), - new("환자", $"<<{Patient}.HipassYon>>", "하이패스 여부"), - new("환자", $"<<{Patient}.EmgKTSGrd>>", "응급 KTAS 등급"), - new("환자", $"<<{Patient}.OemNum1>>", "외부연동 번호1"), - new("환자", $"<<{Patient}.OemNum2>>", "외부연동 번호2"), - new("환자", $"<<{Patient}.OemVar1>>", "외부연동 값1"), + new("환자", $"<<{Patient}.PatMblPhn>>", "휴대전화번호"), + new("환자", $"<<{Patient}.ComDayCar>>", "낮병동 여부 Y/N"), + new("환자", $"<<{Patient}.HipassYon>>", "하이패스 사용여부 Y/N"), + new("환자", $"<<{Patient}.EmgKTSGrd>>", "응급 KTAS 등급(해당 없으면 빈값)"), + new("환자", $"<<{Patient}.OutPat_CalPatTyp>>", "외래 본인부담 산정용 구분"), + // 외부연계(OEM) 슬롯 — E_OemInf 의 같은 이름 컬럼으로 넘어간다. + // 슬롯별 의미는 연계 프로그램마다 달라 설명을 단정하지 않는다(OemNum1 만 처방키 관행). + new("외부연계", $"<<{Patient}.OemTableNam>>", "원본 테이블명"), + new("외부연계", $"<<{Patient}.OemNum1>>", "숫자키1(주로 처방키)"), + new("외부연계", $"<<{Patient}.OemNum2>>", "숫자키2"), + new("외부연계", $"<<{Patient}.OemNum3>>", "숫자키3"), + new("외부연계", $"<<{Patient}.OemNum4>>", "숫자키4"), + new("외부연계", $"<<{Patient}.OemNum5>>", "숫자키5"), + new("외부연계", $"<<{Patient}.OemVar1>>", "문자값1"), + new("외부연계", $"<<{Patient}.OemVar2>>", "문자값2"), + new("외부연계", $"<<{Patient}.OemVar3>>", "문자값3"), + new("외부연계", $"<<{Patient}.OemVar4>>", "문자값4"), + new("외부연계", $"<<{Patient}.OemVar5>>", "문자값5"), + + // ── 환자 상세(DataRow) — 컬럼명을 채워야 완성된다 ── + // item 은 반드시 소문자다(정규식이 대소문자를 구분한다). + new("환자 상세(컬럼 지정)", $"<<{Patient}.PatInfDR.item(\"컬럼명\")>>", "환자 기본정보 P_PatInf 의 컬럼", NeedsColumn: true), + new("환자 상세(컬럼 지정)", $"<<{Patient}.ComInfDR.item(\"컬럼명\")>>", "내원정보 P_ComInf 의 컬럼", NeedsColumn: true), + new("환자 상세(컬럼 지정)", $"<<{Patient}.ComInfDR_Refer.item(\"컬럼명\")>>", "참조 내원의 P_ComInf 컬럼", NeedsColumn: true), + new("환자 상세(컬럼 지정)", $"<<{Patient}.CodInfDR.item(\"컬럼명\")>>", "진료과·담당의 P_CodInf 의 컬럼", NeedsColumn: true), + new("환자 상세(컬럼 지정)", $"<<{Patient}.CoiInfDR.item(\"컬럼명\")>>", "보험 자격정보 P_CoiInf 의 컬럼", NeedsColumn: true), + new("환자 상세(컬럼 지정)", $"<<{Patient}.CowInfDR.item(\"컬럼명\")>>", "입원 병동·병실 P_CowInf 의 컬럼", NeedsColumn: true), + + // ── 서식(bzSheetInfo) — 스칼라 ── new("서식", $"<<{Sheet}.ShtCod>>", "서식 코드"), new("서식", $"<<{Sheet}.ShtNam>>", "서식명"), new("서식", $"<<{Sheet}.EmrKey>>", "EMR 키(신규 작성 시 0)"), new("서식", $"<<{Sheet}.AdpDtm>>", "작성일시"), new("서식", $"<<{Sheet}.TemKey>>", "템플릿 키"), + new("서식", $"<<{Sheet}.PatTyp>>", "서식 기준 환자구분"), + new("서식", $"<<{Sheet}.EmrGbn>>", "EMR 구분"), + new("서식", $"<<{Sheet}.OdrNum>>", "처방 번호"), + new("서식", $"<<{Sheet}.OdrSeq>>", "처방 일련번호"), + new("서식", $"<<{Sheet}.LocalPath>>", "로컬 경로"), + new("서식", $"<<{Sheet}.PrintCstKey>>", "출력 대상 키"), + new("서식", $"<<{Sheet}.PrintCsdStrDtm>>", "출력 기간 시작일시"), + new("서식", $"<<{Sheet}.PrintCsdEndDtm>>", "출력 기간 종료일시"), + new("서식 상세(컬럼 지정)", $"<<{Sheet}.ShtMst.item(\"컬럼명\")>>", "서식 마스터 E_ShtMst 의 컬럼", NeedsColumn: true), + new("서식 상세(컬럼 지정)", $"<<{Sheet}.SdgMst.item(\"컬럼명\")>>", "디자인 마스터 E_SdgMst 의 컬럼", NeedsColumn: true), + + // ── 작업자(bzWorkInfo) — 스칼라 ── new("작업자", $"<<{Work}.WrkUid>>", "작성자 ID"), - new("작업자", $"<<{Work}.WrkDte>>", "작업일자(yyyyMMdd)"), + new("작업자", $"<<{Work}.WrkDte>>", "작업일자 yyyyMMdd"), new("작업자", $"<<{Work}.WrkDtm>>", "작업일시"), }; - /// 런타임이 완전일치로 비교하는 접두어 3종 — 검증·진단용 + /// 런타임이 StartsWith 로 비교하는 접두어 3종 — 검증·진단용 public static readonly IReadOnlyList Prefixes = new[] { Patient, Sheet, Work }; + + /// + /// clsMDataTable.vb:113 의 정규식을 그대로 옮긴 것 — 클래스명 뒤 경로가 이 형태여야 값이 나온다. + /// item 이 소문자여야 하는 것까지 포함해 원문 그대로 쓴다(대소문자 무시 옵션을 주면 안 된다). + /// + private static readonly System.Text.RegularExpressions.Regex PathPattern = + new("^([a-zA-Z_]\\w*)(?:\\.item\\(\"([^\"]+)\"\\)|\\(\"([^\"]+)\"\\))?$", + System.Text.RegularExpressions.RegexOptions.Compiled); + #endregion + + #region Methods + /// + /// 이 토큰이 런타임에서 치환될 수 있는 형태인지 — <<...>> 를 포함한 원문을 받는다. + /// + /// 목록에 없는 토큰이라고 전부 틀린 것은 아니다. DataRow 컬럼은 무한히 많아 목록에 담을 수 없으므로 + /// 형태로 판정한다. 반대로 접두어가 틀리면 런타임이 조용히 실패하므로 여기서 잡아야 한다. + /// + public static bool IsResolvable(string token) + { + if (!token.StartsWith("<<", StringComparison.Ordinal) || !token.EndsWith(">>", StringComparison.Ordinal)) + { + return false; + } + var inner = token[2..^2]; + foreach (var prefix in Prefixes) + { + if (!inner.StartsWith(prefix, StringComparison.Ordinal)) + { + continue; + } + // 클래스명 바로 뒤에는 점 하나가 와야 한다 + if (inner.Length <= prefix.Length || inner[prefix.Length] != '.') + { + continue; + } + return PathPattern.IsMatch(inner[(prefix.Length + 1)..]); + } + return false; + } #endregion } diff --git a/src/SheetMe.Core/Catalog/SqlTokenizer.cs b/src/SheetMe.Core/Catalog/SqlTokenizer.cs index 756538a..5133f2a 100644 --- a/src/SheetMe.Core/Catalog/SqlTokenizer.cs +++ b/src/SheetMe.Core/Catalog/SqlTokenizer.cs @@ -139,7 +139,11 @@ public static class SqlTokenizer continue; } - // 문자열 — Oracle 은 '' 로 작은따옴표를 이스케이프한다 + // 문자열 — Oracle 은 '' 로 작은따옴표를 이스케이프한다. + // + // 안에 든 치환 변수는 따로 떼어 낸다. 엔진이 값에 따옴표를 붙여 주지 않으므로 + // 문자열 비교에 쓰려면 '<<...>>' 처럼 감싸는 것이 정상 사용법이다 — + // 통째로 문자열로 칠해 버리면 가장 흔한 형태의 변수가 색도 검증도 못 받는다. if (ch == '\'') { var j = i + 1; @@ -165,7 +169,7 @@ public static class SqlTokenizer j++; } FlushPlain(i); - tokens.Add(new SqlToken(i, j - i, closed ? SqlTokenKind.Text : SqlTokenKind.Broken)); + AddStringRun(tokens, text, i, j, closed); i = j; plainStart = i; continue; @@ -213,6 +217,39 @@ public static class SqlTokenizer return tokens; } + /// + /// 문자열 구간 [start, end) 을 조각으로 넣는다 — 안에 든 <<...>> 는 변수로 떼어 낸다. + /// 닫히지 않은 문자열이면 변수가 아닌 부분을 Broken 으로 표시한다. + /// + private static void AddStringRun(List tokens, string text, int start, int end, bool closed) + { + var textKind = closed ? SqlTokenKind.Text : SqlTokenKind.Broken; + var cursor = start; + while (cursor < end) + { + var open = text.IndexOf("<<", cursor, StringComparison.Ordinal); + if (open < 0 || open >= end) + { + break; + } + var close = text.IndexOf(">>", open + 2, StringComparison.Ordinal); + if (close < 0 || close + 2 > end) + { + break; + } + if (open > cursor) + { + tokens.Add(new SqlToken(cursor, open - cursor, textKind)); + } + tokens.Add(new SqlToken(open, close + 2 - open, SqlTokenKind.Variable)); + cursor = close + 2; + } + if (cursor < end) + { + tokens.Add(new SqlToken(cursor, end - cursor, textKind)); + } + } + private static bool IsWordStart(char ch) => char.IsLetter(ch) || ch == '_'; private static bool IsWordChar(char ch) => char.IsLetterOrDigit(ch) || ch == '_' || ch == '$' || ch == '#'; diff --git a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs index 6c9d6a0..a6c93c1 100644 --- a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs +++ b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs @@ -122,14 +122,21 @@ public partial class QueryEditorWindow : Window problems.Add("닫히지 않은 따옴표 또는 << >>"); } - var known = LegacyQueryVariableCatalog.All.Select(v => v.Token).ToHashSet(StringComparer.Ordinal); - var unknown = SqlTokenizer.VariablesIn(text) - .Where(v => !known.Contains(v)) + // 목록에 없다고 전부 틀린 것은 아니다 — DataRow 컬럼은 무한히 많다. + // 런타임이 실제로 해석할 수 있는 '형태'인지로 판정한다(접두어 + clsMDataTable 정규식). + var bad = SqlTokenizer.VariablesIn(text) + .Where(v => !LegacyQueryVariableCatalog.IsResolvable(v)) .Distinct(StringComparer.Ordinal) .ToList(); - if (unknown.Count > 0) + if (bad.Count > 0) { - problems.Add($"목록에 없는 변수 {unknown.Count}건: {unknown[0]}"); + problems.Add($"치환되지 않는 변수 {bad.Count}건: {bad[0]}"); + } + + // 컬럼명을 안 채운 채 저장하면 그 칸은 런타임에서 조용히 빈값이 된다 + if (text.Contains("컬럼명\"", StringComparison.Ordinal)) + { + problems.Add("컬럼명 자리를 채우세요"); } ProblemText.Text = problems.Count == 0 ? string.Empty : string.Join(" · ", problems); @@ -330,8 +337,21 @@ public partial class QueryEditorWindow : Window } var caret = SqlBox.CaretIndex; SqlBox.Text = SqlBox.Text.Insert(caret, variable.Token); - SqlBox.CaretIndex = caret + variable.Token.Length; SqlBox.Focus(); + + // DataRow 형태는 컬럼명을 채워야 완성된다 — 그 자리를 선택해 두면 바로 덮어쓸 수 있다 + const string Placeholder = "컬럼명"; + var placeholderAt = variable.NeedsColumn + ? variable.Token.IndexOf(Placeholder, StringComparison.Ordinal) + : -1; + if (placeholderAt >= 0) + { + SqlBox.Select(caret + placeholderAt, Placeholder.Length); + } + else + { + SqlBox.CaretIndex = caret + variable.Token.Length; + } } #endregion diff --git a/tests/SheetMe.Core.Tests/LegacyQueryVariableCatalogTests.cs b/tests/SheetMe.Core.Tests/LegacyQueryVariableCatalogTests.cs index e3c17d9..c36cf2e 100644 --- a/tests/SheetMe.Core.Tests/LegacyQueryVariableCatalogTests.cs +++ b/tests/SheetMe.Core.Tests/LegacyQueryVariableCatalogTests.cs @@ -3,19 +3,17 @@ using SheetMe.Core.Catalog; namespace SheetMe.Core.Tests; /// -/// 쿼리 치환 변수 카탈로그 테스트 — 레거시 bzDesignSheetLoader.ConvertQuery 의 -/// "접두어를 GetType.FullName 과 완전일치 비교" 규칙을 시뮬레이션한다. +/// 쿼리 치환 변수 카탈로그 테스트. +/// +/// 판정 규칙이 바뀌었다. 예전에는 Substring(0, LastIndexOf(".")) 로 접두어를 잘라 +/// 완전일치 비교한다고 보고 그렇게 검사했는데, 실제 엔진(clsMDataTable.vb:24-71)에서 그 줄은 +/// 주석 처리되어 있고 StartsWith(FullName) + 정규식으로 대체됐다. +/// 그래서 DataRow 접근형(.item("컬럼"))도 유효하다 — 옛 규칙으로 검사하면 그것이 전부 오탐이 된다. +/// 판정은 하나로 모았다. /// [TestClass] public sealed class LegacyQueryVariableCatalogTests { - /// 레거시 ConvertQuery 와 동일한 접두어 추출 — Substring(0, LastIndexOf(".")) - private static string PrefixOf(string token) - { - var inner = token[2..^2]; - return inner[..inner.LastIndexOf('.')]; - } - /// 모든 토큰이 <<…>> 로 감싸이고 중복이 없어야 한다 [TestMethod] public void Tokens_AreWellFormedAndUnique() @@ -31,17 +29,16 @@ public sealed class LegacyQueryVariableCatalogTests } /// - /// 레거시 완전일치 시뮬레이션 — 접두어가 런타임 비교 대상 3종 중 하나와 정확히 같아야 한다. - /// 축약형이나 DataRow 접근형(.item("컬럼"))이 섞이면 여기서 걸린다. + /// 모든 토큰이 실제 엔진 규약으로 해석돼야 한다 — 접두어 StartsWith + clsMDataTable 정규식. + /// 축약형이 섞이면 여기서 걸린다(치환되지 않고 SQL 에 그대로 남아 ORA 오류가 난다). /// [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 에 그대로 남습니다."); } } diff --git a/tests/SheetMe.Core.Tests/QueryVariableResolveTests.cs b/tests/SheetMe.Core.Tests/QueryVariableResolveTests.cs new file mode 100644 index 0000000..062f6e3 --- /dev/null +++ b/tests/SheetMe.Core.Tests/QueryVariableResolveTests.cs @@ -0,0 +1,115 @@ +using SheetMe.Core.Catalog; + +namespace SheetMe.Core.Tests; + +/// +/// 치환 변수 형태 판정 테스트 — 실제 엔진 clsMDataTable.ConvertQuery 규약을 옮긴 것. +/// +/// 규약을 어기면 <<...>> 가 SQL 에 그대로 남아 ORA 오류가 나거나 +/// 빈 문자열로 조용히 치환된다. 둘 다 화면에는 아무 표시가 없어 배포 후에야 드러난다. +/// +[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"; + + /// 카탈로그에 실린 토큰은 전부 런타임이 해석할 수 있어야 한다 — 자기모순 방지 + [TestMethod] + public void EveryCatalogTokenIsResolvable() + { + foreach (var variable in LegacyQueryVariableCatalog.All) + { + Assert.IsTrue(LegacyQueryVariableCatalog.IsResolvable(variable.Token), + $"카탈로그 토큰이 규약에 안 맞습니다: {variable.Token}"); + } + } + + /// 컬럼 채우기 틀은 NeedsColumn 표시가 있어야 편집기가 그 자리를 선택해 준다 + [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}>>")); + + /// + /// DataRow 접근형 2가지가 모두 유효하다 — clsMDataTable.vb:113 정규식이 둘 다 받는다. + /// 이 파일의 예전 주석은 '반드시 실패한다'고 했으나 그 근거였던 LastIndexOf(".") 줄은 + /// 실제 엔진에서 주석 처리되어 있다(clsMDataTable.vb:32). + /// + [DataTestMethod] + [DataRow("PatInfDR.item(\"PatNam\")")] + [DataRow("PatInfDR(\"PatNam\")")] + [DataRow("CowInfDR.item(\"CowWadCod\")")] + public void DataRowAccessResolves(string path) + => Assert.IsTrue(LegacyQueryVariableCatalog.IsResolvable($"<<{Patient}.{path}>>")); + + /// item 은 반드시 소문자 — 정규식에 IgnoreCase 가 없다 + [DataTestMethod] + [DataRow("PatInfDR.Item(\"PatNam\")")] + [DataRow("PatInfDR.ITEM(\"PatNam\")")] + public void UpperCaseItemDoesNotResolve(string path) + => Assert.IsFalse(LegacyQueryVariableCatalog.IsResolvable($"<<{Patient}.{path}>>"), + "item 을 대문자로 쓰면 런타임이 값을 못 찾고 빈 문자열로 치환한다"); + + /// 접두어를 줄여 쓰면 치환되지 않는다 — 가장 흔한 실수다 + [DataTestMethod] + [DataRow("<>")] + [DataRow("<>")] + [DataRow("<>")] + public void ShortenedPrefixDoesNotResolve(string token) + => Assert.IsFalse(LegacyQueryVariableCatalog.IsResolvable(token)); + + /// 클래스명 뒤에 점이 없으면 안 된다(접두어가 더 긴 이름의 앞부분인 경우 방지) + [TestMethod] + public void PrefixMustBeFollowedByDot() + => Assert.IsFalse(LegacyQueryVariableCatalog.IsResolvable($"<<{Patient}Extra.ChtNum>>")); + + /// 중첩 경로는 정규식이 받지 않는다 — 한 단계만 허용한다 + [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("< Assert.IsFalse(LegacyQueryVariableCatalog.IsResolvable(token)); + + /// + /// 편집기가 실제로 쓰는 흐름 — 쿼리에서 뽑은 변수를 그대로 판정에 넘긴다. + /// + [TestMethod] + public void TokenizerOutputFeedsResolverDirectly() + { + var sql = $"SELECT * FROM P_PatMst WHERE ChtNum = '<<{Patient}.ChtNum>>' " + + $"AND Nam = '<<{Patient}.PatInfDR.item(\"PatNam\")>>' AND X = '<>'"; + + var bad = SqlTokenizer.VariablesIn(sql) + .Where(v => !LegacyQueryVariableCatalog.IsResolvable(v)) + .ToList(); + + CollectionAssert.AreEqual(new[] { "<>" }, bad.ToArray()); + } +} diff --git a/tests/SheetMe.Core.Tests/SqlTokenizerTests.cs b/tests/SheetMe.Core.Tests/SqlTokenizerTests.cs index 2aa55bb..c6f680d 100644 --- a/tests/SheetMe.Core.Tests/SqlTokenizerTests.cs +++ b/tests/SheetMe.Core.Tests/SqlTokenizerTests.cs @@ -99,6 +99,39 @@ public sealed class SqlTokenizerTests PieceOf(sql, SqlTokenKind.Variable)); } + /// + /// 따옴표 안의 치환 변수도 변수로 잡아야 한다 — 이게 오히려 정상 사용법이다. + /// 엔진은 값에 따옴표를 붙여 주지 않으므로(Replace 원문 삽입) 문자열 비교에 쓰려면 + /// SQL 쪽에서 '<<...>>' 로 감싸야 한다. 통째로 문자열로 칠하면 + /// 가장 흔한 형태의 변수가 색도 검증도 못 받는다. + /// + [TestMethod] + public void Tokenize_FindsVariableInsideStringLiteral() + { + const string sql = "WHERE ChtNum = '<>'"; + + CollectionAssert.AreEqual( + new[] { "<>" }, + SqlTokenizer.VariablesIn(sql).ToArray()); + // 감싼 따옴표는 여전히 문자열로 남는다 + Assert.AreEqual(SqlTokenKind.Text, KindAt(sql, sql.IndexOf('\''))); + } + + /// 변수를 떼어 내도 전 구간 덮기 불변식은 유지돼야 한다 + [TestMethod] + public void Tokenize_StillCoversEverythingWhenVariableIsInsideString() + { + const string sql = "SELECT '앞<>뒤' 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() {