namespace SheetMe.Core.Catalog; /// SQL 조각의 종류 — 색을 정하는 단위 public enum SqlTokenKind { /// 그 밖(식별자·연산자·공백) Plain, /// 예약어(SELECT, FROM …) Keyword, /// 내장 함수(NVL, TO_CHAR …) Function, /// 숫자 리터럴 Number, /// 문자열 리터럴('...') Text, /// 주석(-- 또는 /* */) Comment, /// 치환 변수(<<...>>) — 이 편집기에서 가장 중요한 조각 Variable, /// 따옴표가 닫히지 않은 문자열처럼, 그대로 두면 오류가 나는 조각 Broken, } /// 텍스트 안의 한 조각 public readonly record struct SqlToken(int Start, int Length, SqlTokenKind Kind) { /// 조각 끝(배타) public int End => Start + Length; } /// /// SQL 구문 조각내기 — 편집기의 색칠 근거. /// /// 왜 직접 만드는가. 서드파티 편집기 컴포넌트를 배포본에 넣지 않기로 했다. /// 필요한 것은 완전한 파서가 아니라 보고 판단할 수 있을 만큼의 구분이다: /// 예약어·문자열·주석·숫자, 그리고 이 시스템에서만 의미가 있는 <<치환변수>>. /// /// Broken 을 따로 두는 이유. 이 쿼리는 실행 시점에 치환된 뒤 EMR 런타임에서 돌고, /// 실패해도 빈 catch 가 삼켜 화면에는 아무 표시가 없다. 닫히지 않은 따옴표처럼 /// 확실히 깨진 조각은 편집기에서 즉시 눈에 띄어야 한다. /// /// 방언은 Oracle 기준이다(M.CMM.DBAccess 가 Oracle.ManagedDataAccess 를 쓴다). /// public static class SqlTokenizer { #region Member Fields /// Oracle SQL 예약어 — 대소문자 무시 public static readonly IReadOnlySet Keywords = new HashSet(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", }; /// 자주 쓰는 Oracle 내장 함수 — 예약어와 색을 달리해 눈에 덜 튀게 public static readonly IReadOnlySet Functions = new HashSet(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 /// /// 전체를 조각으로 나눈다 — 조각은 겹치지 않고 순서대로이며, 빈틈은 Plain 으로 채워진다. /// 색칠하는 쪽이 위치 계산을 다시 하지 않도록 전 구간을 덮는다. /// public static IReadOnlyList Tokenize(string text) { var tokens = new List(); 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; } /// /// 문자열 구간 [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 == '#'; /// /// 텍스트에 든 치환 변수 토큰 전부 — 검증·요약용. /// public static IReadOnlyList VariablesIn(string text) { var found = new List(); foreach (var token in Tokenize(text)) { if (token.Kind == SqlTokenKind.Variable) { found.Add(text.Substring(token.Start, token.Length)); } } return found; } #endregion }