데이터소스 배선 전용 편집기 — 조용히 실패하던 문법을 코드가 조립한다

이 시스템의 핵심 자동 채움 경로다. MDataTable 조회 결과의 한 칸을 입력 컨트롤에 꽂는 배선인데,
지금까지 SheetMe 는 ComboBox 에만 안내 없는 자유 텍스트 한 줄이 있었고 나머지 7종에는 항목조차 없었다.

문제는 형식을 어겼을 때의 증상이다. 런타임 파서(ucLoadSheetBase.DataTableBinding_)는 문자열을
쪼개서 읽고, 실패하면 **빈 catch 가 예외를 삼킨다**. 로그도 없다. 디자이너에서는 정상으로 보이고
임상 화면에서만 값이 안 채워지므로 원인 추적이 어렵다. 그래서 문법 골격은 코드가 조립하고
저장 전에 검사한다.

■ 실측이 통설을 뒤집었다

레거시 전용 편집기(fmMDataTableField)는 Select 형 한 가지만 만든다:
  {이름}.Select("{필터}")({행}).Item("{필드}")   ← 공백 0개
그래서 이것이 '정본'이라고 알려져 있었다.

--db-dtf 진단을 새로 만들어 운영 디자인 1,271건을 센 결과는 반대였다:
  Rows 형 1,624건(76%) · Select 형 268건(12%) · 미완성 "MDataTable1.Rows" 234건(11%)
Select 형만 읽었다면 기존 배선의 4분의 3을 편집기가 못 여는 상태로 출시할 뻔했다.
게다가 형태를 함부로 바꾸면 안 된다 — 보조 로더(bzDesignSheetLoader)는 .Select( 분기가 아예 없어
Rows 형만 이해한다. 그래서 **연 값의 형태를 그대로 유지**하고, 새로 만들 때만 Select 형으로 시작한다.

같은 집계에서 운영 결함도 드러났다: 같은 서식에 없는 데이터소스를 가리키는 값 20건
(S0003 은 MDataTable2 를 가리키는데 그 서식에는 MDataTable3 만 있다). 전부 조용히 실패한다.
런타임 조회가 대소문자를 구분하므로 검증에 이름 일치 검사를 넣었다.

■ 레거시보다 나은 점 3가지 (파리티 초과지만 위험 없음)

1. 기존 값을 되읽어 채운다. 레거시는 열 때마다 백지에서 시작해(InitializeSelectedData)
   필드 하나만 고치려 해도 전부 다시 골라야 했다.
2. 아무것도 안 고르고 확인하는 경로를 막는다. 레거시는 5조각을 이어붙이므로 리터럴 ")(" 가 저장된다.
3. 저장 전 검사 — 필터·필드의 큰따옴표(파서가 따옴표로 쪼갠다), 이름의 마침표(첫 조각만 떼어 간다),
   음수 행. 레거시는 검증이 0이다.

필드 후보를 목록으로 주지는 않았다. 레거시는 쿼리를 실제로 실행해 결과 컬럼을 쓰는데,
그 쿼리에는 테스트 환자 설정이 필요한 치환 토큰이 들어 있어 재현이 별개 작업이다.
대신 필드는 자유 입력으로 두고 그 사정을 화면에 적었다. 실제로 필드에는 SQL 별칭 표현식이
들어오기도 한다(운영 값 EMDOPRDTE||'-'||EMDOPNAME) — 식별자 화이트리스트로 막으면 안 된다.

배선을 붙인 타입은 레거시가 bzMDataTableField 편집기를 다는 8종 그대로다:
TextBox · ComboBox · CheckBox · RadioButton · DateTimePicker · MaskedTextBox · CalcBox · PictureBox.

해석 못 하는 값은 조용히 고쳐 쓰지 않는다 — 인스펙터에 "⚠ 형식을 알 수 없음: 원문" 으로 드러낸다
(운영의 미완성 값 234건이 여기 해당한다).

단위 테스트 18건 추가(표본은 전부 실측·레거시 소스 인용). 편집 스모크 4건 추가.
회귀: 테스트 142/142, 편집 스모크 187건 실패 0,
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 12:49:38 +09:00
co-authored by Claude Opus 5
parent 59b0e457fe
commit a90dd1301e
12 changed files with 868 additions and 1 deletions
+156
View File
@@ -615,6 +615,162 @@ public static class DbSmoke
}
}
/// <summary>
/// DataTableField / DataTableItms 값 형태 분포(--db-dtf) — 전용 편집기가 어떤 형태를 읽어야 하는지(읽기 전용).
///
/// 레거시 편집기가 만드는 정본은 <c>{Name}.Select("{필터}")({행}).Item("{필드}")</c> 한 가지지만,
/// 손으로 쓴 <c>{Name}.Rows({행}).Item("{필드}")</c> 형이 섞여 있다. 어느 쪽이 얼마나 되는지 모르면
/// 편집기가 기존 값을 못 읽어 사용자가 열 때마다 백지에서 다시 조립하게 된다.
/// 파서 제약도 함께 센다 — 필터에 큰따옴표가 들어가면 런타임이 조용히 어긋난다.
/// </summary>
public static int RunDataTableFieldReport(string reportPath)
{
var lines = new List<string>();
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<string, int>(StringComparer.Ordinal);
var empty = 0;
var quoteInFilter = new List<string>();
var samples = new List<string>();
var itms = new Dictionary<string, int>(StringComparer.Ordinal);
// 첫 토큰이 그 서식의 MDataTable 이름과 대소문자까지 맞는지 — 어긋나면 런타임이 무음 실패한다
var unresolved = new List<string>();
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<string>(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;
}
}
/// <summary>
/// 선(MLine) 방향 일치 검사(--db-lines) — 디자이너 렌더를 바꿔야 하는지 판단하는 근거(읽기 전용).
///