Files
AX-Copilot-Codex/src/AxCopilot/Handlers/SqlHandler.cs
lacvet 0336904258 AX Commander 비교본 런처 기능 대량 이식
변경 목적: Agent Compare 아래 비교본의 개발 문서와 런처 소스를 기준으로 현재 AX Commander에 빠져 있던 신규 런처 기능을 동일한 흐름으로 옮겨, 비교본 수준의 기능 폭을 현재 제품에 반영했습니다.

핵심 수정사항: 비교본의 신규 런처 핸들러 다수를 src/AxCopilot/Handlers로 이식하고 App.xaml.cs 등록 흐름에 연결했습니다. 빠른 링크, 파일 태그, 알림 센터, 포모도로, 파일 브라우저, 핫키 관리, OCR, 세션/스케줄/매크로, Git/정규식/네트워크/압축/해시/UUID/JWT/QR 등 AX Commander 기능을 추가했습니다.

핵심 수정사항: 신규 기능이 실제 동작하도록 AppSettings 확장, SchedulerService/FileTagService/NotificationCenterService/IconCacheService/UrlTemplateEngine/PomodoroService 추가, 배치 이름변경/세션/스케줄/매크로 편집 창 추가, NotificationService와 Symbols 보강, QR/OCR용 csproj 의존성과 Windows 타겟 프레임워크를 반영했습니다.

문서 반영: README.md와 docs/DEVELOPMENT.md에 비교본 기반 런처 기능 이식 이력과 검증 결과를 업데이트했습니다.

검증 결과: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 실행 기준 경고 0개, 오류 0개를 확인했습니다.
2026-04-05 00:59:45 +09:00

468 lines
19 KiB
C#

using System.Text;
using System.Text.RegularExpressions;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L18-1: SQL 포맷터·분석기 핸들러. "sql" 프리픽스로 사용합니다.
///
/// 예: sql → 클립보드 SQL 포맷 (들여쓰기 정렬)
/// sql mini → SQL 미니파이 (공백·줄바꿈 제거)
/// sql upper → 키워드 대문자로 변환
/// sql lower → 키워드 소문자로 변환
/// sql stats → 테이블·컬럼·조건 수 분석
/// sql tables → FROM/JOIN 테이블 목록 추출
/// sql select <table> → SELECT * FROM <table> 생성
/// Enter → 결과 복사.
/// 외부 라이브러리 없이 순수 구현.
/// </summary>
public partial class SqlHandler : IActionHandler
{
public string? Prefix => "sql";
public PluginMetadata Metadata => new(
"SQL",
"SQL 포맷터·분석기 — 들여쓰기 · 미니파이 · 키워드 · 테이블 추출",
"1.0",
"AX");
// SQL 키워드 목록
private static readonly string[] Keywords =
[
"SELECT", "FROM", "WHERE", "JOIN", "LEFT JOIN", "RIGHT JOIN", "INNER JOIN",
"OUTER JOIN", "FULL JOIN", "CROSS JOIN", "ON", "AND", "OR", "NOT",
"INSERT INTO", "VALUES", "UPDATE", "SET", "DELETE FROM", "DELETE",
"CREATE TABLE", "CREATE INDEX", "CREATE VIEW", "DROP TABLE", "DROP INDEX",
"ALTER TABLE", "ADD COLUMN", "DROP COLUMN", "RENAME TO",
"GROUP BY", "ORDER BY", "HAVING", "LIMIT", "OFFSET", "DISTINCT",
"UNION", "UNION ALL", "INTERSECT", "EXCEPT",
"CASE", "WHEN", "THEN", "ELSE", "END",
"IN", "NOT IN", "EXISTS", "NOT EXISTS", "BETWEEN", "LIKE", "IS NULL", "IS NOT NULL",
"AS", "WITH", "RECURSIVE",
"COUNT", "SUM", "AVG", "MIN", "MAX", "COALESCE", "NULLIF", "CAST",
"SUBSTRING", "TRIM", "UPPER", "LOWER", "LENGTH", "REPLACE",
"NOW", "CURRENT_DATE", "CURRENT_TIMESTAMP", "DATE_FORMAT",
"BEGIN", "COMMIT", "ROLLBACK", "TRANSACTION",
"PRIMARY KEY", "FOREIGN KEY", "REFERENCES", "UNIQUE", "NOT NULL", "DEFAULT",
"INDEX", "CONSTRAINT",
];
// 새 줄 시작 키워드
private static readonly HashSet<string> NewlineKeywords = new(StringComparer.OrdinalIgnoreCase)
{
"SELECT", "FROM", "WHERE", "JOIN", "LEFT JOIN", "RIGHT JOIN", "INNER JOIN",
"OUTER JOIN", "FULL JOIN", "CROSS JOIN", "ON",
"GROUP BY", "ORDER BY", "HAVING", "LIMIT", "OFFSET",
"UNION", "UNION ALL", "INTERSECT", "EXCEPT",
"INSERT INTO", "VALUES", "UPDATE", "SET", "DELETE FROM", "DELETE",
"CREATE TABLE", "CREATE INDEX", "CREATE VIEW",
"DROP TABLE", "ALTER TABLE",
"WITH", "AND", "OR",
};
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
string? clipboard = null;
try
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
if (Clipboard.ContainsText())
clipboard = Clipboard.GetText();
});
}
catch { }
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem("SQL 포맷터·분석기",
"클립보드 SQL 포맷 · sql mini / upper / lower / stats / tables",
null, null, Symbol: "\uE8F1"));
items.Add(new LauncherItem("sql", "들여쓰기 포맷", null, null, Symbol: "\uE8F1"));
items.Add(new LauncherItem("sql mini", "미니파이 (한 줄)", null, null, Symbol: "\uE8F1"));
items.Add(new LauncherItem("sql upper", "키워드 대문자", null, null, Symbol: "\uE8F1"));
items.Add(new LauncherItem("sql lower", "키워드 소문자", null, null, Symbol: "\uE8F1"));
items.Add(new LauncherItem("sql stats", "테이블·컬럼·조건 분석", null, null, Symbol: "\uE8F1"));
items.Add(new LauncherItem("sql tables", "FROM/JOIN 테이블 목록", null, null, Symbol: "\uE8F1"));
items.Add(new LauncherItem("sql select T", "SELECT 쿼리 빠른 생성", null, null, Symbol: "\uE8F1"));
if (string.IsNullOrWhiteSpace(clipboard))
{
items.Add(new LauncherItem("클립보드가 비어 있습니다",
"SQL 쿼리를 복사한 뒤 사용하세요", null, null, Symbol: "\uE946"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 미리보기: 기본 포맷
var preview = Format(clipboard);
var prevLine = preview.Split('\n').FirstOrDefault() ?? "";
items.Add(new LauncherItem("클립보드 SQL 포맷",
prevLine.Length > 60 ? prevLine[..60] + "…" : prevLine,
null, ("copy", preview), Symbol: "\uE8F1"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
// sql select <table> — 클립보드 없이도 동작
if (sub == "select")
{
var table = parts.Length > 1 ? parts[1].Trim() : "your_table";
var generated = BuildSelectTemplate(table);
items.Add(new LauncherItem($"SELECT * FROM {table}",
"Enter → 복사", null, ("copy", generated), Symbol: "\uE8F1"));
foreach (var line in generated.Split('\n').Take(8))
items.Add(new LauncherItem(line, "", null, null, Symbol: "\uE8F1"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
if (string.IsNullOrWhiteSpace(clipboard))
{
items.Add(new LauncherItem("클립보드가 비어 있습니다",
"SQL 쿼리를 복사한 뒤 사용하세요", null, null, Symbol: "\uE946"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
switch (sub)
{
case "format":
case "fmt":
case "pretty":
{
var result = Format(clipboard);
items.Add(new LauncherItem("SQL 포맷 완료",
$"{result.Split('\n').Length}줄 · Enter 복사", null, ("copy", result), Symbol: "\uE8F1"));
AddPreview(items, result, 8);
break;
}
case "mini":
case "minify":
case "compact":
{
var result = Minify(clipboard);
items.Add(new LauncherItem("SQL 미니파이 완료",
$"{result.Length}자 · Enter 복사", null, ("copy", result), Symbol: "\uE8F1"));
var prev = result.Length > 80 ? result[..80] + "…" : result;
items.Add(new LauncherItem(prev, "", null, ("copy", result), Symbol: "\uE8F1"));
break;
}
case "upper":
{
var result = TransformKeywords(clipboard, upper: true);
items.Add(new LauncherItem("키워드 대문자 변환",
"Enter → 복사", null, ("copy", result), Symbol: "\uE8F1"));
AddPreview(items, result, 6);
break;
}
case "lower":
{
var result = TransformKeywords(clipboard, upper: false);
items.Add(new LauncherItem("키워드 소문자 변환",
"Enter → 복사", null, ("copy", result), Symbol: "\uE8F1"));
AddPreview(items, result, 6);
break;
}
case "stats":
case "stat":
case "analyze":
{
items.AddRange(BuildStatsItems(clipboard));
break;
}
case "tables":
case "table":
{
var tables = ExtractTables(clipboard);
items.Add(new LauncherItem($"테이블 {tables.Count}개",
"FROM / JOIN 에서 추출", null, null, Symbol: "\uE8F1"));
foreach (var t in tables)
items.Add(new LauncherItem(t, "", null, ("copy", t), Symbol: "\uE8F1"));
if (tables.Count == 0)
items.Add(new LauncherItem("테이블 없음", "FROM 절이 없습니다", null, null, Symbol: "\uE946"));
break;
}
default:
{
// 기본 동작: 포맷
var result = Format(clipboard);
items.Add(new LauncherItem("SQL 포맷 완료",
$"{result.Split('\n').Length}줄", null, ("copy", result), Symbol: "\uE8F1"));
AddPreview(items, result, 8);
break;
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("SQL", "클립보드에 복사했습니다.");
}
catch { }
}
return Task.CompletedTask;
}
// ── SQL 포맷 ─────────────────────────────────────────────────────────────
private static string Format(string sql)
{
// 정규화: 여러 공백 → 1개, 개행 제거
var flat = WhitespaceRegex().Replace(sql.Replace('\n', ' ').Replace('\r', ' '), " ").Trim();
var sb = new StringBuilder();
var indent = 0;
var tokens = Tokenize(flat);
foreach (var token in tokens)
{
var upper = token.ToUpperInvariant();
// 닫기 괄호 → 들여쓰기 감소
if (upper == ")")
{
indent = Math.Max(0, indent - 1);
if (sb.Length > 0 && sb[^1] != '\n')
sb.AppendLine();
sb.Append(new string(' ', indent * 2));
sb.Append(token);
continue;
}
// 새 줄 시작 키워드
if (NewlineKeywords.Contains(upper))
{
if (sb.Length > 0)
{
sb.AppendLine();
sb.Append(new string(' ', indent * 2));
}
sb.Append(token.ToUpperInvariant());
sb.Append(' ');
continue;
}
// 열기 괄호 → 들여쓰기 증가
if (upper == "(")
{
sb.Append(token);
indent++;
continue;
}
// 쉼표 → 뒤에 공백
if (upper == ",")
{
sb.Append(',');
sb.AppendLine();
sb.Append(new string(' ', indent * 2 + 2));
continue;
}
sb.Append(token);
sb.Append(' ');
}
return sb.ToString().TrimEnd();
}
private static string Minify(string sql)
{
var flat = WhitespaceRegex().Replace(sql.Replace('\n', ' ').Replace('\r', ' '), " ").Trim();
// 괄호 주변 공백 제거
flat = SpaceAroundParensRegex().Replace(flat, "$1");
flat = SpaceBeforeCommaRegex().Replace(flat, ",");
return flat;
}
private static string TransformKeywords(string sql, bool upper)
{
var result = sql;
// 긴 키워드부터 처리 (LEFT JOIN before JOIN 등)
foreach (var kw in Keywords.OrderByDescending(k => k.Length))
{
var pattern = $@"\b{Regex.Escape(kw)}\b";
var replacement = upper ? kw.ToUpperInvariant() : kw.ToLowerInvariant();
result = Regex.Replace(result, pattern, replacement, RegexOptions.IgnoreCase);
}
return result;
}
private static List<LauncherItem> BuildStatsItems(string sql)
{
var items = new List<LauncherItem>();
var upper = sql.ToUpperInvariant();
var tables = ExtractTables(sql);
var selCols = ExtractSelectColumns(sql);
var wheres = CountConditions(sql);
var joins = CountMatches(upper, @"\bJOIN\b");
var subqs = CountMatches(upper, @"\bSELECT\b") - 1;
var orderBy = upper.Contains("ORDER BY");
var groupBy = upper.Contains("GROUP BY");
var hasLimit = upper.Contains("LIMIT");
var dml = DetectDml(upper);
items.Add(new LauncherItem($"SQL 분석 [{dml}]",
$"테이블 {tables.Count}개 · JOIN {joins}개 · WHERE 조건 {wheres}개",
null, null, Symbol: "\uE8F1"));
items.Add(new LauncherItem("DML 유형", dml, null, ("copy", dml), Symbol: "\uE8F1"));
items.Add(new LauncherItem("테이블 수", $"{tables.Count}개", null, ("copy", $"{tables.Count}"), Symbol: "\uE8F1"));
items.Add(new LauncherItem("JOIN 수", $"{joins}개", null, ("copy", $"{joins}"), Symbol: "\uE8F1"));
items.Add(new LauncherItem("WHERE 조건 수", $"{wheres}개", null, ("copy", $"{wheres}"), Symbol: "\uE8F1"));
if (selCols.Count > 0)
items.Add(new LauncherItem("SELECT 컬럼", $"{selCols.Count}개", null, null, Symbol: "\uE8F1"));
items.Add(new LauncherItem("서브쿼리", $"{Math.Max(0, subqs)}개", null, null, Symbol: "\uE8F1"));
items.Add(new LauncherItem("GROUP BY", groupBy ? "있음" : "없음", null, null, Symbol: "\uE8F1"));
items.Add(new LauncherItem("ORDER BY", orderBy ? "있음" : "없음", null, null, Symbol: "\uE8F1"));
items.Add(new LauncherItem("LIMIT", hasLimit ? "있음" : "없음", null, null, Symbol: "\uE8F1"));
items.Add(new LauncherItem("전체 길이", $"{sql.Length}자", null, null, Symbol: "\uE8F1"));
return items;
}
private static List<string> ExtractTables(string sql)
{
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var matches = TableRegex().Matches(sql);
foreach (Match m in matches)
{
var t = m.Groups[1].Value.Trim().Trim('[', ']', '`', '"');
if (!string.IsNullOrWhiteSpace(t) && !IsKeyword(t))
result.Add(t);
}
return result.ToList();
}
private static List<string> ExtractSelectColumns(string sql)
{
var m = SelectColsRegex().Match(sql);
if (!m.Success) return new List<string>();
var cols = m.Groups[1].Value;
return cols.Split(',').Select(c => c.Trim()).Where(c => !string.IsNullOrEmpty(c)).ToList();
}
private static int CountConditions(string sql)
{
var upper = sql.ToUpperInvariant();
var idx = upper.IndexOf("WHERE", StringComparison.Ordinal);
if (idx < 0) return 0;
var wherePart = upper[idx..];
// AND/OR 수 + 1
return Regex.Matches(wherePart, @"\b(AND|OR)\b").Count + 1;
}
private static int CountMatches(string text, string pattern) =>
Regex.Matches(text, pattern, RegexOptions.IgnoreCase).Count;
private static string DetectDml(string upper)
{
if (upper.TrimStart().StartsWith("SELECT")) return "SELECT";
if (upper.TrimStart().StartsWith("INSERT")) return "INSERT";
if (upper.TrimStart().StartsWith("UPDATE")) return "UPDATE";
if (upper.TrimStart().StartsWith("DELETE")) return "DELETE";
if (upper.TrimStart().StartsWith("CREATE")) return "CREATE";
if (upper.TrimStart().StartsWith("ALTER")) return "ALTER";
if (upper.TrimStart().StartsWith("DROP")) return "DROP";
if (upper.TrimStart().StartsWith("WITH")) return "CTE/WITH";
return "기타";
}
private static bool IsKeyword(string s) =>
Keywords.Any(k => k.Equals(s, StringComparison.OrdinalIgnoreCase));
private static List<string> Tokenize(string sql)
{
var tokens = new List<string>();
var current = new StringBuilder();
var inStr = false;
var strChar = ' ';
for (var i = 0; i < sql.Length; i++)
{
var c = sql[i];
if (inStr)
{
current.Append(c);
if (c == strChar) inStr = false;
continue;
}
if (c is '\'' or '"' or '`')
{
if (current.Length > 0) { tokens.Add(current.ToString()); current.Clear(); }
current.Append(c);
inStr = true; strChar = c;
continue;
}
if (c is '(' or ')' or ',')
{
if (current.Length > 0) { tokens.Add(current.ToString().Trim()); current.Clear(); }
tokens.Add(c.ToString());
continue;
}
if (c == ' ')
{
if (current.Length > 0) { tokens.Add(current.ToString().Trim()); current.Clear(); }
continue;
}
current.Append(c);
}
if (current.Length > 0) tokens.Add(current.ToString().Trim());
return tokens.Where(t => !string.IsNullOrEmpty(t)).ToList();
}
private static string BuildSelectTemplate(string table) =>
$"SELECT\n *\nFROM\n {table}\nWHERE\n 1 = 1\nLIMIT 100;";
private static void AddPreview(List<LauncherItem> items, string text, int maxLines)
{
foreach (var line in text.Split('\n').Take(maxLines))
{
var t = line.TrimEnd();
if (!string.IsNullOrWhiteSpace(t))
items.Add(new LauncherItem(t, "", null, null, Symbol: "\uE8F1"));
}
}
[GeneratedRegex(@"\s+")]
private static partial Regex WhitespaceRegex();
[GeneratedRegex(@"\s*([(),])\s*")]
private static partial Regex SpaceAroundParensRegex();
[GeneratedRegex(@"\s+,")]
private static partial Regex SpaceBeforeCommaRegex();
[GeneratedRegex(@"(?:FROM|JOIN)\s+([\w\.\[\]`""]+)", RegexOptions.IgnoreCase)]
private static partial Regex TableRegex();
[GeneratedRegex(@"SELECT\s+(.*?)\s+FROM", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
private static partial Regex SelectColsRegex();
}