쿼리 편집기 레이아웃 — 카드 두 장, 갈래 칩, 그리고 《컨트롤명》 치환

목업대로 다시 짰다. 색·모서리·간격은 SheetMe 토큰을 그대로 쓴다.

■ 레이아웃

머리말(제목 + 대상 칩) / SQL 편집기 카드 / 치환 변수 카드 / 아래 버튼 줄.
편집기 카드에는 툴바(포맷·주석·자동완성 · 전체 지우기)와 상태 줄(연결 상태 · 글자·줄 수 · 줄,열)을 뒀다.
변수 카드에는 검색 + 갈래 칩 + 접이식 그룹 + 행마다 + 버튼을 뒀다.

+ 버튼을 넣은 이유는 더블클릭이 유일한 삽입 수단이었기 때문이다 — 화면에 드러나지 않는 조작이다.

■ 《컨트롤명》 치환 — 아예 없던 기능

레거시 화면에는 Controls 목록이 따로 있었다. 우리에겐 그 문법 자체가 없었다.
clsMDataTable.ConvertQuery 는 <<…>> 를 모두 치환한 뒤 《 로 다시 쪼개
poAllControlOnSheet(이름).GETVALUE 를 박는다(:78-94). 즉 같은 서식의 다른 컨트롤 값을
쿼리 조건에 넣을 수 있다.

이제 이 서식의 컨트롤이 '컨트롤' 갈래로 목록에 뜨고, 토크나이저도 《》 를 변수로 잡아
색이 붙고 검증에 걸린다. 다만 디자인 시점에는 컨트롤 목록이 없어 런타임이 무조건 "0" 으로
치환한다(:86) — 검증 실행이 통과해도 운영에서는 다른 값이 들어간다는 뜻이라 코드에 적어 뒀다.

■ 형식(String/Int32/DataRow) 표시

레거시가 '목록/형식' 두 열로 보여 주던 정보다. DataRow 인지 아닌지가 곧
'컬럼명을 더 채워야 하는가'를 뜻해서 고를 때 필요하다.

■ 갈래 칩

그룹이 일곱이라 칩으로 다 늘어놓으면 그것대로 목록이 된다.
'값이 어디서 오는가'로 네 갈래(환자·서식·컨트롤·작업자)로 묶고, 같은 칩을 다시 누르면 해제된다.

■ 포맷(Ctrl+Shift+F 상당 — 툴바 버튼)

예약어·함수를 대문자로 바꾸고 주요 절 앞에서 줄을 나누며 괄호 깊이만큼 들여쓴다.
문자열·주석·치환 변수는 손대지 않는다 — 특히 치환 변수는 대소문자가 맞아야 런타임이 찾으므로
대문자로 바꾸면 그 칸이 조용히 빈다. 이 포매터가 저지를 수 있는 가장 나쁜 일이라 테스트로 막았다.
두 번 눌러도 결과가 같다(안정성도 테스트로 고정).

단위 테스트 14건 추가(포매터 11 · 컨트롤 치환 3).
회귀: 테스트 230/230, 편집 스모크 실패 0, 팝업 점검 7/7, 검증 실행 점검 10/10,
DB 왕복 1,271건 diff 0/예외 0, 종이 렌더 P062 바이트 동일.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-13 15:53:56 +09:00
co-authored by Claude Opus 5
parent 7ce7506286
commit 68fc01c6ba
13 changed files with 847 additions and 168 deletions
@@ -6,7 +6,8 @@ namespace SheetMe.Core.Catalog;
/// <paramref name="NeedsColumn"/> 이 true 면 컬럼명을 채워야 완성된다(DataRow 접근형).
/// 삽입 후 사용자가 <c>컬럼명</c> 자리를 고쳐야 하므로 편집기가 그 부분을 선택해 준다.
/// </summary>
public sealed record QueryVariable(string Group, string Token, string Description, bool NeedsColumn = false)
public sealed record QueryVariable(
string Group, string Token, string Description, bool NeedsColumn = false, string TypeName = "String")
{
/// <summary>
/// 목록에 띄우는 짧은 형태 — 클래스 전체 이름을 뺀 나머지.
@@ -15,6 +16,18 @@ public sealed record QueryVariable(string Group, string Token, string Descriptio
/// 정작 다른 부분(속성명)이 오른쪽 끝에서 잘려 무엇이 무엇인지 구분되지 않는다.
/// 전체 문자열은 툴팁과 삽입 결과로 확인할 수 있다.
/// </summary>
/// <summary>
/// 상단 칩으로 거르는 큰 갈래 — 그룹은 일곱이라 칩으로 늘어놓으면 그것대로 목록이 된다.
/// 갈래는 '값이 어디서 오는가'로 나눈다.
/// </summary>
public string Bucket => Group switch
{
"환자" or "외부연계" or "환자 상세(컬럼 지정)" => "환자",
"서식" or "서식 상세(컬럼 지정)" => "서식",
"작업자" => "작업자",
_ => "컨트롤",
};
public string ShortForm
{
get
@@ -83,14 +96,14 @@ public static class LegacyQueryVariableCatalog
// ── 환자(bzPatientInfo) — 스칼라 ──
new("환자", $"<<{Patient}.ChtNum>>", "차트번호(등록번호)"),
new("환자", $"<<{Patient}.ComNum>>", "내원번호"),
new("환자", $"<<{Patient}.ComNum_Refer>>", "참조 내원번호(없으면 0)"),
new("환자", $"<<{Patient}.ComNum_Refer>>", "참조 내원번호(없으면 0)", TypeName: "Int32"),
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}.AgeInMonth>>", "개월수", TypeName: "Int32"),
new("환자", $"<<{Patient}.Sex>>", "성별"),
new("환자", $"<<{Patient}.PatMblPhn>>", "휴대전화번호"),
new("환자", $"<<{Patient}.ComDayCar>>", "낮병동 여부 Y/N"),
@@ -101,11 +114,11 @@ public static class LegacyQueryVariableCatalog
// 외부연계(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}.OemNum1>>", "숫자키1(주로 처방키)", TypeName: "Int32"),
new("외부연계", $"<<{Patient}.OemNum2>>", "숫자키2", TypeName: "Int32"),
new("외부연계", $"<<{Patient}.OemNum3>>", "숫자키3", TypeName: "Int32"),
new("외부연계", $"<<{Patient}.OemNum4>>", "숫자키4", TypeName: "Int32"),
new("외부연계", $"<<{Patient}.OemNum5>>", "숫자키5", TypeName: "Int32"),
new("외부연계", $"<<{Patient}.OemVar1>>", "문자값1"),
new("외부연계", $"<<{Patient}.OemVar2>>", "문자값2"),
new("외부연계", $"<<{Patient}.OemVar3>>", "문자값3"),
@@ -114,30 +127,30 @@ public static class LegacyQueryVariableCatalog
// ── 환자 상세(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),
new("환자 상세(컬럼 지정)", $"<<{Patient}.PatInfDR.item(\"\")>>", "환자 기본정보 P_PatInf 의 컬럼", NeedsColumn: true, TypeName: "DataRow"),
new("환자 상세(컬럼 지정)", $"<<{Patient}.ComInfDR.item(\"\")>>", "내원정보 P_ComInf 의 컬럼", NeedsColumn: true, TypeName: "DataRow"),
new("환자 상세(컬럼 지정)", $"<<{Patient}.ComInfDR_Refer.item(\"\")>>", "참조 내원의 P_ComInf 컬럼", NeedsColumn: true, TypeName: "DataRow"),
new("환자 상세(컬럼 지정)", $"<<{Patient}.CodInfDR.item(\"\")>>", "진료과·담당의 P_CodInf 의 컬럼", NeedsColumn: true, TypeName: "DataRow"),
new("환자 상세(컬럼 지정)", $"<<{Patient}.CoiInfDR.item(\"\")>>", "보험 자격정보 P_CoiInf 의 컬럼", NeedsColumn: true, TypeName: "DataRow"),
new("환자 상세(컬럼 지정)", $"<<{Patient}.CowInfDR.item(\"\")>>", "입원 병동·병실 P_CowInf 의 컬럼", NeedsColumn: true, TypeName: "DataRow"),
// ── 서식(bzSheetInfo) — 스칼라 ──
new("서식", $"<<{Sheet}.ShtCod>>", "서식 코드"),
new("서식", $"<<{Sheet}.ShtNam>>", "서식명"),
new("서식", $"<<{Sheet}.EmrKey>>", "EMR 키(신규 작성 시 0)"),
new("서식", $"<<{Sheet}.EmrKey>>", "EMR 키(신규 작성 시 0)", TypeName: "Int32"),
new("서식", $"<<{Sheet}.AdpDtm>>", "작성일시"),
new("서식", $"<<{Sheet}.TemKey>>", "템플릿 키"),
new("서식", $"<<{Sheet}.TemKey>>", "템플릿 키", TypeName: "Int32"),
new("서식", $"<<{Sheet}.PatTyp>>", "서식 기준 환자구분"),
new("서식", $"<<{Sheet}.EmrGbn>>", "EMR 구분"),
new("서식", $"<<{Sheet}.OdrNum>>", "처방 번호"),
new("서식", $"<<{Sheet}.OdrSeq>>", "처방 일련번호"),
new("서식", $"<<{Sheet}.LocalPath>>", "로컬 경로"),
new("서식", $"<<{Sheet}.PrintCstKey>>", "출력 대상 키"),
new("서식", $"<<{Sheet}.PrintCstKey>>", "출력 대상 키", TypeName: "Int32"),
new("서식", $"<<{Sheet}.PrintCsdStrDtm>>", "출력 기간 시작일시"),
new("서식", $"<<{Sheet}.PrintCsdEndDtm>>", "출력 기간 종료일시"),
new("서식 상세(컬럼 지정)", $"<<{Sheet}.ShtMst.item(\"\")>>", "서식 마스터 E_ShtMst 의 컬럼", NeedsColumn: true),
new("서식 상세(컬럼 지정)", $"<<{Sheet}.SdgMst.item(\"\")>>", "디자인 마스터 E_SdgMst 의 컬럼", NeedsColumn: true),
new("서식 상세(컬럼 지정)", $"<<{Sheet}.ShtMst.item(\"\")>>", "서식 마스터 E_ShtMst 의 컬럼", NeedsColumn: true, TypeName: "DataRow"),
new("서식 상세(컬럼 지정)", $"<<{Sheet}.SdgMst.item(\"\")>>", "디자인 마스터 E_SdgMst 의 컬럼", NeedsColumn: true, TypeName: "DataRow"),
// ── 작업자(bzWorkInfo) — 스칼라 ──
new("작업자", $"<<{Work}.WrkUid>>", "작성자 ID"),
@@ -148,6 +161,28 @@ public static class LegacyQueryVariableCatalog
/// <summary>런타임이 StartsWith 로 비교하는 접두어 3종 — 검증·진단용</summary>
public static readonly IReadOnlyList<string> Prefixes = new[] { Patient, Sheet, Work };
/// <summary>컨트롤 값 치환 그룹 이름</summary>
public const string ControlGroup = "이 서식의 컨트롤";
/// <summary>
/// 같은 서식의 컨트롤 값을 넣는 치환 — <c>《컨트롤명》</c>.
///
/// <c>&lt;&lt;…&gt;&gt;</c> 와는 <b>다른 문법이고 다른 단계</b>다. clsMDataTable.ConvertQuery 는
/// <c>&lt;&lt;…&gt;&gt;</c> 를 모두 치환한 뒤 <c>《</c> 로 다시 쪼개
/// <c>poAllControlOnSheet(이름).GETVALUE</c> 를 박는다(clsMDataTable.vb:78-94).
///
/// <b>디자인 시점에는 무조건 "0" 으로 치환된다</b>(:86, 컨트롤 목록이 없을 때).
/// 그래서 레거시 편집기에서 실행해 보면 통과하는데 운영에서는 다른 값이 들어간다 —
/// 검증 실행 결과를 그대로 믿으면 안 되는 지점이라 화면에 적어 둔다.
/// </summary>
public static IReadOnlyList<QueryVariable> ControlVariables(IEnumerable<string> controlNames)
=> controlNames
.Where(n => n.Length > 0)
.Distinct(StringComparer.Ordinal)
.OrderBy(n => n, StringComparer.OrdinalIgnoreCase)
.Select(n => new QueryVariable(ControlGroup, $"《{n}》", n, TypeName: "컨트롤 값"))
.ToList();
/// <summary>
/// clsMDataTable.vb:113 의 정규식을 그대로 옮긴 것 — 클래스명 뒤 경로가 이 형태여야 값이 나온다.
/// <c>item</c> 이 소문자여야 하는 것까지 포함해 원문 그대로 쓴다(대소문자 무시 옵션을 주면 안 된다).
@@ -166,6 +201,12 @@ public static class LegacyQueryVariableCatalog
/// </summary>
public static bool IsResolvable(string token)
{
// 《컨트롤명》 은 다른 문법이다 — 이름이 비지만 않으면 형태로는 유효하다.
// 그 이름의 컨트롤이 실제로 있는지는 편집기가 문서를 보고 따로 검사한다.
if (token.StartsWith('《') && token.EndsWith('》'))
{
return token.Length > 2;
}
if (!token.StartsWith("<<", StringComparison.Ordinal) || !token.EndsWith(">>", StringComparison.Ordinal))
{
return false;
+147
View File
@@ -0,0 +1,147 @@
using System.Text;
namespace SheetMe.Core.Catalog;
/// <summary>
/// SQL 보기 좋게 정리 — 예약어 대문자, 주요 절 앞에서 줄바꿈, 괄호 깊이만큼 들여쓰기.
///
/// <b>완전한 포매터가 아니다.</b> 목적은 남이 쓴 한 줄짜리 쿼리를 읽을 수 있게 펼치는 것이지
/// 표준 서식을 강제하는 것이 아니다. 그래서 <b>문자열·주석·치환 변수는 손대지 않는다</b> —
/// 그 안의 내용을 건드리면 뜻이 바뀐다(특히 치환 변수는 대소문자가 맞아야 런타임이 찾는다).
/// </summary>
public static class SqlFormatter
{
#region Member Fields
/// <summary>앞에서 줄을 바꾸는 절</summary>
private static readonly HashSet<string> BreakBefore = new(StringComparer.OrdinalIgnoreCase)
{
"SELECT", "FROM", "WHERE", "GROUP", "HAVING", "ORDER", "UNION", "MINUS", "INTERSECT",
"JOIN", "INNER", "LEFT", "RIGHT", "FULL", "CROSS", "AND", "OR", "SET", "VALUES", "INTO",
};
#endregion
#region Methods
/// <summary>
/// 정리한 결과를 돌려준다. 원문이 비면 그대로 돌려준다.
/// </summary>
public static string Format(string sql)
{
if (string.IsNullOrWhiteSpace(sql))
{
return sql;
}
var tokens = SqlTokenizer.Tokenize(sql);
var builder = new StringBuilder(sql.Length + 64);
var depth = 0;
var atLineStart = true;
void NewLine()
{
// 이미 줄 시작이면 빈 줄을 만들지 않는다
if (atLineStart)
{
return;
}
builder.Append('\n');
atLineStart = true;
}
void Indent()
{
if (atLineStart)
{
builder.Append(' ', Math.Max(0, depth) * 4);
atLineStart = false;
}
}
void Write(string text)
{
Indent();
builder.Append(text);
}
foreach (var token in tokens)
{
var piece = sql.Substring(token.Start, token.Length);
switch (token.Kind)
{
case SqlTokenKind.Keyword:
if (BreakBefore.Contains(piece))
{
NewLine();
}
// 예약어만 대문자로 — 식별자를 건드리면 대소문자를 가리는 DB 에서 깨진다
Write(piece.ToUpperInvariant());
break;
case SqlTokenKind.Comment:
NewLine();
Write(piece);
NewLine();
break;
// 문자열·치환 변수는 원문 그대로. 안을 건드리면 뜻이 바뀐다.
case SqlTokenKind.Text:
case SqlTokenKind.Variable:
case SqlTokenKind.Broken:
case SqlTokenKind.Function:
case SqlTokenKind.Number:
Write(token.Kind == SqlTokenKind.Function ? piece.ToUpperInvariant() : piece);
break;
default:
WritePlain(piece);
break;
}
}
void WritePlain(string piece)
{
foreach (var ch in piece)
{
switch (ch)
{
case '(':
Write("(");
depth++;
break;
case ')':
depth--;
Write(")");
break;
case ',':
Write(",");
NewLine();
break;
case '\n':
NewLine();
break;
case ' ':
case '\t':
case '\r':
// 줄 시작의 공백은 들여쓰기가 대신한다
if (!atLineStart)
{
builder.Append(' ');
}
break;
default:
Write(ch.ToString());
break;
}
}
}
// 줄 끝 공백을 걷어낸다 — 정리했는데 보이지 않는 찌꺼기가 남으면 diff 가 지저분해진다
var lines = builder.ToString().Split('\n');
for (var i = 0; i < lines.Length; i++)
{
lines[i] = lines[i].TrimEnd();
}
return string.Join('\n', lines).Trim('\n');
}
#endregion
}
+28 -5
View File
@@ -115,6 +115,22 @@ public static class SqlTokenizer
continue;
}
// 컨트롤 값 치환 《컨트롤명》 — <<>> 와 다른 문법이고 런타임의 다른 단계에서 처리된다
if (ch == '《')
{
var close = text.IndexOf('》', i + 1);
FlushPlain(i);
if (close < 0)
{
tokens.Add(new SqlToken(i, text.Length - i, SqlTokenKind.Broken));
return tokens;
}
tokens.Add(new SqlToken(i, close + 1 - i, SqlTokenKind.Variable));
i = close + 1;
plainStart = i;
continue;
}
// 한 줄 주석
if (ch == '-' && i + 1 < text.Length && text[i + 1] == '-')
{
@@ -227,13 +243,20 @@ public static class SqlTokenizer
var cursor = start;
while (cursor < end)
{
var open = text.IndexOf("<<", cursor, StringComparison.Ordinal);
// 두 문법 중 먼저 나오는 것을 떼어 낸다
var openAngle = text.IndexOf("<<", cursor, StringComparison.Ordinal);
var openBracket = text.IndexOf('《', cursor);
var useBracket = openBracket >= 0 && (openAngle < 0 || openBracket < openAngle);
var open = useBracket ? openBracket : openAngle;
if (open < 0 || open >= end)
{
break;
}
var close = text.IndexOf(">>", open + 2, StringComparison.Ordinal);
if (close < 0 || close + 2 > end)
var close = useBracket
? text.IndexOf('》', open + 1)
: text.IndexOf(">>", open + 2, StringComparison.Ordinal);
var closeLength = useBracket ? 1 : 2;
if (close < 0 || close + closeLength > end)
{
break;
}
@@ -241,8 +264,8 @@ public static class SqlTokenizer
{
tokens.Add(new SqlToken(cursor, open - cursor, textKind));
}
tokens.Add(new SqlToken(open, close + 2 - open, SqlTokenKind.Variable));
cursor = close + 2;
tokens.Add(new SqlToken(open, close + closeLength - open, SqlTokenKind.Variable));
cursor = close + closeLength;
}
if (cursor < end)
{