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>
174 lines
6.7 KiB
C#
174 lines
6.7 KiB
C#
using SheetMe.Core.Catalog;
|
|
|
|
namespace SheetMe.Core.Tests;
|
|
|
|
/// <summary>
|
|
/// SQL 조각내기 테스트.
|
|
///
|
|
/// 색칠이 어긋나면 편집기가 거짓말을 한다 — 특히 치환 변수와 '깨진 조각' 판정이 그렇다.
|
|
/// 이 쿼리는 EMR 런타임에서 돌고 실패해도 빈 catch 가 삼키므로,
|
|
/// 편집기에서 눈에 띄지 않으면 배포된 뒤에야 드러난다.
|
|
/// </summary>
|
|
[TestClass]
|
|
public sealed class SqlTokenizerTests
|
|
{
|
|
private static SqlTokenKind KindAt(string text, int index)
|
|
{
|
|
foreach (var token in SqlTokenizer.Tokenize(text))
|
|
{
|
|
if (index >= token.Start && index < token.End)
|
|
{
|
|
return token.Kind;
|
|
}
|
|
}
|
|
return SqlTokenKind.Plain;
|
|
}
|
|
|
|
private static string PieceOf(string text, SqlTokenKind kind)
|
|
{
|
|
foreach (var token in SqlTokenizer.Tokenize(text))
|
|
{
|
|
if (token.Kind == kind)
|
|
{
|
|
return text.Substring(token.Start, token.Length);
|
|
}
|
|
}
|
|
return string.Empty;
|
|
}
|
|
|
|
/// <summary>조각은 겹치지 않고 전 구간을 덮어야 한다 — 빈틈이 있으면 그 글자가 안 그려진다</summary>
|
|
[TestMethod]
|
|
public void Tokenize_CoversEveryCharacterExactlyOnce()
|
|
{
|
|
const string sql = "SELECT NVL(a,0) /* c */ FROM t WHERE x='y' -- 끝\nAND z=1";
|
|
|
|
var tokens = SqlTokenizer.Tokenize(sql);
|
|
|
|
var position = 0;
|
|
foreach (var token in tokens)
|
|
{
|
|
Assert.AreEqual(position, token.Start, $"빈틈 또는 겹침: {token.Start} 에서");
|
|
position = token.End;
|
|
}
|
|
Assert.AreEqual(sql.Length, position, "마지막까지 덮지 못했습니다");
|
|
}
|
|
|
|
[TestMethod]
|
|
public void Tokenize_MarksKeywordsAndFunctions()
|
|
{
|
|
const string sql = "SELECT NVL(x,0) FROM DUAL";
|
|
|
|
Assert.AreEqual(SqlTokenKind.Keyword, KindAt(sql, 0), "SELECT");
|
|
Assert.AreEqual(SqlTokenKind.Function, KindAt(sql, 7), "NVL");
|
|
Assert.AreEqual(SqlTokenKind.Keyword, KindAt(sql, 16), "FROM");
|
|
}
|
|
|
|
/// <summary>예약어는 대소문자를 가리지 않는다 — 실서식에 소문자 쿼리가 섞여 있다</summary>
|
|
[TestMethod]
|
|
public void Tokenize_KeywordsAreCaseInsensitive()
|
|
=> Assert.AreEqual(SqlTokenKind.Keyword, KindAt("select 1 from dual", 0));
|
|
|
|
/// <summary>식별자 안에 든 예약어 조각은 예약어가 아니다(SELECTED 를 SELECT 로 칠하면 안 된다)</summary>
|
|
[TestMethod]
|
|
public void Tokenize_DoesNotMatchKeywordInsideIdentifier()
|
|
=> Assert.AreEqual(SqlTokenKind.Plain, KindAt("SELECTED_FLAG", 0));
|
|
|
|
[TestMethod]
|
|
public void Tokenize_FindsStringsAndComments()
|
|
{
|
|
Assert.AreEqual("'가나'", PieceOf("WHERE nam='가나'", SqlTokenKind.Text));
|
|
Assert.AreEqual("-- 설명", PieceOf("SELECT 1 -- 설명\nFROM t", SqlTokenKind.Comment));
|
|
Assert.AreEqual("/* 블록 */", PieceOf("SELECT /* 블록 */ 1", SqlTokenKind.Comment));
|
|
}
|
|
|
|
/// <summary>Oracle 은 '' 로 작은따옴표를 이스케이프한다 — 거기서 문자열이 끊기면 안 된다</summary>
|
|
[TestMethod]
|
|
public void Tokenize_HandlesDoubledQuoteEscape()
|
|
=> Assert.AreEqual("'it''s'", PieceOf("WHERE x='it''s'", SqlTokenKind.Text));
|
|
|
|
/// <summary>
|
|
/// 치환 변수는 이 편집기의 핵심 조각이다 — 통째로 하나로 잡혀야 한다.
|
|
/// 안쪽 점·대문자 때문에 여러 조각으로 쪼개지면 색이 얼룩진다.
|
|
/// </summary>
|
|
[TestMethod]
|
|
public void Tokenize_TreatsSubstitutionVariableAsOnePiece()
|
|
{
|
|
const string sql = "WHERE ChtNum = <<M.CMM.HISOperatingInfo.bzPatientInfo.ChtNum>>";
|
|
|
|
Assert.AreEqual("<<M.CMM.HISOperatingInfo.bzPatientInfo.ChtNum>>",
|
|
PieceOf(sql, SqlTokenKind.Variable));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 따옴표 안의 치환 변수도 변수로 잡아야 한다 — 이게 오히려 정상 사용법이다.
|
|
/// 엔진은 값에 따옴표를 붙여 주지 않으므로(Replace 원문 삽입) 문자열 비교에 쓰려면
|
|
/// SQL 쪽에서 '<<...>>' 로 감싸야 한다. 통째로 문자열로 칠하면
|
|
/// 가장 흔한 형태의 변수가 색도 검증도 못 받는다.
|
|
/// </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()
|
|
{
|
|
const string sql = "SELECT <<A.B.C>> , <<D.E.F>> FROM t";
|
|
|
|
CollectionAssert.AreEqual(new[] { "<<A.B.C>>", "<<D.E.F>>" }, SqlTokenizer.VariablesIn(sql).ToArray());
|
|
}
|
|
|
|
/// <summary>
|
|
/// 닫히지 않은 조각은 Broken 이다. 그대로 저장하면 런타임이 조용히 실패하므로
|
|
/// 편집기에서 즉시 눈에 띄어야 한다.
|
|
/// </summary>
|
|
[DataTestMethod]
|
|
[DataRow("WHERE x = <<M.CMM.a.b", "변수를 닫지 않음")]
|
|
[DataRow("WHERE nam='가나", "문자열을 닫지 않음")]
|
|
public void Tokenize_FlagsUnclosedPieces(string sql, string why)
|
|
{
|
|
var hasBroken = SqlTokenizer.Tokenize(sql).Any(t => t.Kind == SqlTokenKind.Broken);
|
|
|
|
Assert.IsTrue(hasBroken, why);
|
|
}
|
|
|
|
/// <summary>줄바꿈을 넘긴 문자열은 닫는 따옴표를 빠뜨린 것으로 본다</summary>
|
|
[TestMethod]
|
|
public void Tokenize_UnclosedStringDoesNotSwallowFollowingLines()
|
|
{
|
|
const string sql = "WHERE a='열림\nAND b=1";
|
|
|
|
var tokens = SqlTokenizer.Tokenize(sql);
|
|
|
|
Assert.IsTrue(tokens.Any(t => t.Kind == SqlTokenKind.Broken));
|
|
// 다음 줄의 AND 는 정상 예약어로 남아야 한다
|
|
Assert.AreEqual(SqlTokenKind.Keyword, KindAt(sql, sql.IndexOf("AND", StringComparison.Ordinal)));
|
|
}
|
|
|
|
[TestMethod]
|
|
public void Tokenize_EmptyTextYieldsNothing()
|
|
=> Assert.AreEqual(0, SqlTokenizer.Tokenize(string.Empty).Count);
|
|
}
|