쿼리 편집기 레이아웃 — 카드 두 장, 갈래 칩, 그리고 《컨트롤명》 치환
목업대로 다시 짰다. 색·모서리·간격은 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:
co-authored by
Claude Opus 5
parent
7ce7506286
commit
68fc01c6ba
@@ -6,7 +6,8 @@ namespace SheetMe.Core.Catalog;
|
|||||||
/// <paramref name="NeedsColumn"/> 이 true 면 컬럼명을 채워야 완성된다(DataRow 접근형).
|
/// <paramref name="NeedsColumn"/> 이 true 면 컬럼명을 채워야 완성된다(DataRow 접근형).
|
||||||
/// 삽입 후 사용자가 <c>컬럼명</c> 자리를 고쳐야 하므로 편집기가 그 부분을 선택해 준다.
|
/// 삽입 후 사용자가 <c>컬럼명</c> 자리를 고쳐야 하므로 편집기가 그 부분을 선택해 준다.
|
||||||
/// </summary>
|
/// </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>
|
/// <summary>
|
||||||
/// 목록에 띄우는 짧은 형태 — 클래스 전체 이름을 뺀 나머지.
|
/// 목록에 띄우는 짧은 형태 — 클래스 전체 이름을 뺀 나머지.
|
||||||
@@ -15,6 +16,18 @@ public sealed record QueryVariable(string Group, string Token, string Descriptio
|
|||||||
/// 정작 다른 부분(속성명)이 오른쪽 끝에서 잘려 무엇이 무엇인지 구분되지 않는다.
|
/// 정작 다른 부분(속성명)이 오른쪽 끝에서 잘려 무엇이 무엇인지 구분되지 않는다.
|
||||||
/// 전체 문자열은 툴팁과 삽입 결과로 확인할 수 있다.
|
/// 전체 문자열은 툴팁과 삽입 결과로 확인할 수 있다.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <summary>
|
||||||
|
/// 상단 칩으로 거르는 큰 갈래 — 그룹은 일곱이라 칩으로 늘어놓으면 그것대로 목록이 된다.
|
||||||
|
/// 갈래는 '값이 어디서 오는가'로 나눈다.
|
||||||
|
/// </summary>
|
||||||
|
public string Bucket => Group switch
|
||||||
|
{
|
||||||
|
"환자" or "외부연계" or "환자 상세(컬럼 지정)" => "환자",
|
||||||
|
"서식" or "서식 상세(컬럼 지정)" => "서식",
|
||||||
|
"작업자" => "작업자",
|
||||||
|
_ => "컨트롤",
|
||||||
|
};
|
||||||
|
|
||||||
public string ShortForm
|
public string ShortForm
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
@@ -83,14 +96,14 @@ public static class LegacyQueryVariableCatalog
|
|||||||
// ── 환자(bzPatientInfo) — 스칼라 ──
|
// ── 환자(bzPatientInfo) — 스칼라 ──
|
||||||
new("환자", $"<<{Patient}.ChtNum>>", "차트번호(등록번호)"),
|
new("환자", $"<<{Patient}.ChtNum>>", "차트번호(등록번호)"),
|
||||||
new("환자", $"<<{Patient}.ComNum>>", "내원번호"),
|
new("환자", $"<<{Patient}.ComNum>>", "내원번호"),
|
||||||
new("환자", $"<<{Patient}.ComNum_Refer>>", "참조 내원번호(없으면 0)"),
|
new("환자", $"<<{Patient}.ComNum_Refer>>", "참조 내원번호(없으면 0)", TypeName: "Int32"),
|
||||||
new("환자", $"<<{Patient}.ComCvtCom>>", "전환 내원번호(외래↔입원 연결)"),
|
new("환자", $"<<{Patient}.ComCvtCom>>", "전환 내원번호(외래↔입원 연결)"),
|
||||||
new("환자", $"<<{Patient}.PatTyp>>", "환자구분 O 외래 / I 입원"),
|
new("환자", $"<<{Patient}.PatTyp>>", "환자구분 O 외래 / I 입원"),
|
||||||
new("환자", $"<<{Patient}.PatTyp_Refer>>", "참조 내원의 환자구분"),
|
new("환자", $"<<{Patient}.PatTyp_Refer>>", "참조 내원의 환자구분"),
|
||||||
new("환자", $"<<{Patient}.AdpDtm>>", "적용일시 yyyyMMddHHmm"),
|
new("환자", $"<<{Patient}.AdpDtm>>", "적용일시 yyyyMMddHHmm"),
|
||||||
new("환자", $"<<{Patient}.OrderAdpDtm>>", "처방 적용일시"),
|
new("환자", $"<<{Patient}.OrderAdpDtm>>", "처방 적용일시"),
|
||||||
new("환자", $"<<{Patient}.Age>>", "나이(산출 불가 시 ?)"),
|
new("환자", $"<<{Patient}.Age>>", "나이(산출 불가 시 ?)"),
|
||||||
new("환자", $"<<{Patient}.AgeInMonth>>", "개월수"),
|
new("환자", $"<<{Patient}.AgeInMonth>>", "개월수", TypeName: "Int32"),
|
||||||
new("환자", $"<<{Patient}.Sex>>", "성별"),
|
new("환자", $"<<{Patient}.Sex>>", "성별"),
|
||||||
new("환자", $"<<{Patient}.PatMblPhn>>", "휴대전화번호"),
|
new("환자", $"<<{Patient}.PatMblPhn>>", "휴대전화번호"),
|
||||||
new("환자", $"<<{Patient}.ComDayCar>>", "낮병동 여부 Y/N"),
|
new("환자", $"<<{Patient}.ComDayCar>>", "낮병동 여부 Y/N"),
|
||||||
@@ -101,11 +114,11 @@ public static class LegacyQueryVariableCatalog
|
|||||||
// 외부연계(OEM) 슬롯 — E_OemInf 의 같은 이름 컬럼으로 넘어간다.
|
// 외부연계(OEM) 슬롯 — E_OemInf 의 같은 이름 컬럼으로 넘어간다.
|
||||||
// 슬롯별 의미는 연계 프로그램마다 달라 설명을 단정하지 않는다(OemNum1 만 처방키 관행).
|
// 슬롯별 의미는 연계 프로그램마다 달라 설명을 단정하지 않는다(OemNum1 만 처방키 관행).
|
||||||
new("외부연계", $"<<{Patient}.OemTableNam>>", "원본 테이블명"),
|
new("외부연계", $"<<{Patient}.OemTableNam>>", "원본 테이블명"),
|
||||||
new("외부연계", $"<<{Patient}.OemNum1>>", "숫자키1(주로 처방키)"),
|
new("외부연계", $"<<{Patient}.OemNum1>>", "숫자키1(주로 처방키)", TypeName: "Int32"),
|
||||||
new("외부연계", $"<<{Patient}.OemNum2>>", "숫자키2"),
|
new("외부연계", $"<<{Patient}.OemNum2>>", "숫자키2", TypeName: "Int32"),
|
||||||
new("외부연계", $"<<{Patient}.OemNum3>>", "숫자키3"),
|
new("외부연계", $"<<{Patient}.OemNum3>>", "숫자키3", TypeName: "Int32"),
|
||||||
new("외부연계", $"<<{Patient}.OemNum4>>", "숫자키4"),
|
new("외부연계", $"<<{Patient}.OemNum4>>", "숫자키4", TypeName: "Int32"),
|
||||||
new("외부연계", $"<<{Patient}.OemNum5>>", "숫자키5"),
|
new("외부연계", $"<<{Patient}.OemNum5>>", "숫자키5", TypeName: "Int32"),
|
||||||
new("외부연계", $"<<{Patient}.OemVar1>>", "문자값1"),
|
new("외부연계", $"<<{Patient}.OemVar1>>", "문자값1"),
|
||||||
new("외부연계", $"<<{Patient}.OemVar2>>", "문자값2"),
|
new("외부연계", $"<<{Patient}.OemVar2>>", "문자값2"),
|
||||||
new("외부연계", $"<<{Patient}.OemVar3>>", "문자값3"),
|
new("외부연계", $"<<{Patient}.OemVar3>>", "문자값3"),
|
||||||
@@ -114,30 +127,30 @@ public static class LegacyQueryVariableCatalog
|
|||||||
|
|
||||||
// ── 환자 상세(DataRow) — 컬럼명을 채워야 완성된다 ──
|
// ── 환자 상세(DataRow) — 컬럼명을 채워야 완성된다 ──
|
||||||
// item 은 반드시 소문자다(정규식이 대소문자를 구분한다).
|
// item 은 반드시 소문자다(정규식이 대소문자를 구분한다).
|
||||||
new("환자 상세(컬럼 지정)", $"<<{Patient}.PatInfDR.item(\"컬럼명\")>>", "환자 기본정보 P_PatInf 의 컬럼", NeedsColumn: true),
|
new("환자 상세(컬럼 지정)", $"<<{Patient}.PatInfDR.item(\"컬럼명\")>>", "환자 기본정보 P_PatInf 의 컬럼", NeedsColumn: true, TypeName: "DataRow"),
|
||||||
new("환자 상세(컬럼 지정)", $"<<{Patient}.ComInfDR.item(\"컬럼명\")>>", "내원정보 P_ComInf 의 컬럼", NeedsColumn: true),
|
new("환자 상세(컬럼 지정)", $"<<{Patient}.ComInfDR.item(\"컬럼명\")>>", "내원정보 P_ComInf 의 컬럼", NeedsColumn: true, TypeName: "DataRow"),
|
||||||
new("환자 상세(컬럼 지정)", $"<<{Patient}.ComInfDR_Refer.item(\"컬럼명\")>>", "참조 내원의 P_ComInf 컬럼", NeedsColumn: true),
|
new("환자 상세(컬럼 지정)", $"<<{Patient}.ComInfDR_Refer.item(\"컬럼명\")>>", "참조 내원의 P_ComInf 컬럼", NeedsColumn: true, TypeName: "DataRow"),
|
||||||
new("환자 상세(컬럼 지정)", $"<<{Patient}.CodInfDR.item(\"컬럼명\")>>", "진료과·담당의 P_CodInf 의 컬럼", NeedsColumn: true),
|
new("환자 상세(컬럼 지정)", $"<<{Patient}.CodInfDR.item(\"컬럼명\")>>", "진료과·담당의 P_CodInf 의 컬럼", NeedsColumn: true, TypeName: "DataRow"),
|
||||||
new("환자 상세(컬럼 지정)", $"<<{Patient}.CoiInfDR.item(\"컬럼명\")>>", "보험 자격정보 P_CoiInf 의 컬럼", NeedsColumn: true),
|
new("환자 상세(컬럼 지정)", $"<<{Patient}.CoiInfDR.item(\"컬럼명\")>>", "보험 자격정보 P_CoiInf 의 컬럼", NeedsColumn: true, TypeName: "DataRow"),
|
||||||
new("환자 상세(컬럼 지정)", $"<<{Patient}.CowInfDR.item(\"컬럼명\")>>", "입원 병동·병실 P_CowInf 의 컬럼", NeedsColumn: true),
|
new("환자 상세(컬럼 지정)", $"<<{Patient}.CowInfDR.item(\"컬럼명\")>>", "입원 병동·병실 P_CowInf 의 컬럼", NeedsColumn: true, TypeName: "DataRow"),
|
||||||
|
|
||||||
// ── 서식(bzSheetInfo) — 스칼라 ──
|
// ── 서식(bzSheetInfo) — 스칼라 ──
|
||||||
new("서식", $"<<{Sheet}.ShtCod>>", "서식 코드"),
|
new("서식", $"<<{Sheet}.ShtCod>>", "서식 코드"),
|
||||||
new("서식", $"<<{Sheet}.ShtNam>>", "서식명"),
|
new("서식", $"<<{Sheet}.ShtNam>>", "서식명"),
|
||||||
new("서식", $"<<{Sheet}.EmrKey>>", "EMR 키(신규 작성 시 0)"),
|
new("서식", $"<<{Sheet}.EmrKey>>", "EMR 키(신규 작성 시 0)", TypeName: "Int32"),
|
||||||
new("서식", $"<<{Sheet}.AdpDtm>>", "작성일시"),
|
new("서식", $"<<{Sheet}.AdpDtm>>", "작성일시"),
|
||||||
new("서식", $"<<{Sheet}.TemKey>>", "템플릿 키"),
|
new("서식", $"<<{Sheet}.TemKey>>", "템플릿 키", TypeName: "Int32"),
|
||||||
new("서식", $"<<{Sheet}.PatTyp>>", "서식 기준 환자구분"),
|
new("서식", $"<<{Sheet}.PatTyp>>", "서식 기준 환자구분"),
|
||||||
new("서식", $"<<{Sheet}.EmrGbn>>", "EMR 구분"),
|
new("서식", $"<<{Sheet}.EmrGbn>>", "EMR 구분"),
|
||||||
new("서식", $"<<{Sheet}.OdrNum>>", "처방 번호"),
|
new("서식", $"<<{Sheet}.OdrNum>>", "처방 번호"),
|
||||||
new("서식", $"<<{Sheet}.OdrSeq>>", "처방 일련번호"),
|
new("서식", $"<<{Sheet}.OdrSeq>>", "처방 일련번호"),
|
||||||
new("서식", $"<<{Sheet}.LocalPath>>", "로컬 경로"),
|
new("서식", $"<<{Sheet}.LocalPath>>", "로컬 경로"),
|
||||||
new("서식", $"<<{Sheet}.PrintCstKey>>", "출력 대상 키"),
|
new("서식", $"<<{Sheet}.PrintCstKey>>", "출력 대상 키", TypeName: "Int32"),
|
||||||
new("서식", $"<<{Sheet}.PrintCsdStrDtm>>", "출력 기간 시작일시"),
|
new("서식", $"<<{Sheet}.PrintCsdStrDtm>>", "출력 기간 시작일시"),
|
||||||
new("서식", $"<<{Sheet}.PrintCsdEndDtm>>", "출력 기간 종료일시"),
|
new("서식", $"<<{Sheet}.PrintCsdEndDtm>>", "출력 기간 종료일시"),
|
||||||
|
|
||||||
new("서식 상세(컬럼 지정)", $"<<{Sheet}.ShtMst.item(\"컬럼명\")>>", "서식 마스터 E_ShtMst 의 컬럼", NeedsColumn: true),
|
new("서식 상세(컬럼 지정)", $"<<{Sheet}.ShtMst.item(\"컬럼명\")>>", "서식 마스터 E_ShtMst 의 컬럼", NeedsColumn: true, TypeName: "DataRow"),
|
||||||
new("서식 상세(컬럼 지정)", $"<<{Sheet}.SdgMst.item(\"컬럼명\")>>", "디자인 마스터 E_SdgMst 의 컬럼", NeedsColumn: true),
|
new("서식 상세(컬럼 지정)", $"<<{Sheet}.SdgMst.item(\"컬럼명\")>>", "디자인 마스터 E_SdgMst 의 컬럼", NeedsColumn: true, TypeName: "DataRow"),
|
||||||
|
|
||||||
// ── 작업자(bzWorkInfo) — 스칼라 ──
|
// ── 작업자(bzWorkInfo) — 스칼라 ──
|
||||||
new("작업자", $"<<{Work}.WrkUid>>", "작성자 ID"),
|
new("작업자", $"<<{Work}.WrkUid>>", "작성자 ID"),
|
||||||
@@ -148,6 +161,28 @@ public static class LegacyQueryVariableCatalog
|
|||||||
/// <summary>런타임이 StartsWith 로 비교하는 접두어 3종 — 검증·진단용</summary>
|
/// <summary>런타임이 StartsWith 로 비교하는 접두어 3종 — 검증·진단용</summary>
|
||||||
public static readonly IReadOnlyList<string> Prefixes = new[] { Patient, Sheet, Work };
|
public static readonly IReadOnlyList<string> Prefixes = new[] { Patient, Sheet, Work };
|
||||||
|
|
||||||
|
/// <summary>컨트롤 값 치환 그룹 이름</summary>
|
||||||
|
public const string ControlGroup = "이 서식의 컨트롤";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 같은 서식의 컨트롤 값을 넣는 치환 — <c>《컨트롤명》</c>.
|
||||||
|
///
|
||||||
|
/// <c><<…>></c> 와는 <b>다른 문법이고 다른 단계</b>다. clsMDataTable.ConvertQuery 는
|
||||||
|
/// <c><<…>></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>
|
/// <summary>
|
||||||
/// clsMDataTable.vb:113 의 정규식을 그대로 옮긴 것 — 클래스명 뒤 경로가 이 형태여야 값이 나온다.
|
/// clsMDataTable.vb:113 의 정규식을 그대로 옮긴 것 — 클래스명 뒤 경로가 이 형태여야 값이 나온다.
|
||||||
/// <c>item</c> 이 소문자여야 하는 것까지 포함해 원문 그대로 쓴다(대소문자 무시 옵션을 주면 안 된다).
|
/// <c>item</c> 이 소문자여야 하는 것까지 포함해 원문 그대로 쓴다(대소문자 무시 옵션을 주면 안 된다).
|
||||||
@@ -166,6 +201,12 @@ public static class LegacyQueryVariableCatalog
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool IsResolvable(string token)
|
public static bool IsResolvable(string token)
|
||||||
{
|
{
|
||||||
|
// 《컨트롤명》 은 다른 문법이다 — 이름이 비지만 않으면 형태로는 유효하다.
|
||||||
|
// 그 이름의 컨트롤이 실제로 있는지는 편집기가 문서를 보고 따로 검사한다.
|
||||||
|
if (token.StartsWith('《') && token.EndsWith('》'))
|
||||||
|
{
|
||||||
|
return token.Length > 2;
|
||||||
|
}
|
||||||
if (!token.StartsWith("<<", StringComparison.Ordinal) || !token.EndsWith(">>", StringComparison.Ordinal))
|
if (!token.StartsWith("<<", StringComparison.Ordinal) || !token.EndsWith(">>", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -115,6 +115,22 @@ public static class SqlTokenizer
|
|||||||
continue;
|
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] == '-')
|
if (ch == '-' && i + 1 < text.Length && text[i + 1] == '-')
|
||||||
{
|
{
|
||||||
@@ -227,13 +243,20 @@ public static class SqlTokenizer
|
|||||||
var cursor = start;
|
var cursor = start;
|
||||||
while (cursor < end)
|
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)
|
if (open < 0 || open >= end)
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
var close = text.IndexOf(">>", open + 2, StringComparison.Ordinal);
|
var close = useBracket
|
||||||
if (close < 0 || close + 2 > end)
|
? text.IndexOf('》', open + 1)
|
||||||
|
: text.IndexOf(">>", open + 2, StringComparison.Ordinal);
|
||||||
|
var closeLength = useBracket ? 1 : 2;
|
||||||
|
if (close < 0 || close + closeLength > end)
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -241,8 +264,8 @@ public static class SqlTokenizer
|
|||||||
{
|
{
|
||||||
tokens.Add(new SqlToken(cursor, open - cursor, textKind));
|
tokens.Add(new SqlToken(cursor, open - cursor, textKind));
|
||||||
}
|
}
|
||||||
tokens.Add(new SqlToken(open, close + 2 - open, SqlTokenKind.Variable));
|
tokens.Add(new SqlToken(open, close + closeLength - open, SqlTokenKind.Variable));
|
||||||
cursor = close + 2;
|
cursor = close + closeLength;
|
||||||
}
|
}
|
||||||
if (cursor < end)
|
if (cursor < end)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Windows.Data;
|
||||||
|
using SheetMe.Core.Catalog;
|
||||||
|
|
||||||
|
namespace SheetMe.Designer.Controls;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 치환 변수 그룹을 처음에 펼쳐 둘지 — 그룹 이름으로 판단한다.
|
||||||
|
///
|
||||||
|
/// 일곱 그룹을 다 펴 두면 목록이 길어져 원하는 그룹이 화면 밖으로 밀리고,
|
||||||
|
/// 다 접어 두면 무엇이 있는지 한 번은 눌러 봐야 안다.
|
||||||
|
/// 가장 많이 쓰는 '환자'와, 이 서식에서만 유효해서 존재 자체가 정보인 '컨트롤'만 펴 둔다.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -113,7 +113,7 @@ public static class DialogShots
|
|||||||
{
|
{
|
||||||
("01-sheet-open", () => new Views.SheetOpenDialogView(_ => SampleSheets())),
|
("01-sheet-open", () => new Views.SheetOpenDialogView(_ => SampleSheets())),
|
||||||
("02-sheet-history", () => new Views.SheetHistoryDialogView("S999", "표본 서식", SampleVersions())),
|
("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 = <<M.CMM.HISOperatingInfo.bzPatientInfo.ChtNum>>")),
|
"SELECT ChtNum, PatNam FROM P_PatMst\nWHERE ChtNum = <<M.CMM.HISOperatingInfo.bzPatientInfo.ChtNum>>")),
|
||||||
// 실제 카탈로그(384종)와 실제 제안 순위로 찍는다 — 표본 몇 개로는 분류·건수가 안 보인다
|
// 실제 카탈로그(384종)와 실제 제안 순위로 찍는다 — 표본 몇 개로는 분류·건수가 안 보인다
|
||||||
("04-tag-picker", () => new Views.TagPickerDialogView(
|
("04-tag-picker", () => new Views.TagPickerDialogView(
|
||||||
@@ -311,6 +311,10 @@ public static class DialogShots
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>폰트 관리자·미리보기용 표본 문서 — 컨트롤이 있어야 목록이 비지 않는다</summary>
|
/// <summary>폰트 관리자·미리보기용 표본 문서 — 컨트롤이 있어야 목록이 비지 않는다</summary>
|
||||||
|
/// <summary>쿼리 편집기 — 이 서식의 컨트롤을 《컨트롤명》 후보로 넘겨 그룹이 실제로 차는지 본다</summary>
|
||||||
|
private static Window NewQueryEditor(DesignerViewModel designer, string sql)
|
||||||
|
=> new Views.QueryEditorWindow("데이터소스", sql, controlNames: designer.ControlNames());
|
||||||
|
|
||||||
private static DesignerViewModel SampleDesigner()
|
private static DesignerViewModel SampleDesigner()
|
||||||
{
|
{
|
||||||
var document = new FormDocument { FormId = "S999", Title = "표본 서식" };
|
var document = new FormDocument { FormId = "S999", Title = "표본 서식" };
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
오류는 B.CloseHover 와 같은 값이라 다크 팔레트에 새 색이 늘지 않는다. -->
|
오류는 B.CloseHover 와 같은 값이라 다크 팔레트에 새 색이 늘지 않는다. -->
|
||||||
<SolidColorBrush x:Key="B.Warning" Color="#E0A33A" />
|
<SolidColorBrush x:Key="B.Warning" Color="#E0A33A" />
|
||||||
<SolidColorBrush x:Key="B.Danger" Color="#E04A2B" />
|
<SolidColorBrush x:Key="B.Danger" Color="#E04A2B" />
|
||||||
|
<SolidColorBrush x:Key="B.Ok" Color="#5BC46E" />
|
||||||
|
|
||||||
<!-- SQL 구문 색 — 어두운 배경 대비 4.5:1 이상 -->
|
<!-- SQL 구문 색 — 어두운 배경 대비 4.5:1 이상 -->
|
||||||
<SolidColorBrush x:Key="B.SqlKeyword" Color="#6FB6F2" />
|
<SolidColorBrush x:Key="B.SqlKeyword" Color="#6FB6F2" />
|
||||||
|
|||||||
@@ -81,6 +81,7 @@
|
|||||||
-->
|
-->
|
||||||
<SolidColorBrush x:Key="B.Warning" Color="#B45309" />
|
<SolidColorBrush x:Key="B.Warning" Color="#B45309" />
|
||||||
<SolidColorBrush x:Key="B.Danger" Color="#C4321A" />
|
<SolidColorBrush x:Key="B.Danger" Color="#C4321A" />
|
||||||
|
<SolidColorBrush x:Key="B.Ok" Color="#1A7F37" />
|
||||||
|
|
||||||
<!-- SQL 구문 색 — 흰 배경 대비 4.5:1 이상.
|
<!-- SQL 구문 색 — 흰 배경 대비 4.5:1 이상.
|
||||||
치환 변수는 이 편집기에서 가장 중요한 조각이라 가장 진하게 둔다. -->
|
치환 변수는 이 편집기에서 가장 중요한 조각이라 가장 진하게 둔다. -->
|
||||||
|
|||||||
@@ -946,6 +946,13 @@ public sealed class DesignerViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 문서의 모든 컨트롤 이름 — 쿼리의 《컨트롤명》 치환 후보.
|
||||||
|
/// 런타임도 같은 서식 안에서만 이름을 찾으므로(clsMDataTable 의 poAllControlOnSheet) 범위가 같다.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> ControlNames()
|
||||||
|
=> AllControls().Select(c => c.Id).Where(id => id.Length > 0).Distinct(StringComparer.Ordinal).ToList();
|
||||||
|
|
||||||
/// <summary>문서 전체 컨트롤 순회(컨테이너 자식 포함, 모델 순서)</summary>
|
/// <summary>문서 전체 컨트롤 순회(컨테이너 자식 포함, 모델 순서)</summary>
|
||||||
private IEnumerable<ControlViewModel> AllControls()
|
private IEnumerable<ControlViewModel> AllControls()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -130,7 +130,10 @@ public partial class DesignerCanvasView : UserControl
|
|||||||
// 데이터소스(MDataTable)는 인라인 대신 전용 쿼리 편집기 창
|
// 데이터소스(MDataTable)는 인라인 대신 전용 쿼리 편집기 창
|
||||||
if (target is DataTableViewModel dataTable)
|
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),
|
Owner = Window.GetWindow(this),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,157 +2,322 @@
|
|||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:ctl="clr-namespace:SheetMe.Designer.Controls"
|
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"
|
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
|
||||||
Style="{StaticResource ThemedWindow}">
|
Style="{StaticResource ThemedWindow}">
|
||||||
<DockPanel Margin="12">
|
<Window.Resources>
|
||||||
<TextBlock x:Name="HintText" DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,8"
|
<!-- 처음 펼쳐 둘 그룹 판단 — 가장 많이 쓰는 '환자'와 이 서식의 컨트롤만 펴 둔다 -->
|
||||||
Text="작성 시점에 <<변수>> 가 실제 값으로 치환됩니다. 변수는 클래스 전체 이름을 포함해야 하므로 우측 목록에서 삽입하세요 — 임의로 줄여 쓰면 치환되지 않고 SQL 에 그대로 남아 오류가 납니다."/>
|
<ctl:QueryGroupExpandedConverter x:Key="GroupExpanded"/>
|
||||||
|
|
||||||
<DockPanel DockPanel.Dock="Bottom" Margin="0,10,0,0">
|
<!-- 카드: 두 영역을 눈으로 갈라 준다 -->
|
||||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
|
<Style x:Key="Card" TargetType="Border">
|
||||||
<TextBlock x:Name="LengthText" VerticalAlignment="Center" Margin="0,0,12,0"
|
<Setter Property="CornerRadius" Value="10"/>
|
||||||
Foreground="{DynamicResource B.Muted}" FontSize="11.5"/>
|
<Setter Property="BorderThickness" Value="1"/>
|
||||||
<Button x:Name="TrialButton" Content="검증 실행" Padding="14,5" Click="OnTrial"
|
<Setter Property="BorderBrush" Value="{DynamicResource B.InputBorder}"/>
|
||||||
ToolTip="DB 에서 실제로 돌려 봅니다 (F5). 조회만 실행되며 표본 몇 행만 가져옵니다."/>
|
<Setter Property="Background" Value="{DynamicResource B.Surface}"/>
|
||||||
<Button Content="확인" Padding="20,5" Margin="8,0,0,0" Click="OnConfirm" IsDefault="True"/>
|
</Style>
|
||||||
<Button Content="취소" Padding="20,5" Margin="8,0,0,0" IsCancel="True"/>
|
|
||||||
|
<Style x:Key="CardTitle" TargetType="TextBlock">
|
||||||
|
<Setter Property="FontSize" Value="13.5"/>
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource B.Ink}"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- 편집기 툴바 버튼 -->
|
||||||
|
<Style x:Key="ToolBtn" TargetType="Button" BasedOn="{StaticResource Subtle}">
|
||||||
|
<Setter Property="Padding" Value="8,3"/>
|
||||||
|
<Setter Property="FontSize" Value="11.5"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource B.Ink}"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- 갈래 칩 -->
|
||||||
|
<Style x:Key="BucketChip" TargetType="ToggleButton">
|
||||||
|
<Setter Property="FontSize" Value="11.5"/>
|
||||||
|
<Setter Property="Padding" Value="10,3"/>
|
||||||
|
<Setter Property="Margin" Value="0,0,6,0"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource B.Ink}"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ToggleButton">
|
||||||
|
<Border x:Name="chip" CornerRadius="12" Padding="{TemplateBinding Padding}"
|
||||||
|
Background="{DynamicResource B.Input}"
|
||||||
|
BorderBrush="{DynamicResource B.InputBorder}" BorderThickness="1">
|
||||||
|
<ContentPresenter VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsChecked" Value="True">
|
||||||
|
<Setter TargetName="chip" Property="Background" Value="{DynamicResource B.Accent}"/>
|
||||||
|
<Setter TargetName="chip" Property="BorderBrush" Value="{DynamicResource B.Accent}"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource B.OnAccent}"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="chip" Property="BorderBrush" Value="{DynamicResource B.Accent}"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
</Window.Resources>
|
||||||
|
|
||||||
|
<DockPanel Margin="16,14,16,14">
|
||||||
|
<!-- ── 머리말 ── -->
|
||||||
|
<StackPanel DockPanel.Dock="Top" Margin="0,0,0,12">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<TextBlock Text="쿼리 편집" FontSize="17" FontWeight="Bold"
|
||||||
|
Foreground="{DynamicResource B.Ink}" VerticalAlignment="Center"/>
|
||||||
|
<Border x:Name="TargetChip" CornerRadius="6" Padding="8,2" Margin="10,0,0,0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Background="{DynamicResource B.Input}" BorderBrush="{DynamicResource B.InputBorder}"
|
||||||
|
BorderThickness="1">
|
||||||
|
<TextBlock x:Name="TargetChipText" FontSize="11.5" FontFamily="Consolas, D2Coding, 굴림체"
|
||||||
|
Foreground="{DynamicResource B.AccentText}"/>
|
||||||
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
<TextBlock x:Name="HintText" Margin="0,6,0,0" FontSize="11.5" TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource B.Muted}"
|
||||||
|
Text="작성 시점에 <<변수>> 가 실제 값으로 치환됩니다. 변수는 클래스 전체 이름을 포함해야 하므로 우측 목록에서 삽입하세요."/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- ── 아래 버튼 줄 ── -->
|
||||||
|
<DockPanel DockPanel.Dock="Bottom" Margin="0,12,0,0">
|
||||||
<TextBlock x:Name="ProblemText" VerticalAlignment="Center" Margin="0,0,12,0"
|
<TextBlock x:Name="ProblemText" VerticalAlignment="Center" Margin="0,0,12,0"
|
||||||
Foreground="{DynamicResource B.Danger}" FontSize="11.5"
|
Foreground="{DynamicResource B.Danger}" FontSize="11.5"
|
||||||
TextTrimming="CharacterEllipsis"/>
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
|
||||||
|
<Button x:Name="TrialButton" Padding="14,6" Click="OnTrial"
|
||||||
|
ToolTip="DB 에서 실제로 돌려 봅니다 (F5). 조회만 실행되며 표본 몇 행만 가져옵니다.">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<TextBlock Text="▶" FontSize="10" VerticalAlignment="Center" Margin="0,0,6,0"/>
|
||||||
|
<TextBlock Text="검증 실행" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
<Button Content="확인" Padding="24,6" Margin="8,0,0,0"
|
||||||
|
Style="{StaticResource Primary}" Click="OnConfirm" IsDefault="True"/>
|
||||||
|
<Button Content="취소" Padding="20,6" Margin="8,0,0,0" IsCancel="True"/>
|
||||||
|
</StackPanel>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
||||||
<!-- 검증 결과 — 실행했을 때만 뜬다 -->
|
|
||||||
<Border x:Name="TrialPane" DockPanel.Dock="Bottom" Visibility="Collapsed" Margin="0,10,0,0"
|
|
||||||
MaxHeight="200" CornerRadius="6" BorderThickness="1"
|
|
||||||
BorderBrush="{DynamicResource B.InputBorder}" Background="{DynamicResource B.Surface}">
|
|
||||||
<DockPanel Margin="10,8">
|
|
||||||
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,6">
|
|
||||||
<Button DockPanel.Dock="Right" Content="닫기" Padding="8,1" Style="{StaticResource Subtle}"
|
|
||||||
Click="OnCloseTrial"/>
|
|
||||||
<TextBlock x:Name="TrialSummary" FontSize="11.5" TextWrapping="Wrap"/>
|
|
||||||
</DockPanel>
|
|
||||||
<DataGrid x:Name="TrialGrid" AutoGenerateColumns="True" IsReadOnly="True"
|
|
||||||
HeadersVisibility="Column" FontSize="11.5" MaxHeight="150"
|
|
||||||
GridLinesVisibility="Horizontal" Background="Transparent"
|
|
||||||
AutomationProperties.Name="검증 결과 표본"/>
|
|
||||||
</DockPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="*"/>
|
<ColumnDefinition Width="*"/>
|
||||||
<ColumnDefinition x:Name="SplitterColumn" Width="8"/>
|
<ColumnDefinition x:Name="SplitterColumn" Width="12"/>
|
||||||
<ColumnDefinition x:Name="VariableColumn" Width="360"/>
|
<ColumnDefinition x:Name="VariableColumn" Width="430"/>
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<!-- SQL 편집 영역.
|
<!-- ══ SQL 편집기 카드 ══ -->
|
||||||
색칠은 뒤에 깔린 SqlHighlightLayer 가 하고, 편집은 위에 얹힌 투명 TextBox 가 그대로 한다
|
<Border Grid.Column="0" Style="{StaticResource Card}">
|
||||||
(캐럿·선택·실행취소·IME 를 직접 만들지 않기 위해서다).
|
<DockPanel Margin="14,12,14,12">
|
||||||
두 요소의 글꼴·크기·여백이 어긋나면 글자와 색이 어긋난다 — 반드시 같이 고칠 것. -->
|
<TextBlock DockPanel.Dock="Top" Text="SQL 편집기" Style="{StaticResource CardTitle}"
|
||||||
<Border Grid.Column="0" BorderThickness="1" CornerRadius="6" ClipToBounds="True"
|
Margin="0,0,0,10"/>
|
||||||
BorderBrush="{DynamicResource B.InputBorder}" Background="{DynamicResource B.Input}">
|
|
||||||
<Grid>
|
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="Auto"/>
|
|
||||||
<ColumnDefinition Width="*"/>
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
|
|
||||||
<!-- 줄 번호 -->
|
<!-- 툴바 -->
|
||||||
<Border Grid.Column="0" Background="{DynamicResource B.Surface}" Padding="6,6,6,6">
|
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,8">
|
||||||
<TextBlock x:Name="LineNumbers" FontFamily="Consolas, D2Coding, 굴림체" FontSize="13"
|
<Button DockPanel.Dock="Right" Style="{StaticResource ToolBtn}" Click="OnClearAll"
|
||||||
Foreground="{DynamicResource B.Muted}" TextAlignment="Right" MinWidth="26"/>
|
Foreground="{DynamicResource B.Danger}" Content="전체 지우기"
|
||||||
|
ToolTip="편집 내용을 모두 지웁니다"/>
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<Button Style="{StaticResource ToolBtn}" Content="포맷" Click="OnFormat"
|
||||||
|
ToolTip="예약어를 대문자로 바꾸고 절마다 줄을 나눕니다 (Ctrl+Shift+F). 문자열·치환 변수는 건드리지 않습니다."/>
|
||||||
|
<Button Style="{StaticResource ToolBtn}" Content="주석" Click="OnToggleComment"
|
||||||
|
ToolTip="선택한 줄에 -- 를 붙이거나 뗍니다 (Ctrl+/)"/>
|
||||||
|
<Button Style="{StaticResource ToolBtn}" Content="자동완성" Click="OnInvokeCompletion"
|
||||||
|
ToolTip="커서 위치에서 후보를 띄웁니다 (Ctrl+Space)"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- 상태 줄 -->
|
||||||
|
<Border DockPanel.Dock="Bottom" Margin="0,8,0,0" Padding="10,5" CornerRadius="6"
|
||||||
|
Background="{DynamicResource B.Input}">
|
||||||
|
<DockPanel>
|
||||||
|
<TextBlock DockPanel.Dock="Right" x:Name="CaretText" FontSize="11"
|
||||||
|
Foreground="{DynamicResource B.Muted}"/>
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<Ellipse x:Name="ConnDot" Width="7" Height="7" VerticalAlignment="Center"
|
||||||
|
Margin="0,0,6,0" Fill="{DynamicResource B.Muted}"/>
|
||||||
|
<TextBlock x:Name="ConnText" FontSize="11" VerticalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource B.Muted}"/>
|
||||||
|
<TextBlock Text="│" FontSize="11" Margin="10,0" VerticalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource B.InputBorder}"/>
|
||||||
|
<TextBlock x:Name="LengthText" FontSize="11" VerticalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource B.Muted}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DockPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Grid Grid.Column="1">
|
<!-- 검증 결과 -->
|
||||||
<ctl:SqlHighlightLayer x:Name="Highlight" IsHitTestVisible="False"/>
|
<Border x:Name="TrialPane" DockPanel.Dock="Bottom" Visibility="Collapsed" Margin="0,8,0,0"
|
||||||
<!-- 자동완성 목록 — 커서 아래에 띄운다. StaysOpen 이라 목록을 눌러도 편집 포커스를 잃지 않는다 -->
|
MaxHeight="190" CornerRadius="8" BorderThickness="1"
|
||||||
<!-- StaysOpen=False 여야 바깥을 클릭했을 때 닫힌다.
|
BorderBrush="{DynamicResource B.InputBorder}" Background="{DynamicResource B.Input}">
|
||||||
True 로 두면 목록 항목을 고르기 전까지 화면에 계속 남는다. -->
|
<DockPanel Margin="10,8">
|
||||||
<Popup x:Name="CompletionPopup" Placement="Relative" AllowsTransparency="True"
|
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,6">
|
||||||
StaysOpen="False" PopupAnimation="None" Focusable="False">
|
<Button DockPanel.Dock="Right" Content="닫기" Padding="8,1"
|
||||||
<Border Background="{DynamicResource B.Surface}" BorderThickness="1"
|
Style="{StaticResource ToolBtn}" Click="OnCloseTrial"/>
|
||||||
BorderBrush="{DynamicResource B.InputBorder}" CornerRadius="6" Padding="2">
|
<TextBlock x:Name="TrialSummary" FontSize="11.5" TextWrapping="Wrap"/>
|
||||||
<ListBox x:Name="CompletionList" MaxHeight="240" MinWidth="260" FontSize="12"
|
</DockPanel>
|
||||||
BorderThickness="0" Background="Transparent"
|
<DataGrid x:Name="TrialGrid" AutoGenerateColumns="True" IsReadOnly="True"
|
||||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
HeadersVisibility="Column" FontSize="11.5" MaxHeight="140"
|
||||||
MouseDoubleClick="OnCompletionPicked">
|
GridLinesVisibility="Horizontal" Background="Transparent"
|
||||||
<ListBox.ItemTemplate>
|
AutomationProperties.Name="검증 결과 표본"/>
|
||||||
<DataTemplate>
|
</DockPanel>
|
||||||
<DockPanel MinWidth="240">
|
</Border>
|
||||||
<TextBlock DockPanel.Dock="Right" Text="{Binding KindLabel}" FontSize="10"
|
|
||||||
Foreground="{DynamicResource B.Muted}" Margin="10,0,0,0"
|
<!-- 편집 영역: 색칠 레이어 + 투명 TextBox -->
|
||||||
VerticalAlignment="Center"/>
|
<Border BorderThickness="1" CornerRadius="8" ClipToBounds="True"
|
||||||
<TextBlock Text="{Binding Text}" FontFamily="Consolas, D2Coding, 굴림체"
|
BorderBrush="{DynamicResource B.InputBorder}" Background="{DynamicResource B.Input}">
|
||||||
TextTrimming="CharacterEllipsis"/>
|
<Grid>
|
||||||
</DockPanel>
|
<Grid.ColumnDefinitions>
|
||||||
</DataTemplate>
|
<ColumnDefinition Width="Auto"/>
|
||||||
</ListBox.ItemTemplate>
|
<ColumnDefinition Width="*"/>
|
||||||
</ListBox>
|
</Grid.ColumnDefinitions>
|
||||||
|
<Border Grid.Column="0" Background="{DynamicResource B.Surface}" Padding="8,6">
|
||||||
|
<TextBlock x:Name="LineNumbers" FontFamily="Consolas, D2Coding, 굴림체" FontSize="13"
|
||||||
|
Foreground="{DynamicResource B.Muted}" TextAlignment="Right" MinWidth="24"/>
|
||||||
</Border>
|
</Border>
|
||||||
</Popup>
|
<Grid Grid.Column="1">
|
||||||
<TextBox x:Name="SqlBox"
|
<ctl:SqlHighlightLayer x:Name="Highlight" IsHitTestVisible="False"/>
|
||||||
FontFamily="Consolas, D2Coding, 굴림체" FontSize="13"
|
<!-- StaysOpen=False 여야 바깥을 클릭했을 때 닫힌다 -->
|
||||||
AcceptsReturn="True" AcceptsTab="False"
|
<Popup x:Name="CompletionPopup" Placement="Relative" AllowsTransparency="True"
|
||||||
TextWrapping="NoWrap" Padding="6"
|
StaysOpen="False" PopupAnimation="None" Focusable="False">
|
||||||
Background="Transparent" BorderThickness="0"
|
<Border Background="{DynamicResource B.Surface}" BorderThickness="1"
|
||||||
Foreground="Transparent"
|
BorderBrush="{DynamicResource B.InputBorder}" CornerRadius="8" Padding="3">
|
||||||
CaretBrush="{DynamicResource B.Ink}"
|
<ListBox x:Name="CompletionList" MaxHeight="260" MinWidth="280" FontSize="12"
|
||||||
SelectionBrush="{DynamicResource B.Accent}" SelectionOpacity="0.35"
|
BorderThickness="0" Background="Transparent"
|
||||||
HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"
|
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||||
TextChanged="OnSqlChanged" PreviewKeyDown="OnSqlKeyDown"
|
MouseDoubleClick="OnCompletionPicked">
|
||||||
SelectionChanged="OnSqlSelectionChanged" LostFocus="OnSqlLostFocus"
|
<ListBox.ItemTemplate>
|
||||||
AutomationProperties.Name="SQL 편집"/>
|
<DataTemplate>
|
||||||
</Grid>
|
<DockPanel MinWidth="260">
|
||||||
</Grid>
|
<TextBlock DockPanel.Dock="Right" Text="{Binding KindLabel}" FontSize="10"
|
||||||
|
Foreground="{DynamicResource B.Muted}" Margin="10,0,0,0"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Text="{Binding Text}" FontFamily="Consolas, D2Coding, 굴림체"
|
||||||
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
</DockPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
</Border>
|
||||||
|
</Popup>
|
||||||
|
<TextBox x:Name="SqlBox"
|
||||||
|
FontFamily="Consolas, D2Coding, 굴림체" FontSize="13"
|
||||||
|
AcceptsReturn="True" AcceptsTab="False"
|
||||||
|
TextWrapping="NoWrap" Padding="8,6"
|
||||||
|
Background="Transparent" BorderThickness="0"
|
||||||
|
Foreground="Transparent"
|
||||||
|
CaretBrush="{DynamicResource B.Ink}"
|
||||||
|
SelectionBrush="{DynamicResource B.Accent}" SelectionOpacity="0.35"
|
||||||
|
HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"
|
||||||
|
TextChanged="OnSqlChanged" PreviewKeyDown="OnSqlKeyDown"
|
||||||
|
SelectionChanged="OnSqlSelectionChanged" LostFocus="OnSqlLostFocus"
|
||||||
|
AutomationProperties.Name="SQL 편집"/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DockPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<GridSplitter Grid.Column="1" Style="{StaticResource ColSplitter}"/>
|
<GridSplitter Grid.Column="1" Style="{StaticResource ColSplitter}"/>
|
||||||
|
|
||||||
<!-- 치환 변수 (SQL 용도에서만) -->
|
<!-- ══ 치환 변수 카드 ══ -->
|
||||||
<DockPanel x:Name="VariablePane" Grid.Column="2">
|
<Border x:Name="VariablePane" Grid.Column="2" Style="{StaticResource Card}">
|
||||||
<TextBlock DockPanel.Dock="Top" Text="치환 변수" FontWeight="Bold"
|
<DockPanel Margin="14,12,14,12">
|
||||||
Foreground="{DynamicResource B.Muted}" Margin="4,0,0,6"/>
|
<TextBlock DockPanel.Dock="Top" Text="치환 변수" Style="{StaticResource CardTitle}"
|
||||||
<TextBox x:Name="VariableSearch" DockPanel.Dock="Top" Margin="0,0,0,6" Padding="6,3"
|
Margin="0,0,0,10"/>
|
||||||
VerticalContentAlignment="Center" TextChanged="OnVariableSearchChanged"
|
|
||||||
AutomationProperties.Name="치환 변수 검색"
|
<TextBox x:Name="VariableSearch" DockPanel.Dock="Top" Margin="0,0,0,8" Padding="8,4"
|
||||||
ToolTip="이름·설명·초성으로 찾습니다 (예: ㅊㅌㅂㅎ → 차트번호)"/>
|
VerticalContentAlignment="Center" TextChanged="OnVariableSearchChanged"
|
||||||
<TextBlock DockPanel.Dock="Bottom" x:Name="VariableHint" FontSize="10.5" TextWrapping="Wrap"
|
AutomationProperties.Name="치환 변수 검색"
|
||||||
Foreground="{DynamicResource B.Muted}" Margin="4,6,0,0"
|
ToolTip="변수명·설명·초성으로 찾습니다 (예: ㅊㅌㅂㅎ → 차트번호)"/>
|
||||||
Text="더블클릭 또는 Enter 로 커서 위치에 삽입합니다."/>
|
|
||||||
<ListBox x:Name="VariableList" FontSize="11" MouseDoubleClick="OnInsertVariable"
|
<!-- 갈래 칩 -->
|
||||||
KeyDown="OnVariableListKeyDown"
|
<ItemsControl x:Name="BucketChips" DockPanel.Dock="Top" Margin="0,0,0,8">
|
||||||
FontFamily="Consolas, D2Coding, 굴림체"
|
<ItemsControl.ItemsPanel>
|
||||||
HorizontalContentAlignment="Stretch"
|
<ItemsPanelTemplate><WrapPanel/></ItemsPanelTemplate>
|
||||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
</ItemsControl.ItemsPanel>
|
||||||
AutomationProperties.Name="치환 변수 목록">
|
<ItemsControl.ItemTemplate>
|
||||||
<!-- 토큰 전체(60자 넘음)를 줄마다 깔면 앞 40자가 전부 같아 정작 다른 부분이 잘린다.
|
<DataTemplate>
|
||||||
설명 + 속성명만 띄우고 전체는 툴팁으로 둔다. -->
|
<ToggleButton Style="{StaticResource BucketChip}"
|
||||||
<ListBox.ItemTemplate>
|
IsChecked="{Binding IsActive, Mode=OneWay}"
|
||||||
<DataTemplate>
|
Tag="{Binding Name}" Click="OnBucketClick">
|
||||||
<DockPanel Margin="0,2" ToolTip="{Binding Token}">
|
<StackPanel Orientation="Horizontal">
|
||||||
<TextBlock DockPanel.Dock="Right" Text="{Binding ShortForm}"
|
<TextBlock Text="{Binding Name}"/>
|
||||||
Foreground="{DynamicResource B.Muted}" FontSize="10.5"
|
<TextBlock Text="{Binding Count}" Margin="6,0,0,0" Opacity="0.75"/>
|
||||||
VerticalAlignment="Center" Margin="8,0,0,0"/>
|
</StackPanel>
|
||||||
<TextBlock Text="{Binding Description}" FontFamily="Segoe UI, 맑은 고딕" FontSize="12"
|
</ToggleButton>
|
||||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
|
</DataTemplate>
|
||||||
</DockPanel>
|
</ItemsControl.ItemTemplate>
|
||||||
</DataTemplate>
|
</ItemsControl>
|
||||||
</ListBox.ItemTemplate>
|
|
||||||
<ListBox.GroupStyle>
|
<!-- 안내 -->
|
||||||
<GroupStyle>
|
<Border DockPanel.Dock="Bottom" Margin="0,8,0,0" Padding="10,7" CornerRadius="8"
|
||||||
<GroupStyle.HeaderTemplate>
|
Background="{DynamicResource B.Input}">
|
||||||
<DataTemplate>
|
<TextBlock x:Name="VariableHint" FontSize="10.5" TextWrapping="Wrap"
|
||||||
<TextBlock Text="{Binding Name}" FontWeight="Bold" Margin="2,8,0,3"
|
Foreground="{DynamicResource B.Muted}"/>
|
||||||
FontFamily="Segoe UI, 맑은 고딕" FontSize="12"
|
</Border>
|
||||||
Foreground="{DynamicResource B.AccentText}"/>
|
|
||||||
</DataTemplate>
|
<ListBox x:Name="VariableList" FontSize="11.5" MouseDoubleClick="OnInsertVariable"
|
||||||
</GroupStyle.HeaderTemplate>
|
KeyDown="OnVariableListKeyDown"
|
||||||
</GroupStyle>
|
BorderThickness="0" Background="Transparent"
|
||||||
</ListBox.GroupStyle>
|
HorizontalContentAlignment="Stretch"
|
||||||
</ListBox>
|
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||||
</DockPanel>
|
AutomationProperties.Name="치환 변수 목록">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<DockPanel Margin="0,3" ToolTip="{Binding Token}">
|
||||||
|
<!-- ⊕ 로 바로 삽입 — 더블클릭을 모르는 사람도 넣을 수 있어야 한다 -->
|
||||||
|
<Button DockPanel.Dock="Right" Width="22" Height="22" Padding="0"
|
||||||
|
Margin="8,0,0,0" Style="{StaticResource ToolBtn}"
|
||||||
|
Content="+" FontSize="13"
|
||||||
|
Foreground="{DynamicResource B.AccentText}"
|
||||||
|
Command="{Binding DataContext.InsertCommand,
|
||||||
|
RelativeSource={RelativeSource AncestorType=ListBox}}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
ToolTip="커서 위치에 삽입"/>
|
||||||
|
<TextBlock DockPanel.Dock="Right" Text="{Binding ShortForm}"
|
||||||
|
Foreground="{DynamicResource B.Muted}" FontSize="10.5"
|
||||||
|
FontFamily="Consolas, D2Coding, 굴림체"
|
||||||
|
VerticalAlignment="Center" Margin="8,0,0,0"/>
|
||||||
|
<TextBlock Text="{Binding Description}" FontFamily="Segoe UI, 맑은 고딕" FontSize="12"
|
||||||
|
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
|
||||||
|
</DockPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
<ListBox.GroupStyle>
|
||||||
|
<GroupStyle>
|
||||||
|
<GroupStyle.ContainerStyle>
|
||||||
|
<Style TargetType="GroupItem">
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="GroupItem">
|
||||||
|
<Expander IsExpanded="{Binding Name,
|
||||||
|
Converter={StaticResource GroupExpanded}, Mode=OneTime}"
|
||||||
|
Margin="0,4,0,0">
|
||||||
|
<Expander.Header>
|
||||||
|
<DockPanel MinWidth="330">
|
||||||
|
<Border DockPanel.Dock="Right" CornerRadius="9" Padding="6,1"
|
||||||
|
VerticalAlignment="Center" Margin="6,0,0,0"
|
||||||
|
Background="{DynamicResource B.Input}">
|
||||||
|
<TextBlock Text="{Binding ItemCount}" FontSize="10"
|
||||||
|
Foreground="{DynamicResource B.Muted}"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock Text="{Binding Name}" FontWeight="SemiBold"
|
||||||
|
FontFamily="Segoe UI, 맑은 고딕" FontSize="12"/>
|
||||||
|
</DockPanel>
|
||||||
|
</Expander.Header>
|
||||||
|
<ItemsPresenter Margin="8,2,0,6"/>
|
||||||
|
</Expander>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
</GroupStyle.ContainerStyle>
|
||||||
|
</GroupStyle>
|
||||||
|
</ListBox.GroupStyle>
|
||||||
|
</ListBox>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -49,6 +49,17 @@ public partial class QueryEditorWindow : Window
|
|||||||
|
|
||||||
/// <summary>목록이 걸려 있는 낱말의 끝 — 커서가 이 범위를 벗어나면 목록은 더 이상 유효하지 않다</summary>
|
/// <summary>목록이 걸려 있는 낱말의 끝 — 커서가 이 범위를 벗어나면 목록은 더 이상 유효하지 않다</summary>
|
||||||
private int completionEnd;
|
private int completionEnd;
|
||||||
|
|
||||||
|
/// <summary>이 서식의 컨트롤 이름 — 《컨트롤명》 치환 후보</summary>
|
||||||
|
private readonly IReadOnlyList<string> controlNames;
|
||||||
|
|
||||||
|
/// <summary>고른 갈래 칩 — 빈 문자열이면 전체</summary>
|
||||||
|
private string activeBucket = string.Empty;
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Properties - Commands
|
||||||
|
/// <summary>목록 행의 + 버튼 — 더블클릭을 모르는 사람도 넣을 수 있어야 한다</summary>
|
||||||
|
public M.Framework.WPF.ICustomCommand InsertCommand { get; }
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Properties
|
#region Properties
|
||||||
@@ -60,16 +71,32 @@ public partial class QueryEditorWindow : Window
|
|||||||
/// <param name="ownerLabel">제목에 붙일 대상 이름</param>
|
/// <param name="ownerLabel">제목에 붙일 대상 이름</param>
|
||||||
/// <param name="initialQuery">초기 텍스트</param>
|
/// <param name="initialQuery">초기 텍스트</param>
|
||||||
/// <param name="showVariables">SQL 치환 변수 패널 표시 여부 — 상용구 등 SQL 이 아닌 용도에서는 false</param>
|
/// <param name="showVariables">SQL 치환 변수 패널 표시 여부 — 상용구 등 SQL 이 아닌 용도에서는 false</param>
|
||||||
public QueryEditorWindow(string ownerLabel, string initialQuery, bool showVariables = true)
|
/// <param name="controlNames">
|
||||||
|
/// 같은 서식의 컨트롤 이름 — 《컨트롤명》 치환 후보가 된다.
|
||||||
|
/// 이 목록은 문서마다 다르므로 카탈로그가 아니라 호출부가 준다.
|
||||||
|
/// </param>
|
||||||
|
public QueryEditorWindow(string ownerLabel, string initialQuery, bool showVariables = true,
|
||||||
|
IReadOnlyList<string>? controlNames = null)
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
isSql = showVariables;
|
isSql = showVariables;
|
||||||
|
this.controlNames = controlNames ?? Array.Empty<string>();
|
||||||
|
InsertCommand = new M.Framework.WPF.Command((object parameter) =>
|
||||||
|
{
|
||||||
|
if (parameter is QueryVariable variable)
|
||||||
|
{
|
||||||
|
InsertVariable(variable);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
DataContext = this;
|
||||||
SqlBox.Text = initialQuery;
|
SqlBox.Text = initialQuery;
|
||||||
|
TargetChipText.Text = ownerLabel;
|
||||||
|
|
||||||
if (showVariables)
|
if (showVariables)
|
||||||
{
|
{
|
||||||
Title = $"쿼리 편집 — {ownerLabel}";
|
Title = $"쿼리 편집 — {ownerLabel}";
|
||||||
Highlight.Source = SqlBox;
|
Highlight.Source = SqlBox;
|
||||||
|
RebuildBucketChips();
|
||||||
ApplyVariableFilter(string.Empty);
|
ApplyVariableFilter(string.Empty);
|
||||||
var connection = Services.ConfigService.Current.ConnectionString;
|
var connection = Services.ConfigService.Current.ConnectionString;
|
||||||
workbench = new SheetMe.Data.Stores.OracleQueryWorkbench(connection);
|
workbench = new SheetMe.Data.Stores.OracleQueryWorkbench(connection);
|
||||||
@@ -78,6 +105,8 @@ public partial class QueryEditorWindow : Window
|
|||||||
TrialButton.IsEnabled = false;
|
TrialButton.IsEnabled = false;
|
||||||
TrialButton.ToolTip = "DB 접속이 설정되지 않아 검증 실행을 쓸 수 없습니다.";
|
TrialButton.ToolTip = "DB 접속이 설정되지 않아 검증 실행을 쓸 수 없습니다.";
|
||||||
}
|
}
|
||||||
|
ConnText.Text = workbench.CanUseDb ? "연결 상태: 정상" : "연결 상태: 없음(오프라인)";
|
||||||
|
ConnDot.Fill = (System.Windows.Media.Brush)FindResource(workbench.CanUseDb ? "B.Ok" : "B.Muted");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -88,6 +117,8 @@ public partial class QueryEditorWindow : Window
|
|||||||
VariablePane.Visibility = Visibility.Collapsed;
|
VariablePane.Visibility = Visibility.Collapsed;
|
||||||
SplitterColumn.Width = new GridLength(0);
|
SplitterColumn.Width = new GridLength(0);
|
||||||
VariableColumn.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;
|
var text = SqlBox.Text;
|
||||||
LengthText.Text = $"{text.Length:N0}자 · {CountLines(text)}줄";
|
LengthText.Text = $"{text.Length:N0}자 · {CountLines(text)}줄";
|
||||||
|
UpdateCaretText();
|
||||||
UpdateLineNumbers(text);
|
UpdateLineNumbers(text);
|
||||||
UpdateProblems(text);
|
UpdateProblems(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>줄·열 표시 — 오류 메시지의 위치를 찾아갈 때 쓴다</summary>
|
||||||
|
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}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>포맷 — 예약어 대문자, 절마다 줄바꿈. 문자열·치환 변수는 건드리지 않는다</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>전체 지우기 — 되돌릴 수 있게 TextBox 실행취소에 남긴다</summary>
|
||||||
|
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)
|
private static int CountLines(string text)
|
||||||
{
|
{
|
||||||
var lines = 1;
|
var lines = 1;
|
||||||
@@ -714,23 +794,57 @@ public partial class QueryEditorWindow : Window
|
|||||||
private void OnVariableSearchChanged(object sender, TextChangedEventArgs e)
|
private void OnVariableSearchChanged(object sender, TextChangedEventArgs e)
|
||||||
=> ApplyVariableFilter(VariableSearch.Text);
|
=> ApplyVariableFilter(VariableSearch.Text);
|
||||||
|
|
||||||
/// <summary>이름·설명·초성으로 좁힌다 — 목록이 길어 눈으로 훑기 어렵다</summary>
|
/// <summary>카탈로그 + 이 서식의 컨트롤 — 컨트롤은 문서마다 달라 여기서 합친다</summary>
|
||||||
|
private IReadOnlyList<QueryVariable> AllVariables()
|
||||||
|
=> LegacyQueryVariableCatalog.All
|
||||||
|
.Concat(LegacyQueryVariableCatalog.ControlVariables(controlNames))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
/// <summary>상단 칩을 다시 만든다 — 갈래별 개수는 검색과 무관하게 전체 기준으로 둔다</summary>
|
||||||
|
private void RebuildBucketChips()
|
||||||
|
{
|
||||||
|
var all = AllVariables();
|
||||||
|
var chips = new List<object>
|
||||||
|
{
|
||||||
|
new { Name = "전체", Count = all.Count, IsActive = activeBucket.Length == 0 },
|
||||||
|
};
|
||||||
|
foreach (var group in all.GroupBy(v => v.Bucket).OrderByDescending(g => g.Count()))
|
||||||
|
{
|
||||||
|
chips.Add(new { Name = group.Key, Count = group.Count(), IsActive = activeBucket == group.Key });
|
||||||
|
}
|
||||||
|
BucketChips.ItemsSource = chips;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnBucketClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not System.Windows.Controls.Primitives.ToggleButton { Tag: string name })
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 같은 칩을 다시 누르면 해제 — '전체'와 같은 뜻이라 따로 누를 필요가 없다
|
||||||
|
activeBucket = name == "전체" || activeBucket == name ? string.Empty : name;
|
||||||
|
RebuildBucketChips();
|
||||||
|
ApplyVariableFilter(VariableSearch.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>이름·설명·초성으로 좁히고 갈래 칩을 함께 적용한다</summary>
|
||||||
private void ApplyVariableFilter(string keyword)
|
private void ApplyVariableFilter(string keyword)
|
||||||
{
|
{
|
||||||
|
var all = AllVariables();
|
||||||
var tokens = keyword.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
var tokens = keyword.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
var matched = tokens.Length == 0
|
var matched = all
|
||||||
? LegacyQueryVariableCatalog.All
|
.Where(v => activeBucket.Length == 0 || v.Bucket == activeBucket)
|
||||||
: LegacyQueryVariableCatalog.All
|
.Where(v => tokens.Length == 0 || tokens.All(t =>
|
||||||
.Where(v => tokens.All(t =>
|
TagSearch.Matches(v.Description, t) || TagSearch.Matches(v.Token, t)))
|
||||||
TagSearch.Matches(v.Description, t) || TagSearch.Matches(v.Token, t)))
|
.ToList();
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var view = new CollectionViewSource { Source = matched };
|
var view = new CollectionViewSource { Source = matched };
|
||||||
view.GroupDescriptions.Add(new PropertyGroupDescription(nameof(QueryVariable.Group)));
|
view.GroupDescriptions.Add(new PropertyGroupDescription(nameof(QueryVariable.Group)));
|
||||||
VariableList.ItemsSource = view.View;
|
VariableList.ItemsSource = view.View;
|
||||||
VariableHint.Text = matched.Count == LegacyQueryVariableCatalog.All.Count
|
|
||||||
? $"{matched.Count}개 · 더블클릭 또는 Enter 로 삽입합니다."
|
var scope = matched.Count == all.Count ? $"{matched.Count}개" : $"{matched.Count}/{all.Count}개";
|
||||||
: $"{matched.Count}/{LegacyQueryVariableCatalog.All.Count}개 · 더블클릭 또는 Enter 로 삽입합니다.";
|
VariableHint.Text = $"{scope} · + 를 누르거나 더블클릭하면 커서 위치에 삽입됩니다. " +
|
||||||
|
"편집기에서 << 를 치면 자동완성으로도 고를 수 있습니다.";
|
||||||
if (matched.Count > 0)
|
if (matched.Count > 0)
|
||||||
{
|
{
|
||||||
VariableList.SelectedIndex = 0;
|
VariableList.SelectedIndex = 0;
|
||||||
@@ -750,10 +864,14 @@ public partial class QueryEditorWindow : Window
|
|||||||
|
|
||||||
private void InsertSelectedVariable()
|
private void InsertSelectedVariable()
|
||||||
{
|
{
|
||||||
if (VariableList.SelectedItem is not QueryVariable variable)
|
if (VariableList.SelectedItem is QueryVariable variable)
|
||||||
{
|
{
|
||||||
return;
|
InsertVariable(variable);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InsertVariable(QueryVariable variable)
|
||||||
|
{
|
||||||
var caret = SqlBox.CaretIndex;
|
var caret = SqlBox.CaretIndex;
|
||||||
SqlBox.Text = SqlBox.Text.Insert(caret, variable.Token);
|
SqlBox.Text = SqlBox.Text.Insert(caret, variable.Token);
|
||||||
SqlBox.Focus();
|
SqlBox.Focus();
|
||||||
|
|||||||
@@ -97,6 +97,37 @@ public sealed class QueryVariableResolveTests
|
|||||||
public void MalformedTokenDoesNotResolve(string token)
|
public void MalformedTokenDoesNotResolve(string token)
|
||||||
=> Assert.IsFalse(LegacyQueryVariableCatalog.IsResolvable(token));
|
=> Assert.IsFalse(LegacyQueryVariableCatalog.IsResolvable(token));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 《컨트롤명》 은 <c><<…>></c> 와 다른 문법이고 런타임의 다른 단계에서 처리된다
|
||||||
|
/// (clsMDataTable.vb:78-94 — 같은 서식 컨트롤의 GETVALUE 를 박는다).
|
||||||
|
/// </summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void ControlSubstitutionIsItsOwnSyntax()
|
||||||
|
{
|
||||||
|
Assert.IsTrue(LegacyQueryVariableCatalog.IsResolvable("《TextBox1》"));
|
||||||
|
Assert.IsFalse(LegacyQueryVariableCatalog.IsResolvable("《》"), "이름이 비면 안 된다");
|
||||||
|
Assert.IsFalse(LegacyQueryVariableCatalog.IsResolvable("《TextBox1"), "닫히지 않았다");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>컨트롤 후보는 문서에서 온다 — 중복은 한 번만, 이름순</summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void ControlVariables_AreDedupedAndSorted()
|
||||||
|
{
|
||||||
|
var made = LegacyQueryVariableCatalog.ControlVariables(new[] { "TextBox2", "TextBox1", "TextBox2", "" });
|
||||||
|
|
||||||
|
CollectionAssert.AreEqual(new[] { "《TextBox1》", "《TextBox2》" }, made.Select(v => v.Token).ToArray());
|
||||||
|
Assert.IsTrue(made.All(v => v.Group == LegacyQueryVariableCatalog.ControlGroup));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>토크나이저도 《》 를 변수로 잡아야 색이 붙고 검증에 걸린다</summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void Tokenizer_FindsControlSubstitution()
|
||||||
|
{
|
||||||
|
var found = SqlTokenizer.VariablesIn("WHERE a = 《TextBox1》 AND b = '《TextBox2》'");
|
||||||
|
|
||||||
|
CollectionAssert.AreEqual(new[] { "《TextBox1》", "《TextBox2》" }, found.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 편집기가 실제로 쓰는 흐름 — 쿼리에서 뽑은 변수를 그대로 판정에 넘긴다.
|
/// 편집기가 실제로 쓰는 흐름 — 쿼리에서 뽑은 변수를 그대로 판정에 넘긴다.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
using SheetMe.Core.Catalog;
|
||||||
|
|
||||||
|
namespace SheetMe.Core.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SQL 정리 테스트.
|
||||||
|
///
|
||||||
|
/// 가장 중요한 불변식은 <b>뜻이 바뀌지 않는 것</b>이다. 특히 치환 변수는 대소문자가 맞아야
|
||||||
|
/// 런타임이 찾으므로, 정리하면서 건드리면 그 서식은 조용히 값을 못 받는다.
|
||||||
|
/// </summary>
|
||||||
|
[TestClass]
|
||||||
|
public sealed class SqlFormatterTests
|
||||||
|
{
|
||||||
|
[TestMethod]
|
||||||
|
public void Format_BreaksBeforeMajorClauses()
|
||||||
|
{
|
||||||
|
var formatted = SqlFormatter.Format("select a from t where b=1 and c=2");
|
||||||
|
|
||||||
|
var lines = formatted.Split('\n');
|
||||||
|
StringAssert.StartsWith(lines[0], "SELECT");
|
||||||
|
Assert.IsTrue(lines.Any(l => l.StartsWith("FROM", StringComparison.Ordinal)));
|
||||||
|
Assert.IsTrue(lines.Any(l => l.StartsWith("WHERE", StringComparison.Ordinal)));
|
||||||
|
Assert.IsTrue(lines.Any(l => l.StartsWith("AND", StringComparison.Ordinal)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void Format_UppercasesKeywordsAndFunctions()
|
||||||
|
{
|
||||||
|
var formatted = SqlFormatter.Format("select nvl(a,0) from dual");
|
||||||
|
|
||||||
|
StringAssert.Contains(formatted, "SELECT");
|
||||||
|
StringAssert.Contains(formatted, "NVL");
|
||||||
|
StringAssert.Contains(formatted, "DUAL");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>식별자는 건드리지 않는다 — 대소문자를 가리는 곳에서 깨진다</summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void Format_LeavesIdentifiersAlone()
|
||||||
|
{
|
||||||
|
var formatted = SqlFormatter.Format("select ChtNum, PatNam from P_PatMst");
|
||||||
|
|
||||||
|
StringAssert.Contains(formatted, "ChtNum");
|
||||||
|
StringAssert.Contains(formatted, "PatNam");
|
||||||
|
StringAssert.Contains(formatted, "P_PatMst");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 치환 변수는 원문 그대로여야 한다. 대문자로 바꾸면 런타임이 클래스 이름을 못 찾아
|
||||||
|
/// 그 칸이 조용히 빈다 — 이 포매터가 저지를 수 있는 가장 나쁜 일이다.
|
||||||
|
/// </summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void Format_NeverTouchesSubstitutionVariables()
|
||||||
|
{
|
||||||
|
const string token = "<<M.CMM.HISOperatingInfo.bzPatientInfo.ChtNum>>";
|
||||||
|
|
||||||
|
var formatted = SqlFormatter.Format($"select * from t where a = '{token}'");
|
||||||
|
|
||||||
|
StringAssert.Contains(formatted, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>《컨트롤명》도 마찬가지다</summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void Format_NeverTouchesControlSubstitution()
|
||||||
|
{
|
||||||
|
var formatted = SqlFormatter.Format("select * from t where a = 《TextBox1》");
|
||||||
|
|
||||||
|
StringAssert.Contains(formatted, "《TextBox1》");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>문자열 안은 손대지 않는다 — 예약어와 같은 낱말이 들어 있어도</summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void Format_LeavesStringLiteralsAlone()
|
||||||
|
{
|
||||||
|
var formatted = SqlFormatter.Format("select 'from where select' from t");
|
||||||
|
|
||||||
|
StringAssert.Contains(formatted, "'from where select'");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void Format_IndentsByParenthesisDepth()
|
||||||
|
{
|
||||||
|
var formatted = SqlFormatter.Format("select * from (select a from t)");
|
||||||
|
|
||||||
|
Assert.IsTrue(formatted.Split('\n').Any(l => l.StartsWith(" ", StringComparison.Ordinal)),
|
||||||
|
formatted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void Format_LeavesNoTrailingWhitespace()
|
||||||
|
{
|
||||||
|
var formatted = SqlFormatter.Format("select a , b from t where c = 1");
|
||||||
|
|
||||||
|
foreach (var line in formatted.Split('\n'))
|
||||||
|
{
|
||||||
|
Assert.AreEqual(line.TrimEnd(), line, $"줄 끝 공백이 남았습니다: '{line}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[DataTestMethod]
|
||||||
|
[DataRow("")]
|
||||||
|
[DataRow(" ")]
|
||||||
|
public void Format_ReturnsEmptyInputUnchanged(string input)
|
||||||
|
=> Assert.AreEqual(input, SqlFormatter.Format(input));
|
||||||
|
|
||||||
|
/// <summary>정리해도 다시 정리하면 같아야 한다 — 누를 때마다 모양이 흔들리면 못 쓴다</summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void Format_IsStableWhenAppliedTwice()
|
||||||
|
{
|
||||||
|
var once = SqlFormatter.Format("select a,b from t where c=1 and d=2");
|
||||||
|
var twice = SqlFormatter.Format(once);
|
||||||
|
|
||||||
|
Assert.AreEqual(once, twice);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user