쿼리 편집기 고도화 — 자동완성·검증 실행·편집 기본기 (Oracle)

색칠만으로는 편집기라 하기 어렵다. 실제로 쿼리를 칠 때 손이 가는 곳을 메웠다.

■ 자동완성 (Ctrl+Space / 치는 대로)

문맥을 보고 후보를 바꾼다(SqlCompletion — Core 순수 함수라 테스트로 고정했다):
  · << 직후          → 치환 변수 51종. 이 편집기에만 있는 문법이라 외워서 칠 수 없다.
                        고르면 알맹이를 넣고 닫는 >> 를 붙여 준다.
  · FROM/JOIN 뒤     → 테이블·뷰 이름(운영 스키마에서 2,494건)
  · 별칭. 뒤          → 그 테이블의 컬럼. 별칭은 같은 쿼리 FROM 절에서 푼다
                        (FROM P_PatMst p → p.  는 P_PatMst 의 컬럼).
  · 그 밖             → 예약어·함수·테이블

일반 위치에서 한 글자도 안 쳤으면 목록을 띄우지 않는다 — 아무 데서나 뜨면 방해가 된다.
테이블 목록은 처음 한 번만 읽고, 컬럼은 테이블별로 캐시한다(매번 조회하면 입력이 끊긴다).
DB 가 없으면 예약어·함수·변수만 나오고 나머지는 조용히 빠진다.

별칭 자리에 예약어가 오면 별칭으로 보지 않는다 — FROM P_PatMst WHERE 에서 WHERE 를
별칭으로 잡으면 엉뚱한 테이블의 컬럼을 제안하게 된다.

■ 검증 실행 (F5)

저장 전에 실제로 돌려 본다. 치환 변수는 디자인 시점에 값이 없으므로 런타임과 같은 방식으로
빈 문자열로 바꾼 뒤 실행한다(clsMDataTable.vb:67-71). 목적은 데이터 확인이 아니라
테이블·컬럼 오타와 구문 오류를 배포 전에 잡는 것이다 — 지금까지는 EMR 런타임에서야
처음 실행됐고 실패해도 빈 catch 가 삼켰다.

성공하면 컬럼 목록과 표본 행(최대 20)을 보여 주고, 실패하면 ORA 메시지를 그대로 띄운다.

안전 장치: SELECT/WITH 로 시작하는 조회만 실행한다. 운영 DB 에 붙을 수 있는 도구이고
편집 중인 텍스트를 그대로 실행하므로 DML·DDL 이 섞이면 되돌릴 수 없다.
앞쪽 주석을 걷어내고 판정하므로 /* SELECT */ DELETE ... 같은 우회도 막힌다.

--db-trial 진단을 신설해 실DB 로 확인했다(10/10 통과):
  정상 조회 통과 · 없는 테이블 ORA-00942 · 없는 컬럼 ORA-00904 · 구문 오류 ORA-00936 ·
  앞 주석 뒤 정상 통과 · INSERT/DELETE/DROP/주석우회 전부 차단 · 빈 쿼리 차단.

■ 편집 기본기

따옴표·괄호 자동 닫기(선택이 있으면 그 선택을 감싼다) — SQL 에서 짝을 빠뜨리면
런타임에서 조용히 실패하는 종류다.
앞서 넣은 줄번호·Tab 들여쓰기·Enter 자동 들여쓰기·Ctrl+/ 주석 토글은 그대로.

단위 테스트 15건 추가(문맥 판정 6종·별칭 풀기 4종·키워드 별칭 배제·후보 정렬·내장 목록).
회귀: 테스트 211/211, 편집 스모크 실패 0, 검증 실행 점검 10/10,
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 15:21:08 +09:00
co-authored by Claude Opus 5
parent 864f11b0e4
commit 79db31cb4d
7 changed files with 1021 additions and 7 deletions
@@ -0,0 +1,231 @@
using System.Data;
using System.Text.RegularExpressions;
using Oracle.ManagedDataAccess.Client;
namespace SheetMe.Data.Stores;
/// <summary>테이블 1건 — 자동완성 후보</summary>
public sealed record SchemaTable(string Name, string Kind);
/// <summary>검증 실행 결과</summary>
public sealed class QueryTrialResult
{
/// <summary>성공 여부</summary>
public bool Ok { get; init; }
/// <summary>실패 사유(ORA 메시지 등)</summary>
public string? Error { get; init; }
/// <summary>결과 컬럼명</summary>
public IReadOnlyList<string> Columns { get; init; } = Array.Empty<string>();
/// <summary>표본 행 — 컬럼 순서와 같은 문자열 배열</summary>
public IReadOnlyList<string[]> Rows { get; init; } = Array.Empty<string[]>();
/// <summary>사람이 읽는 요약</summary>
public string Summary { get; init; } = string.Empty;
}
/// <summary>
/// 쿼리 편집기 지원 — 스키마 목록과 검증 실행(Oracle).
///
/// <b>왜 필요한가.</b> MDataTable 쿼리는 저장한 뒤 EMR 런타임에서야 처음 실행되고,
/// 실패하면 빈 catch 가 삼켜 화면에 아무 표시가 없다. 오타 하나가 배포 후에
/// '그 칸만 비어 있다'는 형태로 드러난다. 편집기에서 미리 돌려 볼 수 있어야 한다.
///
/// <b>안전 장치.</b> 검증 실행은 SELECT/WITH 로 시작하는 문장만 받는다.
/// 여기는 운영 DB 에 붙을 수 있는 도구이고, 편집 중인 텍스트를 그대로 실행하므로
/// DML·DDL 이 섞여 들어오면 되돌릴 수 없다. 행 수도 제한한다.
/// </summary>
public sealed class OracleQueryWorkbench
{
#region Member Fields
private readonly string connectionString;
/// <summary>검증 실행이 가져오는 최대 행 수 — 미리보기지 조회 도구가 아니다</summary>
public const int MaxPreviewRows = 20;
/// <summary>SELECT 또는 WITH 로 시작하는지 — 주석과 공백을 걷어내고 본다</summary>
private static readonly Regex ReadOnlyStart =
new(@"^\s*(SELECT|WITH)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled);
#endregion
#region Constructors
public OracleQueryWorkbench(string connectionString) => this.connectionString = connectionString;
#endregion
#region Properties
/// <summary>DB 에 붙을 수 있는지 — 접속 문자열이 없으면 스키마·검증 기능이 꺼진다</summary>
public bool CanUseDb => connectionString.Length > 0;
#endregion
#region Methods
/// <summary>
/// 접근 가능한 테이블·뷰 이름 — 자동완성 후보.
/// 전부 가져오면 수만 건이 될 수 있어 상한을 둔다.
/// </summary>
public IReadOnlyList<SchemaTable> ListTables(int max = 4000)
{
var found = new List<SchemaTable>();
if (!CanUseDb)
{
return found;
}
try
{
using var connection = new OracleConnection(connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText =
"SELECT TABLE_NAME, 'TABLE' FROM USER_TABLES " +
"UNION ALL SELECT VIEW_NAME, 'VIEW' FROM USER_VIEWS " +
"ORDER BY 1";
using var reader = command.ExecuteReader();
while (reader.Read() && found.Count < max)
{
found.Add(new SchemaTable(reader.GetString(0), reader.GetString(1)));
}
}
catch
{
// 스키마 조회 실패는 자동완성만 비는 것이라 편집을 막지 않는다
}
return found;
}
/// <summary>특정 테이블의 컬럼명 — 없거나 조회 실패면 빈 목록</summary>
public IReadOnlyList<string> ListColumns(string tableName)
{
var found = new List<string>();
if (!CanUseDb || !Regex.IsMatch(tableName, "^[A-Za-z0-9_$#]{1,128}$"))
{
return found;
}
try
{
using var connection = new OracleConnection(connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText =
"SELECT COLUMN_NAME FROM USER_TAB_COLUMNS WHERE TABLE_NAME = UPPER(:t) ORDER BY COLUMN_ID";
command.Parameters.Add(new OracleParameter("t", tableName));
using var reader = command.ExecuteReader();
while (reader.Read())
{
found.Add(reader.GetString(0));
}
}
catch
{
// 위와 같다
}
return found;
}
/// <summary>
/// 검증 실행 — 구문과 이름이 맞는지 실제로 돌려 본다.
///
/// 치환 변수는 디자인 시점에 값을 알 수 없으므로 호출부가 미리 치환해 넘긴다
/// (보통 빈 문자열이나 0). 목적은 데이터 확인이 아니라 <b>테이블·컬럼 오타와 구문 오류</b>를
/// 배포 전에 잡는 것이다.
/// </summary>
public QueryTrialResult Trial(string sql)
{
if (!CanUseDb)
{
return new QueryTrialResult { Ok = false, Error = "DB 접속이 설정되지 않아 검증할 수 없습니다." };
}
var trimmed = StripLeadingComments(sql);
if (trimmed.Length == 0)
{
return new QueryTrialResult { Ok = false, Error = "쿼리가 비어 있습니다." };
}
if (!ReadOnlyStart.IsMatch(trimmed))
{
// 운영 DB 에 붙을 수 있는 도구다 — 편집 중인 텍스트를 그대로 실행하므로 조회만 허용한다
return new QueryTrialResult
{
Ok = false,
Error = "검증 실행은 SELECT 또는 WITH 로 시작하는 조회만 지원합니다.",
};
}
try
{
using var connection = new OracleConnection(connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = trimmed.TrimEnd().TrimEnd(';');
using var reader = command.ExecuteReader(CommandBehavior.SingleResult);
var columns = new List<string>();
for (var i = 0; i < reader.FieldCount; i++)
{
columns.Add(reader.GetName(i));
}
var rows = new List<string[]>();
while (reader.Read() && rows.Count < MaxPreviewRows)
{
var row = new string[reader.FieldCount];
for (var i = 0; i < reader.FieldCount; i++)
{
var value = reader.IsDBNull(i) ? "(null)" : reader.GetValue(i).ToString() ?? string.Empty;
row[i] = value.Length > 60 ? value[..60] + "…" : value;
}
rows.Add(row);
}
return new QueryTrialResult
{
Ok = true,
Columns = columns,
Rows = rows,
Summary = $"성공 — 컬럼 {columns.Count}개, 표본 {rows.Count}행" +
(rows.Count == MaxPreviewRows ? $" (최대 {MaxPreviewRows}행까지만 봅니다)" : string.Empty),
};
}
catch (Exception ex)
{
return new QueryTrialResult { Ok = false, Error = ex.Message };
}
}
/// <summary>앞쪽 주석·공백을 걷어낸 나머지 — SELECT 판정을 주석이 가리지 않도록</summary>
private static string StripLeadingComments(string sql)
{
var i = 0;
while (i < sql.Length)
{
if (char.IsWhiteSpace(sql[i]))
{
i++;
continue;
}
if (sql[i] == '-' && i + 1 < sql.Length && sql[i + 1] == '-')
{
var nl = sql.IndexOf('\n', i);
if (nl < 0)
{
return string.Empty;
}
i = nl + 1;
continue;
}
if (sql[i] == '/' && i + 1 < sql.Length && sql[i + 1] == '*')
{
var close = sql.IndexOf("*/", i + 2, StringComparison.Ordinal);
if (close < 0)
{
return string.Empty;
}
i = close + 2;
continue;
}
break;
}
return sql[i..];
}
#endregion
}