Files
AX-Copilot/src/AxCopilot/Handlers/SqlHandler.cs
lacvet e0548c52a9 [Phase L18] SQL포맷·TextCase·Aspect·Abbr 핸들러 4종 추가
SqlHandler.cs (신규, ~280줄, prefix=sql, partial class):
- Keywords[]: 50+ SQL 키워드 + NewlineKeywords HashSet
- Format(): Tokenize() 기반 키워드·괄호·쉼표 들여쓰기 포맷
- Minify(): 공백·괄호 주변 최소화
- TransformKeywords(): 키워드 대소문자 일괄 변환
- BuildStatsItems(): DML유형·테이블·JOIN·WHERE·서브쿼리 분석
- ExtractTables(): FROM/JOIN 테이블 추출 (TableRegex)
- sql select <table>: SELECT 쿼리 템플릿 생성
- [GeneratedRegex]: Whitespace/SpaceAroundParens/Table/SelectCols

TextCaseHandler.cs (신규, ~220줄, prefix=text, partial class):
- CaseItem record: Name/Key/Func<string,string> 13가지 케이스
- Tokenize(): CamelBoundaryRegex + SeparatorRegex 단어 분리
- ToCamel/ToPascal/ToSnake/ToConst/ToKebab/ToDot 구현
- ToSlug(): NormalizationForm.FormD → ASCII 변환 + NonSlugRegex
- 인라인 입력: text camel hello world → helloWorld
- 특정 케이스 선택 시 다른 케이스도 함께 표시

AspectHandler.cs (신규, ~260줄, prefix=aspect):
- AspectPreset record: Ratio/Name/(W,H)[] 9개 프리셋
- BuildFromResolution(): GCD 약분 비율 + 배율별 해상도
- BuildFromRatio(): 프리셋 매칭 또는 기준 너비 5개 계산
- BuildFromRatioAndDim(): 너비/높이 단방향 계산
- BuildCropItems(): 크롭 방향 판별 + FFmpeg crop 파라미터
- TryParseRatio(): :·/ 구분자 + 소수 비율 지원
- Gcd(): 재귀 최대공약수

AbbrHandler.cs (신규, ~260줄, prefix=abbr):
- AbbrEntry record: Short/Full/Description/Category
- 150+개 내장 약어 (웹/개발/DB/보안/클라우드/AI/데이터형식/협업)
- 정확 일치 1개: 약어·원문·설명·카테고리 상세 표시
- 부분 일치: 목록 표시 (Short·Full·Description 검색)
- 카테고리명 직접 입력 → 해당 카테고리 전체 목록

App.xaml.cs (수정): Phase L18 핸들러 4종 RegisterHandler 추가
docs/LAUNCHER_ROADMAP.md (수정): Phase L18 섹션 추가 ( 완료)
빌드: 경고 0, 오류 0
2026-04-04 16:15:13 +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();
}