diff --git a/src/SheetMe.Core/Catalog/DataTableFieldPicker.cs b/src/SheetMe.Core/Catalog/DataTableFieldPicker.cs
new file mode 100644
index 0000000..b356923
--- /dev/null
+++ b/src/SheetMe.Core/Catalog/DataTableFieldPicker.cs
@@ -0,0 +1,95 @@
+using System.Data;
+
+namespace SheetMe.Core.Catalog;
+
+///
+/// 조회 결과(컬럼·행 문자열)에서 이 가리키는 한 칸을 꺼낸다 —
+/// Rows 형은 행 번호로, Select 형은 .NET DataTable.Select 를 그대로 호출해서 고른다.
+///
+/// 필터 문법을 직접 파싱하지 않는다. 레거시 배선의 Select 필터는 .NET
+/// DataTable.Select(filterExpression) 문법 그대로다 — 같은 엔진에 넘기면
+/// 문법 호환을 우리가 증명할 필요가 없다. 파서를 새로 쓰면 LIKE·괄호·이스케이프에서
+/// 조용히 갈라진다.
+///
+/// 알려진 한계: 우리 결과는 전부 문자열 컬럼이라(레거시는 Oracle 어댑터가
+/// 실타입으로 채운다) 숫자 대소 비교 필터(예: AMT > 100)는 사전순이 된다.
+/// 운영 실측 필터는 대부분 동등 비교(컬럼 = '값')라 실질 영향이 좁지만,
+/// 대소 비교 필터를 만나면 값이 다를 수 있다 — 그때 타입 추론을 붙인다.
+///
+public static class DataTableFieldPicker
+{
+ #region Methods
+ /// 한 칸의 값 — 못 고르면 사유
+ public static TagValue Pick(IReadOnlyList columns, IReadOnlyList rows,
+ DataTableFieldSpec spec)
+ {
+ var columnIndex = -1;
+ for (var i = 0; i < columns.Count; i++)
+ {
+ if (string.Equals(columns[i], spec.Field, StringComparison.OrdinalIgnoreCase))
+ {
+ columnIndex = i;
+ break;
+ }
+ }
+ if (columnIndex < 0)
+ {
+ return new TagValue(false, $"결과에 '{spec.Field}' 컬럼이 없습니다"
+ + $" (있는 컬럼: {string.Join(", ", columns.Take(8))})");
+ }
+
+ if (spec.Form == DataTableFieldForm.Select && spec.Filter.Length > 0)
+ {
+ using var table = new DataTable();
+ foreach (var name in columns)
+ {
+ // SELECT * 조인은 컬럼명이 겹칠 수 있다 — Oracle 어댑터의 Fill 과 같은
+ // 규칙(이름1, 이름2 …)으로 비껴 담는다. spec.Field 는 첫 것과 매칭된다.
+ var unique = name;
+ var suffix = 1;
+ while (table.Columns.Contains(unique))
+ {
+ unique = name + suffix.ToString(System.Globalization.CultureInfo.InvariantCulture);
+ suffix++;
+ }
+ table.Columns.Add(unique, typeof(string));
+ }
+ foreach (var row in rows)
+ {
+ table.Rows.Add(row.Select(object (c) => c ?? string.Empty).ToArray());
+ }
+ DataRow[] picked;
+ try
+ {
+ picked = table.Select(spec.Filter);
+ }
+ catch (Exception ex)
+ {
+ // 필터 문법이 깨졌다 — 레거시 런타임은 빈 catch 로 삼켜 빈칸이 됐다
+ return new TagValue(false, $"Select 필터를 해석할 수 없습니다: {ex.Message}");
+ }
+ if (spec.RowIndex >= picked.Length)
+ {
+ return new TagValue(false, picked.Length == 0
+ ? $"필터({spec.Filter})에 맞는 행이 없습니다"
+ : $"{spec.RowIndex}번 행이 없습니다(필터 결과 {picked.Length}행)");
+ }
+ var filtered = picked[spec.RowIndex][columnIndex]?.ToString() ?? string.Empty;
+ return filtered.Length > 0
+ ? new TagValue(true, filtered)
+ : new TagValue(false, $"{spec.Field} 값이 비어 있습니다");
+ }
+
+ if (spec.RowIndex >= rows.Count)
+ {
+ return new TagValue(false, rows.Count == 0
+ ? "조회 결과가 0행입니다"
+ : $"{spec.RowIndex}번 행이 없습니다(결과 {rows.Count}행)");
+ }
+ var cell = rows[spec.RowIndex][columnIndex] ?? string.Empty;
+ return cell.Length > 0
+ ? new TagValue(true, cell)
+ : new TagValue(false, $"{spec.Field} 값이 비어 있습니다");
+ }
+ #endregion
+}
diff --git a/src/SheetMe.Designer/Services/MDataTableRunner.cs b/src/SheetMe.Designer/Services/MDataTableRunner.cs
index 0183368..aada052 100644
--- a/src/SheetMe.Designer/Services/MDataTableRunner.cs
+++ b/src/SheetMe.Designer/Services/MDataTableRunner.cs
@@ -95,42 +95,14 @@ public sealed class MDataTableRunner : IDataFieldResolver
// 아무 표시 없이 빈칸이 된다. 여기서는 이름을 말한다.
return new TagValue(false, $"데이터소스 '{spec.TableName}' 이 이 서식에 없습니다");
}
- if (spec.Form == DataTableFieldForm.Select && spec.Filter.Length > 0)
- {
- // Select 필터는 DataTable.Select 의 메모리 필터 문법이다(SQL 이 아니다).
- // 아직 옮기지 않았다 — 무시하고 행 번호만 쓰면 다른 행의 값이 조용히 찍힌다.
- return new TagValue(false, $"Select 필터를 아직 옮기지 않았습니다: {spec.Filter}");
- }
-
var run = Run(spec.TableName);
if (!run.Ok)
{
return new TagValue(false, run.Error ?? "조회에 실패했습니다");
}
- if (spec.RowIndex >= run.Rows.Count)
- {
- return new TagValue(false, run.Rows.Count == 0
- ? "조회 결과가 0행입니다"
- : $"{spec.RowIndex}번 행이 없습니다(결과 {run.Rows.Count}행)");
- }
- var column = -1;
- for (var i = 0; i < run.Columns.Count; i++)
- {
- if (string.Equals(run.Columns[i], spec.Field, StringComparison.OrdinalIgnoreCase))
- {
- column = i;
- break;
- }
- }
- if (column < 0)
- {
- return new TagValue(false, $"결과에 '{spec.Field}' 컬럼이 없습니다"
- + $" (있는 컬럼: {string.Join(", ", run.Columns.Take(8))})");
- }
- var cell = run.Rows[spec.RowIndex][column] ?? string.Empty;
- return cell.Length > 0
- ? new TagValue(true, cell)
- : new TagValue(false, $"{spec.Field} 값이 비어 있습니다");
+ // 행 선택(Rows 형 번호 / Select 형 .NET DataTable.Select 필터)은
+ // 순수 함수에 있다 — DB 없이 검증할 수 있어야 진단이 접속에 안 묶인다
+ return DataTableFieldPicker.Pick(run.Columns, run.Rows, spec);
}
private MDataTableResult Execute(string name)
diff --git a/src/SheetMe.Designer/Services/PrintService.cs b/src/SheetMe.Designer/Services/PrintService.cs
index 72ca1be..6cf9aa6 100644
--- a/src/SheetMe.Designer/Services/PrintService.cs
+++ b/src/SheetMe.Designer/Services/PrintService.cs
@@ -21,6 +21,18 @@ public static class PrintService
SheetMe.Core.Catalog.IDataFieldResolver? fields = null)
{
var dialog = new System.Windows.Controls.PrintDialog();
+ // 용지를 문서 크기로 미리 지정한다 — 지정하지 않으면 드라이버가 폭이 비슷한
+ // 기본 용지에 얹는다. 실측: P062(720×856DIP = 540×642pt)를 PDF 로 뽑았더니
+ // MediaBox 가 540×735pt(≈16절 190×260mm)였다 — 폭이 우연히 같아 좌우는 멀쩡하고
+ // 아래에만 33mm 여백이 더 붙어 화면과 다른 종이가 됐다.
+ // ShowDialog 전에 넣어야 대화상자의 기본값이 되고, 사용자가 대화상자에서
+ // 용지를 바꾸면 그 선택이 이긴다(우리는 기본값만 제시한다).
+ if (designer.Pages.Count > 0)
+ {
+ var first = designer.Pages[0];
+ dialog.PrintTicket.PageMediaSize = new System.Printing.PageMediaSize(
+ first.WidthDip, first.HeightDip); // PageMediaSize 는 1/96" (DIP) 단위다
+ }
if (dialog.ShowDialog() != true)
{
return;
diff --git a/tests/SheetMe.Core.Tests/DataTableFieldPickerTests.cs b/tests/SheetMe.Core.Tests/DataTableFieldPickerTests.cs
new file mode 100644
index 0000000..2280e2c
--- /dev/null
+++ b/tests/SheetMe.Core.Tests/DataTableFieldPickerTests.cs
@@ -0,0 +1,73 @@
+using SheetMe.Core.Catalog;
+
+namespace SheetMe.Core.Tests;
+
+///
+/// 배선 한 칸 선택 — Select 형 필터는 .NET DataTable.Select 를 그대로 쓰므로
+/// 여기서는 "같은 엔진에 제대로 넘겼는가"(필터 적용·행 선택·사유)를 본다.
+///
+[TestClass]
+public sealed class DataTableFieldPickerTests
+{
+ #region Methods
+ private static readonly string[] Columns = { "NAM", "TYP" };
+
+ private static readonly string[][] Rows =
+ {
+ new[] { "감기", "M" },
+ new[] { "골절", "S" },
+ new[] { "당뇨", "S" },
+ };
+
+ private static DataTableFieldSpec Select(string filter, int row, string field = "NAM")
+ => new()
+ {
+ TableName = "T", Form = DataTableFieldForm.Select,
+ Filter = filter, RowIndex = row, Field = field,
+ };
+
+ [TestMethod]
+ public void Select_필터가_행을_거른다()
+ {
+ // TYP='S' 인 행이 둘 — 0번은 골절, 1번은 당뇨
+ Assert.AreEqual("골절", DataTableFieldPicker.Pick(Columns, Rows, Select("TYP = 'S'", 0)).Text);
+ Assert.AreEqual("당뇨", DataTableFieldPicker.Pick(Columns, Rows, Select("TYP = 'S'", 1)).Text);
+ }
+
+ [TestMethod]
+ public void Select_필터에_맞는_행이_없으면_사유다()
+ {
+ var value = DataTableFieldPicker.Pick(Columns, Rows, Select("TYP = 'X'", 0));
+ Assert.IsFalse(value.Resolved);
+ StringAssert.Contains(value.Text, "맞는 행이 없습니다");
+ }
+
+ [TestMethod]
+ public void Select_필터_문법이_깨지면_사유다()
+ {
+ // 레거시 런타임은 빈 catch 로 삼켜 빈칸이 됐다 — 여기서는 사유가 나온다
+ var value = DataTableFieldPicker.Pick(Columns, Rows, Select("TYP === ", 0));
+ Assert.IsFalse(value.Resolved);
+ StringAssert.Contains(value.Text, "해석할 수 없습니다");
+ }
+
+ [TestMethod]
+ public void Rows_형은_행_번호로_고른다()
+ {
+ var spec = new DataTableFieldSpec
+ {
+ TableName = "T", Form = DataTableFieldForm.Rows, RowIndex = 2, Field = "NAM",
+ };
+ Assert.AreEqual("당뇨", DataTableFieldPicker.Pick(Columns, Rows, spec).Text);
+ }
+
+ [TestMethod]
+ public void 중복_컬럼명은_Fill_규칙으로_비껴_담고_첫_컬럼과_매칭된다()
+ {
+ // SELECT * 조인에서 같은 이름이 두 번 오는 경우 — 첫 컬럼의 값이 나와야 한다
+ var dup = new[] { "NAM", "NAM" };
+ var rows = new[] { new[] { "첫값", "둘값" } };
+ Assert.AreEqual("첫값", DataTableFieldPicker.Pick(dup, rows, Select("NAM = '첫값'", 0)).Text);
+ }
+ #endregion
+}