쿼리 편집기 고도화 — 자동완성·검증 실행·편집 기본기 (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:
co-authored by
Claude Opus 5
parent
864f11b0e4
commit
79db31cb4d
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user