diff --git a/src/SheetMe.Core/Catalog/ControlRegistry.cs b/src/SheetMe.Core/Catalog/ControlRegistry.cs
index b388b94..670bb9c 100644
--- a/src/SheetMe.Core/Catalog/ControlRegistry.cs
+++ b/src/SheetMe.Core/Catalog/ControlRegistry.cs
@@ -58,6 +58,16 @@ public static class ControlRegistry
{
Key = "ReLoadDataMsgNoCheck", Label = "재조회 무확인", Editor = PropEditorKind.Toggle, Default = "False",
};
+
+ ///
+ /// 데이터소스 배선 — 레거시가 bzMDataTableField 편집기를 붙이는 8종에만 준다
+ /// (TextBox.vb:1188, ComboBox.vb:927, CheckBox.vb:900, RadioButton.vb:1581,
+ /// DateTimePicker.vb:544, MaskedTextBox.vb:699, MCalcBox.vb:249, MPictureBox.vb:1575).
+ ///
+ public static PropertyDef DataTableField => new()
+ {
+ Key = "DataTableField", Label = "데이터 필드", Editor = PropEditorKind.DataTableField,
+ };
}
private static readonly List all = new()
@@ -95,6 +105,7 @@ public static class ControlRegistry
new() { Key = "DataInterfaceTag", Label = "데이터 태그", Editor = PropEditorKind.DataInterfaceTag },
new() { Key = "DataActionTag", Label = "액션 태그", Editor = PropEditorKind.DataActionTag },
new() { Key = "DataActionTagControl", Label = "액션 대상 컨트롤", Editor = PropEditorKind.Text },
+ Runtime.DataTableField,
// 레거시 TextBox.vb:63 mbRwdRsvWrdYon = True — 2022-09-28 추가된 속성이라 그 이전 서식엔 키가 없다.
// 기본값을 알려주지 않으면 항상 '꺼짐'으로 보이고, 사용자가 껐다 켜면 명시 False 가 기록돼 기능이 죽는다.
new() { Key = "RwdRsvWrdYon", Label = "상용구 사용", Editor = PropEditorKind.Toggle, Default = "True" },
@@ -117,6 +128,7 @@ public static class ControlRegistry
new() { Key = "Mask", Label = "마스크", Editor = PropEditorKind.Mask },
Runtime.IsRequiredValue,
new() { Key = "DataInterfaceTag", Label = "데이터 태그", Editor = PropEditorKind.DataInterfaceTag },
+ Runtime.DataTableField,
new() { Key = "AutoHeight", Label = "자동 높이", Editor = PropEditorKind.Toggle, Default = "False" },
Runtime.PrintOutPut,
Runtime.PreventEditing,
@@ -132,6 +144,7 @@ public static class ControlRegistry
{
new() { Key = "Text", Label = "텍스트", Editor = PropEditorKind.Text },
new() { Key = "Checked", Label = "기본 체크", Editor = PropEditorKind.Toggle },
+ Runtime.DataTableField,
// 진짜 배점이다 — CheckBox.vb:352-367 게터가 저장값(mdScore)을 그대로 돌려준다(체크됐을 때).
new() { Key = "Score", Label = "점수", Editor = PropEditorKind.Number, Default = "0" },
Runtime.IsRequiredValue,
@@ -147,6 +160,7 @@ public static class ControlRegistry
{
new() { Key = "Text", Label = "텍스트", Editor = PropEditorKind.Text },
new() { Key = "Checked", Label = "기본 선택", Editor = PropEditorKind.Toggle },
+ Runtime.DataTableField,
// CheckBox 와 같은 구조(RadioButton.vb Score 게터가 mdScore 반환) — 낙상·욕창 평가지의 배점원
new() { Key = "Score", Label = "점수", Editor = PropEditorKind.Number, Default = "0" },
Runtime.IsRequiredValue,
@@ -162,7 +176,7 @@ public static class ControlRegistry
{
new() { Key = "Items", Label = "선택지(줄바꿈 구분)", Editor = PropEditorKind.StringList },
Runtime.IsRequiredValue,
- new() { Key = "DataTableField", Label = "데이터 필드", Editor = PropEditorKind.Text },
+ Runtime.DataTableField,
// 콤보의 배점은 Score 가 아니라 ItemScore 다 — ComboBox.vb Score 게터는 저장값을 읽지 않고
// ItemScore 를 "/" 로 쪼개 SelectedIndex 번째를 돌려준다. 항목 순서와 1:1 로 맞춰야 한다.
new() { Key = "ItemScore", Label = "항목 배점", Editor = PropEditorKind.Text },
@@ -193,6 +207,7 @@ public static class ControlRegistry
{
new() { Key = "Format", Label = "형식", Editor = PropEditorKind.Choice, Default = "Long", Choices = new[] { "Long", "Short", "Time", "Custom" } },
new() { Key = "CustomFormat", Label = "사용자 형식", Editor = PropEditorKind.Text },
+ Runtime.DataTableField,
Runtime.IsRequiredValue,
new() { Key = "Visible", Label = "표시", Editor = PropEditorKind.Toggle, Default = "True" },
Runtime.PrintOutPut,
@@ -253,6 +268,7 @@ public static class ControlRegistry
{
new() { Key = "DataInterfaceTag", Label = "데이터 태그", Editor = PropEditorKind.DataInterfaceTag },
new() { Key = "SizeMode", Label = "크기 모드", Editor = PropEditorKind.Choice, Default = "Normal", Choices = new[] { "Normal", "StretchImage", "AutoSize", "CenterImage", "Zoom" } },
+ Runtime.DataTableField,
// 서명란 — 이 값이 True 여야 런타임이 서명 수집 대상으로 잡는다(MPictureBox.vb:1480, 사용처 :992/:1075/:1212…).
// 이걸 못 걸면 서명이 필요한 동의서를 SheetMe 만으로 새로 만들 수 없다.
new() { Key = "IsSignature", Label = "서명란", Editor = PropEditorKind.Toggle, Default = "False" },
@@ -273,6 +289,7 @@ public static class ControlRegistry
{
new() { Key = "Formula", Label = "수식", Editor = PropEditorKind.MultilineText },
new() { Key = "Text", Label = "텍스트", Editor = PropEditorKind.Text },
+ Runtime.DataTableField,
// 계산박스에는 Score 를 노출하지 않는다 — MCalcBox.vb Score 게터는 저장값이 아니라
// Me.Text 를 숫자로 읽어 돌려주고, 세터는 반대로 Text 를 덮어쓴다. 배점은 이 칸이 아니라
// 합산 대상인 CheckBox/RadioButton 쪽에 준다.
@@ -383,6 +400,12 @@ public enum PropEditorKind
/// 입력 마스크 — 프리셋 목록 + 미리보기 대화상자
Mask,
+
+ ///
+ /// 데이터소스 배선 — 전용 대화상자가 문법을 조립한다.
+ /// 자유 텍스트로 두면 형식을 어긴 값이 런타임에서 무음으로 실패한다(빈 catch).
+ ///
+ DataTableField,
}
/// 인스펙터 속성 정의 1건 — PropBag 의 레거시 Property 를 편집
diff --git a/src/SheetMe.Core/Catalog/DataTableFieldSpec.cs b/src/SheetMe.Core/Catalog/DataTableFieldSpec.cs
new file mode 100644
index 0000000..05a7210
--- /dev/null
+++ b/src/SheetMe.Core/Catalog/DataTableFieldSpec.cs
@@ -0,0 +1,166 @@
+using System.Globalization;
+using System.Text.RegularExpressions;
+
+namespace SheetMe.Core.Catalog;
+
+/// DataTableField 값의 두 가지 형태
+public enum DataTableFieldForm
+{
+ /// {이름}.Select("{필터}")({행}).Item("{필드}") — 레거시 전용 편집기가 만드는 형태
+ Select,
+
+ /// {이름}.Rows({행}).Item("{필드}") — 손으로 쓴 형태. 운영에서 다수다
+ Rows,
+}
+
+///
+/// MDataTable 조회 결과를 입력 칸에 꽂는 배선 문자열 — 파싱·조립·검증.
+///
+/// 왜 전용 취급이 필요한가. 런타임 파서는 문자열을 쪼개서 읽는데(ucLoadSheetBase.DataTableBinding_),
+/// 형식을 어기면 예외를 빈 catch 가 삼켜서 아무 표시 없이 값이 안 채워진다.
+/// 디자이너에서는 정상으로 보이고 임상 화면에서만 빈칸이 되므로 원인 추적이 어렵다.
+/// 그래서 우리는 문법 골격을 코드가 조립하고, 저장 전에 형식을 검사한다.
+///
+/// 두 형태를 모두 다룬다. 레거시 전용 편집기는 Select 형만 만들지만,
+/// 운영 실측(진단 --db-dtf)은 반대다 — Rows 형 1,624건(76%) 대 Select 형 268건(12%).
+/// Select 형만 읽으면 기존 배선의 대부분을 편집기가 못 열고, 형태를 함부로 바꾸면
+/// Rows 형만 이해하는 보조 로더(bzDesignSheetLoader)에서 동작이 달라질 수 있다.
+/// 그래서 연 값의 형태를 그대로 유지하고, 새로 만들 때만 레거시 편집기와 같은 Select 형을 쓴다.
+///
+public sealed class DataTableFieldSpec
+{
+ #region Member Fields
+ // 파서는 첫 괄호쌍과 첫 따옴표쌍만 본다 — 공백은 어디에도 넣지 않는다(레거시 조립도 공백 0개다)
+ private static readonly Regex SelectPattern = new(
+ @"^(?[^.\s""]+)\.Select\((?"")(?[^""]*)""\)\((?\d+)\)\.Item\(""(?[^""]*)""\)$",
+ RegexOptions.Compiled | RegexOptions.CultureInvariant);
+
+ private static readonly Regex RowsPattern = new(
+ @"^(?[^.\s""]+)\.Rows\((?\d+)\)\.Item\(""(?[^""]*)""\)$",
+ RegexOptions.Compiled | RegexOptions.CultureInvariant);
+ #endregion
+
+ #region Properties
+ /// 값 형태
+ public DataTableFieldForm Form { get; set; } = DataTableFieldForm.Select;
+
+ /// 데이터소스(MDataTable) 컨트롤 이름 — 런타임이 대소문자까지 맞춰 찾는다
+ public string TableName { get; set; } = string.Empty;
+
+ /// DataTable.Select 필터 — Rows 형에서는 쓰이지 않는다
+ public string Filter { get; set; } = string.Empty;
+
+ /// 행 번호(0부터)
+ public int RowIndex { get; set; }
+
+ /// 컬럼명 또는 SQL 별칭 표현식
+ public string Field { get; set; } = string.Empty;
+ #endregion
+
+ #region Methods
+ /// 기존 값 해석 — 알아볼 수 없으면 null(원문을 손대지 않기 위해 호출부가 판단한다)
+ public static DataTableFieldSpec? Parse(string? value)
+ {
+ if (value is null)
+ {
+ return null;
+ }
+ var text = value.Trim();
+ if (text.Length == 0)
+ {
+ return null;
+ }
+
+ var select = SelectPattern.Match(text);
+ if (select.Success)
+ {
+ return new DataTableFieldSpec
+ {
+ Form = DataTableFieldForm.Select,
+ TableName = select.Groups["name"].Value,
+ Filter = select.Groups["filter"].Value,
+ RowIndex = int.Parse(select.Groups["row"].Value, CultureInfo.InvariantCulture),
+ Field = select.Groups["field"].Value,
+ };
+ }
+
+ var rows = RowsPattern.Match(text);
+ if (rows.Success)
+ {
+ return new DataTableFieldSpec
+ {
+ Form = DataTableFieldForm.Rows,
+ TableName = rows.Groups["name"].Value,
+ RowIndex = int.Parse(rows.Groups["row"].Value, CultureInfo.InvariantCulture),
+ Field = rows.Groups["field"].Value,
+ };
+ }
+ return null;
+ }
+
+ /// 문자열 조립 — 공백 없이(레거시 조립과 같은 형태)
+ public string Compose()
+ => Form == DataTableFieldForm.Rows
+ ? $"{TableName}.Rows({RowIndex.ToString(CultureInfo.InvariantCulture)}).Item(\"{Field}\")"
+ : $"{TableName}.Select(\"{Filter}\")({RowIndex.ToString(CultureInfo.InvariantCulture)}).Item(\"{Field}\")";
+
+ ///
+ /// 저장 전 검사 — 문제가 없으면 null, 있으면 사용자에게 보일 이유 한 줄.
+ ///
+ /// 레거시는 검사가 전혀 없고 실패가 100% 무음이라, 여기서 막지 않으면 운영에 도달한다.
+ ///
+ public string? Validate(IReadOnlyCollection? knownTables = null)
+ {
+ if (TableName.Length == 0)
+ {
+ return "데이터소스를 선택하세요.";
+ }
+ if (TableName.Contains('.', StringComparison.Ordinal))
+ {
+ // 런타임은 첫 '.' 앞을 이름으로 자른다 — 이름에 점이 있으면 영영 못 찾는다
+ return "데이터소스 이름에 마침표(.)를 쓸 수 없습니다.";
+ }
+ if (TableName.Contains('"', StringComparison.Ordinal) || TableName.Any(char.IsWhiteSpace))
+ {
+ return "데이터소스 이름에 공백이나 큰따옴표를 쓸 수 없습니다.";
+ }
+ if (Field.Length == 0)
+ {
+ return "필드를 입력하세요.";
+ }
+ if (Field.Contains('"', StringComparison.Ordinal))
+ {
+ // 필드는 SQL 별칭 표현식일 수 있어 넓게 허용하되(예: EMDOPRDTE||'-'||EMDOPNAME) 큰따옴표만 막는다
+ return "필드에 큰따옴표를 쓸 수 없습니다.";
+ }
+ if (Form == DataTableFieldForm.Select && Filter.Contains('"', StringComparison.Ordinal))
+ {
+ // 파서가 따옴표로 쪼개므로 필터 안의 따옴표는 필터·행·필드를 전부 어긋나게 만든다
+ return "필터에 큰따옴표를 쓸 수 없습니다.";
+ }
+ if (RowIndex < 0)
+ {
+ return "행 번호는 0 이상이어야 합니다.";
+ }
+ if (knownTables is { Count: > 0 } && !knownTables.Contains(TableName, StringComparer.Ordinal))
+ {
+ // 대소문자까지 맞아야 한다 — 런타임 Hashtable 조회가 대소문자를 구분한다.
+ // 실측에서도 같은 서식에 없는 이름을 가리키는 값이 20건 있었다(전부 조용히 실패한다).
+ return $"이 서식에 '{TableName}' 데이터소스가 없습니다 — 대소문자까지 정확해야 합니다.";
+ }
+ return null;
+ }
+
+ /// 조립 결과가 다시 같은 값으로 해석되는지(왕복 자기검사)
+ public bool RoundTrips()
+ {
+ var parsed = Parse(Compose());
+ return parsed is not null
+ && parsed.Form == Form
+ && string.Equals(parsed.TableName, TableName, StringComparison.Ordinal)
+ && parsed.RowIndex == RowIndex
+ && string.Equals(parsed.Field, Field, StringComparison.Ordinal)
+ && (Form == DataTableFieldForm.Rows || string.Equals(parsed.Filter, Filter, StringComparison.Ordinal));
+ }
+ #endregion
+}
diff --git a/src/SheetMe.Designer/App.xaml.cs b/src/SheetMe.Designer/App.xaml.cs
index 1f94801..679553f 100644
--- a/src/SheetMe.Designer/App.xaml.cs
+++ b/src/SheetMe.Designer/App.xaml.cs
@@ -142,6 +142,11 @@ public partial class App : Application
return Diagnostics.DbSmoke.RunRow(args[1], args[2]);
}
+ if (args.Length >= 2 && args[0] == "--db-dtf")
+ {
+ return Diagnostics.DbSmoke.RunDataTableFieldReport(args[1]);
+ }
+
if (args.Length >= 2 && args[0] == "--db-lines")
{
return Diagnostics.DbSmoke.RunLineReport(args[1]);
diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
index 7f60cec..87d17a6 100644
--- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
+++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
@@ -615,6 +615,162 @@ public static class DbSmoke
}
}
+ ///
+ /// DataTableField / DataTableItms 값 형태 분포(--db-dtf) — 전용 편집기가 어떤 형태를 읽어야 하는지(읽기 전용).
+ ///
+ /// 레거시 편집기가 만드는 정본은 {Name}.Select("{필터}")({행}).Item("{필드}") 한 가지지만,
+ /// 손으로 쓴 {Name}.Rows({행}).Item("{필드}") 형이 섞여 있다. 어느 쪽이 얼마나 되는지 모르면
+ /// 편집기가 기존 값을 못 읽어 사용자가 열 때마다 백지에서 다시 조립하게 된다.
+ /// 파서 제약도 함께 센다 — 필터에 큰따옴표가 들어가면 런타임이 조용히 어긋난다.
+ ///
+ public static int RunDataTableFieldReport(string reportPath)
+ {
+ var lines = new List();
+ try
+ {
+ var config = ConfigService.Current;
+ var store = new OracleLegacyFormStore(config.ConnectionString);
+ var serializer = new LegacyXmlSerializer();
+ var sheets = store.ListSheets(null, max: 3000).Where(s => s.HasDesign).ToList();
+
+ var selectForm = 0;
+ var rowsForm = 0;
+ var otherForm = new Dictionary(StringComparer.Ordinal);
+ var empty = 0;
+ var quoteInFilter = new List();
+ var samples = new List();
+ var itms = new Dictionary(StringComparer.Ordinal);
+ // 첫 토큰이 그 서식의 MDataTable 이름과 대소문자까지 맞는지 — 어긋나면 런타임이 무음 실패한다
+ var unresolved = new List();
+
+ foreach (var sheet in sheets)
+ {
+ try
+ {
+ var raw = store.LoadActiveDesignRaw(sheet.ShtCod);
+ if (raw is null)
+ {
+ continue;
+ }
+ var document = serializer.Read(raw.Value.Xml);
+ var tableNames = new HashSet(StringComparer.Ordinal);
+ void CollectTables(Core.Models.ControlElement element)
+ {
+ if (string.Equals(element.Type, "DataTable", StringComparison.Ordinal))
+ {
+ tableNames.Add(element.Id);
+ }
+ foreach (var child in element.Children)
+ {
+ CollectTables(child);
+ }
+ }
+
+ void Visit(Core.Models.ControlElement element)
+ {
+ var value = element.Props.GetText("DataTableField");
+ if (value is not null)
+ {
+ var trimmed = value.Trim();
+ if (trimmed.Length == 0)
+ {
+ empty++;
+ }
+ else if (trimmed.Contains(".Select(", StringComparison.Ordinal))
+ {
+ selectForm++;
+ if (samples.Count < 12)
+ {
+ samples.Add($" {sheet.ShtCod} {element.Id}: {trimmed}");
+ }
+ // C6 — 필터 안의 큰따옴표. Split(""")(") 이 앞쪽에서 잘려 전부 어긋난다
+ var afterSelect = trimmed.Split(".Select(\"", 2, StringSplitOptions.None);
+ if (afterSelect.Length == 2)
+ {
+ var filter = afterSelect[1].Split("\")(", 2, StringSplitOptions.None)[0];
+ if (filter.Contains('"'))
+ {
+ quoteInFilter.Add($" {sheet.ShtCod} {element.Id}: {trimmed}");
+ }
+ }
+ }
+ else if (trimmed.Contains(".Rows(", StringComparison.Ordinal))
+ {
+ rowsForm++;
+ if (samples.Count < 12)
+ {
+ samples.Add($" {sheet.ShtCod} {element.Id}: {trimmed}");
+ }
+ }
+ else
+ {
+ otherForm[trimmed] = otherForm.GetValueOrDefault(trimmed) + 1;
+ }
+
+ if (trimmed.Length > 0)
+ {
+ var head = trimmed.Split('.')[0];
+ if (head.Length > 0 && !tableNames.Contains(head) && unresolved.Count < 20)
+ {
+ unresolved.Add($" {sheet.ShtCod} {element.Id}: '{head}' — 이 서식의 데이터소스: " +
+ (tableNames.Count == 0 ? "(없음)" : string.Join(",", tableNames)));
+ }
+ }
+ }
+ if (element.Props.GetText("DataTableItms") is { } items && items.Trim().Length > 0)
+ {
+ var kind = items.Contains(".Select(", StringComparison.Ordinal) ? "Select형"
+ : items.Contains(".Rows(", StringComparison.Ordinal) ? "Rows형(콤보 안 채워짐)"
+ : "기타";
+ itms[kind] = itms.GetValueOrDefault(kind) + 1;
+ }
+ foreach (var child in element.Children)
+ {
+ Visit(child);
+ }
+ }
+
+ foreach (var page in document.Pages)
+ {
+ CollectTables(page.Root);
+ }
+ foreach (var page in document.Pages)
+ {
+ Visit(page.Root);
+ }
+ }
+ catch
+ {
+ // 통계용
+ }
+ }
+
+ lines.Add($"DataTableField — Select형 {selectForm}건 / Rows형 {rowsForm}건 / 기타 {otherForm.Values.Sum()}건 / 빈값 {empty}건");
+ if (otherForm.Count > 0)
+ {
+ lines.Add("[기타 형태]");
+ lines.AddRange(otherForm.OrderByDescending(p => p.Value).Take(10).Select(p => $" {p.Key} ×{p.Value}"));
+ }
+ lines.Add($"필터에 큰따옴표가 든 값(런타임 무음 실패): {quoteInFilter.Count}건");
+ lines.AddRange(quoteInFilter.Take(10));
+ lines.Add($"첫 토큰이 같은 서식의 데이터소스 이름과 안 맞는 값: {unresolved.Count}건");
+ lines.AddRange(unresolved.Take(10));
+ lines.Add("DataTableItms 형태: " + (itms.Count == 0 ? "(없음)"
+ : string.Join(", ", itms.OrderByDescending(p => p.Value).Select(p => $"{p.Key}×{p.Value}"))));
+ lines.Add("[표본]");
+ lines.AddRange(samples);
+
+ File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ lines.Add("실패: " + ex);
+ File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
+ return 1;
+ }
+ }
+
///
/// 선(MLine) 방향 일치 검사(--db-lines) — 디자이너 렌더를 바꿔야 하는지 판단하는 근거(읽기 전용).
///
diff --git a/src/SheetMe.Designer/Diagnostics/DialogShots.cs b/src/SheetMe.Designer/Diagnostics/DialogShots.cs
index da3c7a3..490f83c 100644
--- a/src/SheetMe.Designer/Diagnostics/DialogShots.cs
+++ b/src/SheetMe.Designer/Diagnostics/DialogShots.cs
@@ -117,6 +117,9 @@ public static class DialogShots
"SELECT ChtNum, PatNam FROM P_PatMst\nWHERE ChtNum = <>")),
("04-tag-picker", () => new Views.TagPickerDialogView("동작 태그", SampleTags(), SampleTags()[1])),
("05-mask-picker", () => new Views.MaskPickerDialogView("0000년 90월 90일")),
+ // 데이터소스 배선 — 운영 다수파인 Rows 형 값을 넣어 되읽기·형태 유지가 보이게 찍는다
+ ("05b-datatable-field", () => new Views.DataTableFieldDialogView(
+ "MDataTable2.Rows(0).Item(\"ALGYON\")", new[] { "MDataTable1", "MDataTable2" })),
("06-font-manager", () => new Views.FontManagerDialogView(designer)),
("07-register-sheet", () => new Views.RegisterSheetDialogView("S999", "표본 서식")),
("08-preview", () => new Views.PreviewWindow(designer)),
diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
index e98a14d..1676dcf 100644
--- a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
+++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
@@ -820,6 +820,43 @@ public static class EditSmoke
designer.Selection.Clear();
}
+ // 20-5g) 데이터소스 배선 — 전용 행이 붙고, 이 서식의 데이터소스만 후보가 된다
+ {
+ designer.AddControlAt("DataTable", new Point(600, 320));
+ var source = designer.Pages[0].Controls.First(c => c.Type == "DataTable");
+ var bound = Find("TextBox");
+ designer.Selection.SetSingle(bound);
+ // 배선은 '무슨 값이 채워지는가'라 데이터 탭에 있다
+ var previousTab = designer.Inspector.SelectedTab;
+ designer.Inspector.SelectedTab = ViewModels.Inspector.InspectorTab.Data;
+ var dtfRow = designer.Inspector.Rows
+ .OfType().FirstOrDefault();
+ Check("배선: 전용 편집 행이 붙는다", dtfRow is not null);
+ if (dtfRow is null)
+ {
+ designer.Inspector.SelectedTab = previousTab;
+ goto skipWiring;
+ }
+ Check("배선: 값이 없으면 안내 문구", dtfRow.Summary == "(배선 없음)", dtfRow.Summary);
+
+ // 운영 다수파인 Rows 형을 읽어야 한다(1,624건, 76%)
+ dtfRow.ValueText = $"{source.Id}.Rows(0).Item(\"ALGYON\")";
+ Check("배선: Rows 형을 읽는다", dtfRow.Summary.Contains(source.Id) && dtfRow.Summary.Contains("ALGYON"),
+ dtfRow.Summary);
+
+ // 해석 못 하는 값은 조용히 고치지 않고 경고로 드러낸다
+ dtfRow.ValueText = "MDataTable1.Rows";
+ Check("배선: 미완성 값은 경고로 드러낸다", dtfRow.Summary.StartsWith("⚠"), dtfRow.Summary);
+
+ dtfRow.ValueText = string.Empty;
+ designer.Inspector.SelectedTab = previousTab;
+
+ skipWiring:
+ designer.Selection.SetSingle(source);
+ designer.DeleteSelection();
+ designer.Selection.Clear();
+ }
+
// 20-5d) 태그 카탈로그 — 레거시에서 주석 처리된 태그를 고를 수 있으면 안 된다
Check("태그 카탈로그: 미구현 3종 제외",
!SheetMe.Core.Catalog.LegacyTagCatalog.DataInterfaceTags.Contains("PAT_BMI")
diff --git a/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs b/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs
index 9d41ace..359fd9d 100644
--- a/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs
+++ b/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs
@@ -602,11 +602,41 @@ public sealed class InspectorViewModel : ViewModelBase
"액션 태그 선택 — 더블클릭/버튼 액션(EN_DataActionTyp)", LegacyTagCatalog.DataActionTags),
PropEditorKind.SqlQuery => new QueryRowViewModel(def.Label),
PropEditorKind.Mask => new MaskRowViewModel(def.Label),
+ // 배선 대상 목록은 '지금 이 서식'의 데이터소스다 — 런타임도 같은 서식 안에서만 이름을 찾는다
+ PropEditorKind.DataTableField => new DataTableFieldRowViewModel(def.Label, DataTableNames),
_ => new TextRowViewModel(def.Label),
};
AddRow(row, binding);
}
+ ///
+ /// 이 서식의 데이터소스(MDataTable) 이름 — 배선 편집기의 선택지.
+ ///
+ /// 레거시 편집기는 열려 있는 모든 디자인 서피스를 훑어 다른 서식의 데이터소스까지 섞어 보여 주고,
+ /// 그걸 고르면 런타임에 조용히 실패한다(bzMDataTableField.vb:18-63). 우리는 현재 문서로 한정한다.
+ ///
+ private IReadOnlyList DataTableNames()
+ {
+ var names = new List();
+ void Visit(SheetMe.Core.Models.ControlElement element)
+ {
+ if (string.Equals(element.Type, "DataTable", StringComparison.Ordinal) && element.Id.Length > 0)
+ {
+ names.Add(element.Id);
+ }
+ foreach (var child in element.Children)
+ {
+ Visit(child);
+ }
+ }
+ foreach (var page in designer.Document.Pages)
+ {
+ Visit(page.Root);
+ }
+ names.Sort(StringComparer.Ordinal);
+ return names;
+ }
+
///
/// 선의 방향·굵기 변경 — 속성과 크기를 함께 맞춘다(레거시 MLine 의 세터/OnResize 이식).
///
diff --git a/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs b/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs
index b2ce6e5..ebe152c 100644
--- a/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs
+++ b/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs
@@ -661,6 +661,62 @@ public sealed class ToggleAdvancedRowViewModel : PropertyRowViewModel
}
}
+///
+/// 데이터소스 배선 행 — 전용 대화상자로만 편집한다.
+///
+/// 자유 텍스트로 두면 형식을 어긴 값이 저장되고, 런타임은 그 예외를 빈 catch 로 삼켜
+/// 아무 표시 없이 값을 비워 둔다. 디자이너에서는 정상으로 보이므로 원인 추적이 어렵다.
+///
+public sealed class DataTableFieldRowViewModel : PropertyRowViewModel
+{
+ private readonly Func> tablesOf;
+
+ /// 사람이 읽는 요약 — 배선이 없으면 안내 문구
+ public string Summary
+ {
+ get
+ {
+ if (ValueText.Length == 0)
+ {
+ return "(배선 없음)";
+ }
+ var spec = SheetMe.Core.Catalog.DataTableFieldSpec.Parse(ValueText);
+ return spec is null
+ // 해석 못 하는 값은 원문을 그대로 보여 준다 — 조용히 고쳐 쓰지 않는다
+ ? $"⚠ 형식을 알 수 없음: {ValueText}"
+ : $"{spec.TableName} · {spec.Field}" + (spec.RowIndex == 0 ? string.Empty : $" · {spec.RowIndex}행");
+ }
+ }
+
+ /// 편집기 열기
+ public M.Framework.WPF.ICustomCommand? EditCommand { get; set; }
+
+ public DataTableFieldRowViewModel(string label, Func> tablesOf) : base(label)
+ {
+ this.tablesOf = tablesOf;
+ EditCommand = new M.Framework.WPF.Command((sender, e) => OnEdit());
+ PropertyChanged += (_, e) =>
+ {
+ if (e.PropertyName == nameof(ValueText))
+ {
+ OnPropertyChanged(nameof(Summary));
+ }
+ };
+ }
+
+ private void OnEdit()
+ {
+ var dialog = new Views.DataTableFieldDialogView(ValueText, tablesOf())
+ {
+ Owner = System.Windows.Application.Current.MainWindow,
+ };
+ if (dialog.ShowDialog() == true && dialog.Result is not null)
+ {
+ ValueText = dialog.Result;
+ }
+ }
+}
+
///
/// 속성 추가 행 — 키 입력 후 빈 속성 생성(고급).
///
diff --git a/src/SheetMe.Designer/Views/DataTableFieldDialogView.xaml b/src/SheetMe.Designer/Views/DataTableFieldDialogView.xaml
new file mode 100644
index 0000000..cdfd2d6
--- /dev/null
+++ b/src/SheetMe.Designer/Views/DataTableFieldDialogView.xaml
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SheetMe.Designer/Views/DataTableFieldDialogView.xaml.cs b/src/SheetMe.Designer/Views/DataTableFieldDialogView.xaml.cs
new file mode 100644
index 0000000..3f3717f
--- /dev/null
+++ b/src/SheetMe.Designer/Views/DataTableFieldDialogView.xaml.cs
@@ -0,0 +1,128 @@
+using System.Globalization;
+using System.Windows;
+using System.Windows.Controls;
+using SheetMe.Core.Catalog;
+
+namespace SheetMe.Designer.Views;
+
+///
+/// DataTableField 전용 편집기 — 문법 골격을 코드가 조립하고, 저장 전에 검사한다.
+///
+/// 레거시 편집기와 다르게 기존 값을 되읽어 채운다. 레거시는 열 때마다 백지에서 시작해
+/// 한 조각만 고치려 해도 전부 다시 골라야 했다(fmMDataTableField.InitializeSelectedData).
+/// 형태(Select/Rows)도 연 값 그대로 유지한다 — 운영 다수는 Rows 형이고(1,624건, 76%),
+/// 형태를 바꾸면 그 형태만 이해하는 보조 로더에서 동작이 달라질 수 있다.
+///
+public partial class DataTableFieldDialogView : Window
+{
+ #region Member Fields
+ private readonly IReadOnlyList tables;
+ private bool loading = true;
+ #endregion
+
+ #region Properties
+ /// 적용 결과 — 취소면 null, '배선 지우기'면 빈 문자열
+ public string? Result { get; private set; }
+ #endregion
+
+ #region Constructors
+ public DataTableFieldDialogView(string? current, IReadOnlyList dataTables)
+ {
+ InitializeComponent();
+ tables = dataTables;
+
+ foreach (var name in tables)
+ {
+ TableList.Items.Add(name);
+ }
+ NoTableHint.Visibility = tables.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+
+ var spec = DataTableFieldSpec.Parse(current);
+ if (spec is not null)
+ {
+ FormBox.SelectedIndex = spec.Form == DataTableFieldForm.Rows ? 1 : 0;
+ TableList.SelectedItem = tables.FirstOrDefault(t => string.Equals(t, spec.TableName, StringComparison.Ordinal));
+ FilterBox.Text = spec.Filter;
+ RowBox.Text = spec.RowIndex.ToString(CultureInfo.InvariantCulture);
+ FieldBox.Text = spec.Field;
+ }
+ else
+ {
+ // 새 배선 — 레거시 편집기와 같은 Select 형으로 시작한다
+ FormBox.SelectedIndex = 0;
+ RowBox.Text = "0";
+ if (tables.Count == 1)
+ {
+ TableList.SelectedIndex = 0;
+ }
+ }
+
+ loading = false;
+ Refresh();
+ }
+ #endregion
+
+ #region Methods
+ ///
+ /// 현재 입력을 명세로 옮긴다 — 행 번호는 숫자가 아니면 0 으로 본다
+ /// (레거시도 빈 값을 0 으로 강제한다: fmMDataTableField.vb:135).
+ ///
+ private DataTableFieldSpec CurrentSpec()
+ {
+ var row = int.TryParse(RowBox.Text.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
+ ? parsed
+ : 0;
+ return new DataTableFieldSpec
+ {
+ Form = FormBox.SelectedIndex == 1 ? DataTableFieldForm.Rows : DataTableFieldForm.Select,
+ TableName = TableList.SelectedItem as string ?? string.Empty,
+ Filter = FilterBox.Text,
+ RowIndex = row,
+ Field = FieldBox.Text.Trim(),
+ };
+ }
+
+ private void Refresh()
+ {
+ var spec = CurrentSpec();
+ var rowsForm = spec.Form == DataTableFieldForm.Rows;
+
+ // Rows 형은 필터를 아예 쓰지 않는다 — 켜 두면 입력해도 무시되는 칸이 된다
+ FilterBox.IsEnabled = !rowsForm;
+ FilterLabel.Opacity = rowsForm ? 0.45 : 1;
+ FilterBox.Opacity = rowsForm ? 0.45 : 1;
+
+ PreviewText.Text = spec.Compose();
+ var problem = spec.Validate(tables);
+ ProblemText.Text = problem ?? string.Empty;
+ ProblemText.Visibility = problem is null ? Visibility.Collapsed : Visibility.Visible;
+ // 레거시는 아무것도 안 고르고 확인하면 ")(" 같은 찌꺼기를 저장한다 — 그 경로를 막는다
+ OkButton.IsEnabled = problem is null;
+ }
+
+ private void OnFormChanged(object sender, RoutedEventArgs e)
+ {
+ if (!loading)
+ {
+ Refresh();
+ }
+ }
+
+ private void OnApply(object sender, RoutedEventArgs e)
+ {
+ var spec = CurrentSpec();
+ if (spec.Validate(tables) is not null)
+ {
+ return;
+ }
+ Result = spec.Compose();
+ DialogResult = true;
+ }
+
+ private void OnClear(object sender, RoutedEventArgs e)
+ {
+ Result = string.Empty;
+ DialogResult = true;
+ }
+ #endregion
+}
diff --git a/src/SheetMe.Designer/Views/InspectorView.xaml b/src/SheetMe.Designer/Views/InspectorView.xaml
index 0ae348a..51f8287 100644
--- a/src/SheetMe.Designer/Views/InspectorView.xaml
+++ b/src/SheetMe.Designer/Views/InspectorView.xaml
@@ -415,6 +415,18 @@
+
+
+
+
+
+
+
+
diff --git a/tests/SheetMe.Core.Tests/DataTableFieldSpecTests.cs b/tests/SheetMe.Core.Tests/DataTableFieldSpecTests.cs
new file mode 100644
index 0000000..ca49b5d
--- /dev/null
+++ b/tests/SheetMe.Core.Tests/DataTableFieldSpecTests.cs
@@ -0,0 +1,179 @@
+using SheetMe.Core.Catalog;
+
+namespace SheetMe.Core.Tests;
+
+///
+/// DataTableField 배선 문자열의 파싱·조립·검증 테스트.
+///
+/// 표본은 지어내지 않고 운영 DB 실측(--db-dtf)과 레거시 소스에서 그대로 가져왔다.
+/// 이 형식은 어겨도 런타임이 예외를 빈 catch 로 삼켜 아무 표시 없이 값이 안 채워지므로,
+/// 여기서 막지 못하면 잘못된 배선이 임상 화면까지 간다.
+///
+[TestClass]
+public sealed class DataTableFieldSpecTests
+{
+ /// 레거시 전용 편집기가 만드는 정본 형태 — 공백이 하나도 없다
+ [TestMethod]
+ public void Parse_SelectForm_FromLegacyEditorOutput()
+ {
+ var spec = DataTableFieldSpec.Parse("mdt_CodDepUid.Select(\"\")(0).Item(\"CODSTRDTM\")");
+
+ Assert.IsNotNull(spec);
+ Assert.AreEqual(DataTableFieldForm.Select, spec.Form);
+ Assert.AreEqual("mdt_CodDepUid", spec.TableName);
+ Assert.AreEqual(string.Empty, spec.Filter);
+ Assert.AreEqual(0, spec.RowIndex);
+ Assert.AreEqual("CODSTRDTM", spec.Field);
+ }
+
+ /// 운영 다수파(1,624건, 76%) — 손으로 쓴 Rows 형도 읽어야 한다
+ [TestMethod]
+ public void Parse_RowsForm_TheMajorityInProduction()
+ {
+ var spec = DataTableFieldSpec.Parse("MDataTable2.Rows(0).Item(\"ALGALGTYP\")");
+
+ Assert.IsNotNull(spec);
+ Assert.AreEqual(DataTableFieldForm.Rows, spec.Form);
+ Assert.AreEqual("MDataTable2", spec.TableName);
+ Assert.AreEqual(0, spec.RowIndex);
+ Assert.AreEqual("ALGALGTYP", spec.Field);
+ }
+
+ ///
+ /// 필드에는 SQL 별칭 표현식이 들어온다 — 식별자 화이트리스트로 막으면 실서식을 못 연다.
+ /// 실측 출처: [900]CGCH [349]퇴원요약지.
+ ///
+ [TestMethod]
+ public void Parse_FieldMayBeSqlExpression()
+ {
+ var spec = DataTableFieldSpec.Parse("MDataTable4.Select(\"\")(0).Item(\"EMDOPRDTE||'-'||EMDOPNAME\")");
+
+ Assert.IsNotNull(spec);
+ Assert.AreEqual("EMDOPRDTE||'-'||EMDOPNAME", spec.Field);
+ Assert.IsNull(spec.Validate());
+ }
+
+ /// 필터가 있는 형태
+ [TestMethod]
+ public void Parse_SelectForm_WithFilterAndRow()
+ {
+ var spec = DataTableFieldSpec.Parse("MDataTable1.Select(\"CODNAM='A'\")(2).Item(\"CODCOD\")");
+
+ Assert.IsNotNull(spec);
+ Assert.AreEqual("CODNAM='A'", spec.Filter);
+ Assert.AreEqual(2, spec.RowIndex);
+ }
+
+ ///
+ /// 알아볼 수 없는 값은 null 이어야 한다 — 억지로 해석해 되쓰면 원문이 손상된다.
+ /// "MDataTable1.Rows" 는 실제로 운영에 234건 있는 미완성 값이고,
+ /// ")(" 는 레거시 편집기에서 아무것도 안 고르고 확인을 눌렀을 때 남는 찌꺼기다.
+ ///
+ [DataTestMethod]
+ [DataRow("MDataTable1.Rows")]
+ [DataRow(")(")]
+ [DataRow("MDataTable1")]
+ [DataRow("MDataTable1.Select(\"\")(0).Item(\"F\") ")] // 뒤 공백은 Trim 되지만
+ [DataRow("MDataTable1.Select(\"\")( 0 ).Item(\"F\")")] // 내부 공백은 파서가 못 읽는다
+ [DataRow("MDataTable1.select(\"\")(0).Item(\"F\")")] // 소문자 select — 런타임이 다른 가지로 샌다
+ public void Parse_ReturnsNull_ForShapesTheRuntimeCannotRead(string value)
+ {
+ var spec = DataTableFieldSpec.Parse(value);
+
+ // 뒤 공백만 있는 경우는 Trim 후 정상이다 — 그 한 건만 예외로 허용한다
+ if (value.Trim() == "MDataTable1.Select(\"\")(0).Item(\"F\")")
+ {
+ Assert.IsNotNull(spec);
+ return;
+ }
+ Assert.IsNull(spec, $"해석돼서는 안 되는 값이 해석됐습니다: {value}");
+ }
+
+ /// 조립 결과가 다시 같은 값으로 읽혀야 한다
+ [DataTestMethod]
+ [DataRow(DataTableFieldForm.Select)]
+ [DataRow(DataTableFieldForm.Rows)]
+ public void Compose_RoundTrips(DataTableFieldForm form)
+ {
+ var spec = new DataTableFieldSpec
+ {
+ Form = form,
+ TableName = "MDataTable3",
+ Filter = "CODCOD='X'",
+ RowIndex = 1,
+ Field = "CODNAM",
+ };
+
+ Assert.IsTrue(spec.RoundTrips(), spec.Compose());
+ }
+
+ /// 조립에는 공백이 들어가지 않는다 — 레거시 파서가 공백을 못 넘긴다
+ [TestMethod]
+ public void Compose_ContainsNoSpaces_AroundSyntax()
+ {
+ var spec = new DataTableFieldSpec { TableName = "MDataTable1", RowIndex = 0, Field = "F" };
+
+ Assert.AreEqual("MDataTable1.Select(\"\")(0).Item(\"F\")", spec.Compose());
+ }
+
+ /// Rows 형은 필터를 쓰지 않는다 — 값에 섞여 나가면 안 된다
+ [TestMethod]
+ public void Compose_RowsForm_DropsFilter()
+ {
+ var spec = new DataTableFieldSpec
+ {
+ Form = DataTableFieldForm.Rows,
+ TableName = "MDataTable1",
+ Filter = "무시돼야 한다",
+ RowIndex = 3,
+ Field = "F",
+ };
+
+ Assert.AreEqual("MDataTable1.Rows(3).Item(\"F\")", spec.Compose());
+ }
+
+ /// 런타임을 조용히 깨뜨리는 값은 저장 전에 막는다
+ [TestMethod]
+ public void Validate_RejectsQuoteInFilter()
+ {
+ var spec = new DataTableFieldSpec
+ {
+ TableName = "MDataTable1", Filter = "CODNAM=\"A\"", RowIndex = 0, Field = "F",
+ };
+
+ Assert.IsNotNull(spec.Validate());
+ }
+
+ /// 이름에 마침표가 있으면 런타임이 첫 조각만 떼어내 영영 못 찾는다
+ [TestMethod]
+ public void Validate_RejectsDotInTableName()
+ {
+ var spec = new DataTableFieldSpec { TableName = "a.b", RowIndex = 0, Field = "F" };
+
+ Assert.IsNotNull(spec.Validate());
+ }
+
+ ///
+ /// 같은 서식에 없는 데이터소스를 가리키면 막는다 — 대소문자까지 맞아야 한다.
+ /// 실측에서 이런 값이 20건 있었고 전부 조용히 실패한다.
+ ///
+ [TestMethod]
+ public void Validate_RejectsTableMissingFromThisSheet()
+ {
+ var spec = new DataTableFieldSpec { TableName = "MDataTable2", RowIndex = 0, Field = "F" };
+
+ Assert.IsNotNull(spec.Validate(new[] { "MDataTable3" }));
+ Assert.IsNull(spec.Validate(new[] { "MDataTable2" }));
+ // 대소문자가 다르면 런타임 Hashtable 조회가 실패한다
+ Assert.IsNotNull(spec.Validate(new[] { "mdatatable2" }));
+ }
+
+ /// 필드가 비면 저장할 수 없다
+ [TestMethod]
+ public void Validate_RequiresField()
+ {
+ var spec = new DataTableFieldSpec { TableName = "MDataTable1", RowIndex = 0, Field = string.Empty };
+
+ Assert.IsNotNull(spec.Validate());
+ }
+}