Files
SheetMe/src/SheetMe.Core/Catalog/SqlTokenizer.cs
T
MsystechandClaude Opus 5 864f11b0e4 치환 변수 전량 이식 — 그리고 이 파일의 주석이 틀렸었다
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>
2026-08-13 15:10:20 +09:00

274 lines
10 KiB
C#

namespace SheetMe.Core.Catalog;
/// <summary>SQL 조각의 종류 — 색을 정하는 단위</summary>
public enum SqlTokenKind
{
/// <summary>그 밖(식별자·연산자·공백)</summary>
Plain,
/// <summary>예약어(SELECT, FROM …)</summary>
Keyword,
/// <summary>내장 함수(NVL, TO_CHAR …)</summary>
Function,
/// <summary>숫자 리터럴</summary>
Number,
/// <summary>문자열 리터럴('...')</summary>
Text,
/// <summary>주석(-- 또는 /* */)</summary>
Comment,
/// <summary>치환 변수(&lt;&lt;...&gt;&gt;) — 이 편집기에서 가장 중요한 조각</summary>
Variable,
/// <summary>따옴표가 닫히지 않은 문자열처럼, 그대로 두면 오류가 나는 조각</summary>
Broken,
}
/// <summary>텍스트 안의 한 조각</summary>
public readonly record struct SqlToken(int Start, int Length, SqlTokenKind Kind)
{
/// <summary>조각 끝(배타)</summary>
public int End => Start + Length;
}
/// <summary>
/// SQL 구문 조각내기 — 편집기의 색칠 근거.
///
/// <b>왜 직접 만드는가.</b> 서드파티 편집기 컴포넌트를 배포본에 넣지 않기로 했다.
/// 필요한 것은 완전한 파서가 아니라 <b>보고 판단할 수 있을 만큼의 구분</b>이다:
/// 예약어·문자열·주석·숫자, 그리고 이 시스템에서만 의미가 있는 <c>&lt;&lt;치환변수&gt;&gt;</c>.
///
/// <b>Broken 을 따로 두는 이유.</b> 이 쿼리는 실행 시점에 치환된 뒤 EMR 런타임에서 돌고,
/// 실패해도 빈 catch 가 삼켜 화면에는 아무 표시가 없다. 닫히지 않은 따옴표처럼
/// 확실히 깨진 조각은 편집기에서 즉시 눈에 띄어야 한다.
///
/// 방언은 Oracle 기준이다(M.CMM.DBAccess 가 Oracle.ManagedDataAccess 를 쓴다).
/// </summary>
public static class SqlTokenizer
{
#region Member Fields
/// <summary>Oracle SQL 예약어 — 대소문자 무시</summary>
public static readonly IReadOnlySet<string> Keywords = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"SELECT", "FROM", "WHERE", "AND", "OR", "NOT", "NULL", "IS", "IN", "EXISTS",
"BETWEEN", "LIKE", "ORDER", "BY", "GROUP", "HAVING", "DISTINCT", "ALL",
"UNION", "INTERSECT", "MINUS", "JOIN", "INNER", "LEFT", "RIGHT", "FULL",
"OUTER", "CROSS", "ON", "AS", "CASE", "WHEN", "THEN", "ELSE", "END",
"INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", "MERGE", "USING",
"CREATE", "ALTER", "DROP", "TABLE", "VIEW", "INDEX", "WITH", "DUAL",
"ASC", "DESC", "CONNECT", "START", "PRIOR", "LEVEL", "ROWNUM", "ROWID",
"PARTITION", "OVER", "FETCH", "FIRST", "NEXT", "ROWS", "ONLY", "OFFSET",
"PIVOT", "UNPIVOT", "ANY", "SOME", "CAST", "DECODE",
};
/// <summary>자주 쓰는 Oracle 내장 함수 — 예약어와 색을 달리해 눈에 덜 튀게</summary>
public static readonly IReadOnlySet<string> Functions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"NVL", "NVL2", "COALESCE", "SUBSTR", "SUBSTRB", "INSTR", "LENGTH", "LENGTHB",
"TRIM", "LTRIM", "RTRIM", "LPAD", "RPAD", "REPLACE", "TRANSLATE", "UPPER", "LOWER",
"TO_CHAR", "TO_DATE", "TO_NUMBER", "TO_TIMESTAMP", "SYSDATE", "SYSTIMESTAMP",
"ADD_MONTHS", "MONTHS_BETWEEN", "LAST_DAY", "NEXT_DAY", "TRUNC", "ROUND",
"COUNT", "SUM", "AVG", "MIN", "MAX", "ROW_NUMBER", "RANK", "DENSE_RANK",
"LISTAGG", "WM_CONCAT", "GREATEST", "LEAST", "MOD", "ABS", "CEIL", "FLOOR",
"SIGN", "POWER", "SQRT", "REGEXP_LIKE", "REGEXP_SUBSTR", "REGEXP_REPLACE",
};
#endregion
#region Methods
/// <summary>
/// 전체를 조각으로 나눈다 — 조각은 겹치지 않고 순서대로이며, 빈틈은 Plain 으로 채워진다.
/// 색칠하는 쪽이 위치 계산을 다시 하지 않도록 전 구간을 덮는다.
/// </summary>
public static IReadOnlyList<SqlToken> Tokenize(string text)
{
var tokens = new List<SqlToken>();
if (string.IsNullOrEmpty(text))
{
return tokens;
}
var i = 0;
var plainStart = 0;
void FlushPlain(int upto)
{
if (upto > plainStart)
{
tokens.Add(new SqlToken(plainStart, upto - plainStart, SqlTokenKind.Plain));
}
}
while (i < text.Length)
{
var ch = text[i];
// 치환 변수 <<...>> — 닫히지 않으면 SQL 에 그대로 남아 오류가 난다
if (ch == '<' && i + 1 < text.Length && text[i + 1] == '<')
{
var close = text.IndexOf(">>", i + 2, StringComparison.Ordinal);
FlushPlain(i);
if (close < 0)
{
tokens.Add(new SqlToken(i, text.Length - i, SqlTokenKind.Broken));
return tokens;
}
tokens.Add(new SqlToken(i, close + 2 - i, SqlTokenKind.Variable));
i = close + 2;
plainStart = i;
continue;
}
// 한 줄 주석
if (ch == '-' && i + 1 < text.Length && text[i + 1] == '-')
{
var lineEnd = text.IndexOf('\n', i);
var end = lineEnd < 0 ? text.Length : lineEnd;
FlushPlain(i);
tokens.Add(new SqlToken(i, end - i, SqlTokenKind.Comment));
i = end;
plainStart = i;
continue;
}
// 블록 주석
if (ch == '/' && i + 1 < text.Length && text[i + 1] == '*')
{
var close = text.IndexOf("*/", i + 2, StringComparison.Ordinal);
var end = close < 0 ? text.Length : close + 2;
FlushPlain(i);
tokens.Add(new SqlToken(i, end - i, SqlTokenKind.Comment));
i = end;
plainStart = i;
continue;
}
// 문자열 — Oracle 은 '' 로 작은따옴표를 이스케이프한다.
//
// 안에 든 치환 변수는 따로 떼어 낸다. 엔진이 값에 따옴표를 붙여 주지 않으므로
// 문자열 비교에 쓰려면 '<<...>>' 처럼 감싸는 것이 정상 사용법이다 —
// 통째로 문자열로 칠해 버리면 가장 흔한 형태의 변수가 색도 검증도 못 받는다.
if (ch == '\'')
{
var j = i + 1;
var closed = false;
while (j < text.Length)
{
if (text[j] == '\'')
{
if (j + 1 < text.Length && text[j + 1] == '\'')
{
j += 2;
continue;
}
closed = true;
j++;
break;
}
// 줄바꿈을 넘긴 문자열은 거의 항상 닫는 따옴표를 빠뜨린 것이다
if (text[j] == '\n')
{
break;
}
j++;
}
FlushPlain(i);
AddStringRun(tokens, text, i, j, closed);
i = j;
plainStart = i;
continue;
}
// 숫자
if (char.IsDigit(ch) && (i == 0 || !IsWordChar(text[i - 1])))
{
var j = i;
while (j < text.Length && (char.IsDigit(text[j]) || text[j] == '.'))
{
j++;
}
FlushPlain(i);
tokens.Add(new SqlToken(i, j - i, SqlTokenKind.Number));
i = j;
plainStart = i;
continue;
}
// 낱말 — 예약어/함수만 골라낸다
if (IsWordStart(ch))
{
var j = i;
while (j < text.Length && IsWordChar(text[j]))
{
j++;
}
var word = text[i..j];
if (Keywords.Contains(word) || Functions.Contains(word))
{
FlushPlain(i);
tokens.Add(new SqlToken(i, j - i,
Keywords.Contains(word) ? SqlTokenKind.Keyword : SqlTokenKind.Function));
plainStart = j;
}
i = j;
continue;
}
i++;
}
FlushPlain(text.Length);
return tokens;
}
/// <summary>
/// 문자열 구간 [start, end) 을 조각으로 넣는다 — 안에 든 <c>&lt;&lt;...&gt;&gt;</c> 는 변수로 떼어 낸다.
/// 닫히지 않은 문자열이면 변수가 아닌 부분을 Broken 으로 표시한다.
/// </summary>
private static void AddStringRun(List<SqlToken> 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 == '#';
/// <summary>
/// 텍스트에 든 치환 변수 토큰 전부 — 검증·요약용.
/// </summary>
public static IReadOnlyList<string> VariablesIn(string text)
{
var found = new List<string>();
foreach (var token in Tokenize(text))
{
if (token.Kind == SqlTokenKind.Variable)
{
found.Add(text.Substring(token.Start, token.Length));
}
}
return found;
}
#endregion
}