diff --git a/src/SheetMe.Core/Catalog/LegacyQueryVariableCatalog.cs b/src/SheetMe.Core/Catalog/LegacyQueryVariableCatalog.cs
index 9895b1c..80d1504 100644
--- a/src/SheetMe.Core/Catalog/LegacyQueryVariableCatalog.cs
+++ b/src/SheetMe.Core/Catalog/LegacyQueryVariableCatalog.cs
@@ -6,7 +6,8 @@ namespace SheetMe.Core.Catalog;
/// 이 true 면 컬럼명을 채워야 완성된다(DataRow 접근형).
/// 삽입 후 사용자가 컬럼명 자리를 고쳐야 하므로 편집기가 그 부분을 선택해 준다.
///
-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")
{
///
/// 목록에 띄우는 짧은 형태 — 클래스 전체 이름을 뺀 나머지.
@@ -15,6 +16,18 @@ public sealed record QueryVariable(string Group, string Token, string Descriptio
/// 정작 다른 부분(속성명)이 오른쪽 끝에서 잘려 무엇이 무엇인지 구분되지 않는다.
/// 전체 문자열은 툴팁과 삽입 결과로 확인할 수 있다.
///
+ ///
+ /// 상단 칩으로 거르는 큰 갈래 — 그룹은 일곱이라 칩으로 늘어놓으면 그것대로 목록이 된다.
+ /// 갈래는 '값이 어디서 오는가'로 나눈다.
+ ///
+ 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
/// 런타임이 StartsWith 로 비교하는 접두어 3종 — 검증·진단용
public static readonly IReadOnlyList Prefixes = new[] { Patient, Sheet, Work };
+ /// 컨트롤 값 치환 그룹 이름
+ public const string ControlGroup = "이 서식의 컨트롤";
+
+ ///
+ /// 같은 서식의 컨트롤 값을 넣는 치환 — 《컨트롤명》.
+ ///
+ /// <<…>> 와는 다른 문법이고 다른 단계다. clsMDataTable.ConvertQuery 는
+ /// <<…>> 를 모두 치환한 뒤 《 로 다시 쪼개
+ /// poAllControlOnSheet(이름).GETVALUE 를 박는다(clsMDataTable.vb:78-94).
+ ///
+ /// 디자인 시점에는 무조건 "0" 으로 치환된다(:86, 컨트롤 목록이 없을 때).
+ /// 그래서 레거시 편집기에서 실행해 보면 통과하는데 운영에서는 다른 값이 들어간다 —
+ /// 검증 실행 결과를 그대로 믿으면 안 되는 지점이라 화면에 적어 둔다.
+ ///
+ public static IReadOnlyList ControlVariables(IEnumerable controlNames)
+ => controlNames
+ .Where(n => n.Length > 0)
+ .Distinct(StringComparer.Ordinal)
+ .OrderBy(n => n, StringComparer.OrdinalIgnoreCase)
+ .Select(n => new QueryVariable(ControlGroup, $"《{n}》", n, TypeName: "컨트롤 값"))
+ .ToList();
+
///
/// clsMDataTable.vb:113 의 정규식을 그대로 옮긴 것 — 클래스명 뒤 경로가 이 형태여야 값이 나온다.
/// item 이 소문자여야 하는 것까지 포함해 원문 그대로 쓴다(대소문자 무시 옵션을 주면 안 된다).
@@ -166,6 +201,12 @@ public static class LegacyQueryVariableCatalog
///
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;
diff --git a/src/SheetMe.Core/Catalog/SqlFormatter.cs b/src/SheetMe.Core/Catalog/SqlFormatter.cs
new file mode 100644
index 0000000..e4922c9
--- /dev/null
+++ b/src/SheetMe.Core/Catalog/SqlFormatter.cs
@@ -0,0 +1,147 @@
+using System.Text;
+
+namespace SheetMe.Core.Catalog;
+
+///
+/// SQL 보기 좋게 정리 — 예약어 대문자, 주요 절 앞에서 줄바꿈, 괄호 깊이만큼 들여쓰기.
+///
+/// 완전한 포매터가 아니다. 목적은 남이 쓴 한 줄짜리 쿼리를 읽을 수 있게 펼치는 것이지
+/// 표준 서식을 강제하는 것이 아니다. 그래서 문자열·주석·치환 변수는 손대지 않는다 —
+/// 그 안의 내용을 건드리면 뜻이 바뀐다(특히 치환 변수는 대소문자가 맞아야 런타임이 찾는다).
+///
+public static class SqlFormatter
+{
+ #region Member Fields
+ /// 앞에서 줄을 바꾸는 절
+ private static readonly HashSet 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
+ ///
+ /// 정리한 결과를 돌려준다. 원문이 비면 그대로 돌려준다.
+ ///
+ 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
+}
diff --git a/src/SheetMe.Core/Catalog/SqlTokenizer.cs b/src/SheetMe.Core/Catalog/SqlTokenizer.cs
index 5133f2a..2647b9e 100644
--- a/src/SheetMe.Core/Catalog/SqlTokenizer.cs
+++ b/src/SheetMe.Core/Catalog/SqlTokenizer.cs
@@ -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)
{
diff --git a/src/SheetMe.Designer/Controls/QueryGroupExpandedConverter.cs b/src/SheetMe.Designer/Controls/QueryGroupExpandedConverter.cs
new file mode 100644
index 0000000..3f089af
--- /dev/null
+++ b/src/SheetMe.Designer/Controls/QueryGroupExpandedConverter.cs
@@ -0,0 +1,24 @@
+using System.Globalization;
+using System.Windows.Data;
+using SheetMe.Core.Catalog;
+
+namespace SheetMe.Designer.Controls;
+
+///
+/// 치환 변수 그룹을 처음에 펼쳐 둘지 — 그룹 이름으로 판단한다.
+///
+/// 일곱 그룹을 다 펴 두면 목록이 길어져 원하는 그룹이 화면 밖으로 밀리고,
+/// 다 접어 두면 무엇이 있는지 한 번은 눌러 봐야 안다.
+/// 가장 많이 쓰는 '환자'와, 이 서식에서만 유효해서 존재 자체가 정보인 '컨트롤'만 펴 둔다.
+///
+public sealed class QueryGroupExpandedConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ var name = value as string ?? string.Empty;
+ return name is "환자" || name == LegacyQueryVariableCatalog.ControlGroup;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ => throw new NotSupportedException();
+}
diff --git a/src/SheetMe.Designer/Diagnostics/DialogShots.cs b/src/SheetMe.Designer/Diagnostics/DialogShots.cs
index 22c0b89..a679aed 100644
--- a/src/SheetMe.Designer/Diagnostics/DialogShots.cs
+++ b/src/SheetMe.Designer/Diagnostics/DialogShots.cs
@@ -113,7 +113,7 @@ public static class DialogShots
{
("01-sheet-open", () => new Views.SheetOpenDialogView(_ => SampleSheets())),
("02-sheet-history", () => new Views.SheetHistoryDialogView("S999", "표본 서식", SampleVersions())),
- ("03-query-editor", () => new Views.QueryEditorWindow("데이터소스",
+ ("03-query-editor", () => NewQueryEditor(designer,
"SELECT ChtNum, PatNam FROM P_PatMst\nWHERE ChtNum = <>")),
// 실제 카탈로그(384종)와 실제 제안 순위로 찍는다 — 표본 몇 개로는 분류·건수가 안 보인다
("04-tag-picker", () => new Views.TagPickerDialogView(
@@ -311,6 +311,10 @@ public static class DialogShots
};
/// 폰트 관리자·미리보기용 표본 문서 — 컨트롤이 있어야 목록이 비지 않는다
+ /// 쿼리 편집기 — 이 서식의 컨트롤을 《컨트롤명》 후보로 넘겨 그룹이 실제로 차는지 본다
+ private static Window NewQueryEditor(DesignerViewModel designer, string sql)
+ => new Views.QueryEditorWindow("데이터소스", sql, controlNames: designer.ControlNames());
+
private static DesignerViewModel SampleDesigner()
{
var document = new FormDocument { FormId = "S999", Title = "표본 서식" };
diff --git a/src/SheetMe.Designer/Themes/Tokens.Dark.xaml b/src/SheetMe.Designer/Themes/Tokens.Dark.xaml
index e80731d..09ecad2 100644
--- a/src/SheetMe.Designer/Themes/Tokens.Dark.xaml
+++ b/src/SheetMe.Designer/Themes/Tokens.Dark.xaml
@@ -47,6 +47,7 @@
오류는 B.CloseHover 와 같은 값이라 다크 팔레트에 새 색이 늘지 않는다. -->
+
diff --git a/src/SheetMe.Designer/Themes/Tokens.Light.xaml b/src/SheetMe.Designer/Themes/Tokens.Light.xaml
index 25ecd1b..c851138 100644
--- a/src/SheetMe.Designer/Themes/Tokens.Light.xaml
+++ b/src/SheetMe.Designer/Themes/Tokens.Light.xaml
@@ -81,6 +81,7 @@
-->
+
diff --git a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs
index 1f4f8b1..be54821 100644
--- a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs
+++ b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs
@@ -946,6 +946,13 @@ public sealed class DesignerViewModel : ViewModelBase
}
}
+ ///
+ /// 문서의 모든 컨트롤 이름 — 쿼리의 《컨트롤명》 치환 후보.
+ /// 런타임도 같은 서식 안에서만 이름을 찾으므로(clsMDataTable 의 poAllControlOnSheet) 범위가 같다.
+ ///
+ public IReadOnlyList ControlNames()
+ => AllControls().Select(c => c.Id).Where(id => id.Length > 0).Distinct(StringComparer.Ordinal).ToList();
+
/// 문서 전체 컨트롤 순회(컨테이너 자식 포함, 모델 순서)
private IEnumerable AllControls()
{
diff --git a/src/SheetMe.Designer/Views/DesignerCanvasView.xaml.cs b/src/SheetMe.Designer/Views/DesignerCanvasView.xaml.cs
index d2ee058..a10705c 100644
--- a/src/SheetMe.Designer/Views/DesignerCanvasView.xaml.cs
+++ b/src/SheetMe.Designer/Views/DesignerCanvasView.xaml.cs
@@ -130,7 +130,10 @@ public partial class DesignerCanvasView : UserControl
// 데이터소스(MDataTable)는 인라인 대신 전용 쿼리 편집기 창
if (target is DataTableViewModel dataTable)
{
- var queryDialog = new QueryEditorWindow(dataTable.Id, dataTable.Model.Props.GetText("Query") ?? string.Empty)
+ // 《컨트롤명》 치환 후보 — 같은 서식의 컨트롤만 유효하다
+ var queryDialog = new QueryEditorWindow(dataTable.Id,
+ dataTable.Model.Props.GetText("Query") ?? string.Empty,
+ controlNames: subscribedDesigner.ControlNames())
{
Owner = Window.GetWindow(this),
};
diff --git a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml
index 107a3a4..a3ea31f 100644
--- a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml
+++ b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml
@@ -2,157 +2,322 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ctl="clr-namespace:SheetMe.Designer.Controls"
- Title="쿼리 편집" Width="1060" Height="640" MinWidth="780" MinHeight="440"
+ Title="쿼리 편집" Width="1180" Height="760" MinWidth="880" MinHeight="520"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Style="{StaticResource ThemedWindow}">
-
-
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs
index d213aff..63e19d8 100644
--- a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs
+++ b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs
@@ -49,6 +49,17 @@ public partial class QueryEditorWindow : Window
/// 목록이 걸려 있는 낱말의 끝 — 커서가 이 범위를 벗어나면 목록은 더 이상 유효하지 않다
private int completionEnd;
+
+ /// 이 서식의 컨트롤 이름 — 《컨트롤명》 치환 후보
+ private readonly IReadOnlyList controlNames;
+
+ /// 고른 갈래 칩 — 빈 문자열이면 전체
+ private string activeBucket = string.Empty;
+ #endregion
+
+ #region Properties - Commands
+ /// 목록 행의 + 버튼 — 더블클릭을 모르는 사람도 넣을 수 있어야 한다
+ public M.Framework.WPF.ICustomCommand InsertCommand { get; }
#endregion
#region Properties
@@ -60,16 +71,32 @@ public partial class QueryEditorWindow : Window
/// 제목에 붙일 대상 이름
/// 초기 텍스트
/// SQL 치환 변수 패널 표시 여부 — 상용구 등 SQL 이 아닌 용도에서는 false
- public QueryEditorWindow(string ownerLabel, string initialQuery, bool showVariables = true)
+ ///
+ /// 같은 서식의 컨트롤 이름 — 《컨트롤명》 치환 후보가 된다.
+ /// 이 목록은 문서마다 다르므로 카탈로그가 아니라 호출부가 준다.
+ ///
+ public QueryEditorWindow(string ownerLabel, string initialQuery, bool showVariables = true,
+ IReadOnlyList? controlNames = null)
{
InitializeComponent();
isSql = showVariables;
+ this.controlNames = controlNames ?? Array.Empty();
+ InsertCommand = new M.Framework.WPF.Command((object parameter) =>
+ {
+ if (parameter is QueryVariable variable)
+ {
+ InsertVariable(variable);
+ }
+ });
+ DataContext = this;
SqlBox.Text = initialQuery;
+ TargetChipText.Text = ownerLabel;
if (showVariables)
{
Title = $"쿼리 편집 — {ownerLabel}";
Highlight.Source = SqlBox;
+ RebuildBucketChips();
ApplyVariableFilter(string.Empty);
var connection = Services.ConfigService.Current.ConnectionString;
workbench = new SheetMe.Data.Stores.OracleQueryWorkbench(connection);
@@ -78,6 +105,8 @@ public partial class QueryEditorWindow : Window
TrialButton.IsEnabled = false;
TrialButton.ToolTip = "DB 접속이 설정되지 않아 검증 실행을 쓸 수 없습니다.";
}
+ ConnText.Text = workbench.CanUseDb ? "연결 상태: 정상" : "연결 상태: 없음(오프라인)";
+ ConnDot.Fill = (System.Windows.Media.Brush)FindResource(workbench.CanUseDb ? "B.Ok" : "B.Muted");
}
else
{
@@ -88,6 +117,8 @@ public partial class QueryEditorWindow : Window
VariablePane.Visibility = Visibility.Collapsed;
SplitterColumn.Width = new GridLength(0);
VariableColumn.Width = new GridLength(0);
+ TrialButton.Visibility = Visibility.Collapsed;
+ ConnText.Text = "텍스트 편집";
}
// 편집기가 스크롤되면 목록이 걸린 자리가 화면에서 움직인다 — 따라가지 않고 닫는다
@@ -429,10 +460,59 @@ public partial class QueryEditorWindow : Window
{
var text = SqlBox.Text;
LengthText.Text = $"{text.Length:N0}자 · {CountLines(text)}줄";
+ UpdateCaretText();
UpdateLineNumbers(text);
UpdateProblems(text);
}
+ /// 줄·열 표시 — 오류 메시지의 위치를 찾아갈 때 쓴다
+ private void UpdateCaretText()
+ {
+ var caret = Math.Clamp(SqlBox.CaretIndex, 0, SqlBox.Text.Length);
+ var line = SqlBox.GetLineIndexFromCharacterIndex(caret);
+ var column = line >= 0 ? caret - SqlBox.GetCharacterIndexFromLineIndex(line) : 0;
+ CaretText.Text = $"줄 {Math.Max(0, line) + 1}, 열 {Math.Max(0, column) + 1}";
+ }
+
+ /// 포맷 — 예약어 대문자, 절마다 줄바꿈. 문자열·치환 변수는 건드리지 않는다
+ private void OnFormat(object sender, RoutedEventArgs e)
+ {
+ if (!isSql)
+ {
+ return;
+ }
+ var formatted = SqlFormatter.Format(SqlBox.Text);
+ if (!string.Equals(formatted, SqlBox.Text, StringComparison.Ordinal))
+ {
+ SqlBox.Text = formatted;
+ SqlBox.CaretIndex = SqlBox.Text.Length;
+ }
+ }
+
+ private void OnToggleComment(object sender, RoutedEventArgs e)
+ {
+ SqlBox.Focus();
+ ToggleComment();
+ }
+
+ private void OnInvokeCompletion(object sender, RoutedEventArgs e)
+ {
+ SqlBox.Focus();
+ UpdateCompletion();
+ }
+
+ /// 전체 지우기 — 되돌릴 수 있게 TextBox 실행취소에 남긴다
+ private void OnClearAll(object sender, RoutedEventArgs e)
+ {
+ if (SqlBox.Text.Length == 0)
+ {
+ return;
+ }
+ SqlBox.SelectAll();
+ SqlBox.SelectedText = string.Empty;
+ SqlBox.Focus();
+ }
+
private static int CountLines(string text)
{
var lines = 1;
@@ -714,23 +794,57 @@ public partial class QueryEditorWindow : Window
private void OnVariableSearchChanged(object sender, TextChangedEventArgs e)
=> ApplyVariableFilter(VariableSearch.Text);
- /// 이름·설명·초성으로 좁힌다 — 목록이 길어 눈으로 훑기 어렵다
+ /// 카탈로그 + 이 서식의 컨트롤 — 컨트롤은 문서마다 달라 여기서 합친다
+ private IReadOnlyList AllVariables()
+ => LegacyQueryVariableCatalog.All
+ .Concat(LegacyQueryVariableCatalog.ControlVariables(controlNames))
+ .ToList();
+
+ /// 상단 칩을 다시 만든다 — 갈래별 개수는 검색과 무관하게 전체 기준으로 둔다
+ private void RebuildBucketChips()
+ {
+ var all = AllVariables();
+ var chips = new List