Initial commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a9809e375b6992d4fab11953cd09b301
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,157 @@
|
||||
using System;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
namespace UnityEditorToolkit.Editor.Database.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Command Pattern 기본 추상 클래스
|
||||
/// 공통 기능 구현
|
||||
/// </summary>
|
||||
public abstract class CommandBase : ICommand
|
||||
{
|
||||
#region ICommand Properties
|
||||
public string CommandId { get; protected set; }
|
||||
public string CommandName { get; protected set; }
|
||||
public DateTime ExecutedAt { get; protected set; }
|
||||
public virtual bool CanPersist => true;
|
||||
#endregion
|
||||
|
||||
#region Constructor
|
||||
protected CommandBase(string commandName)
|
||||
{
|
||||
CommandId = Guid.NewGuid().ToString();
|
||||
CommandName = commandName;
|
||||
ExecutedAt = DateTime.UtcNow;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Abstract Methods
|
||||
/// <summary>
|
||||
/// 실제 실행 로직 (파생 클래스에서 구현)
|
||||
/// </summary>
|
||||
protected abstract UniTask<bool> OnExecuteAsync();
|
||||
|
||||
/// <summary>
|
||||
/// 실제 Undo 로직 (파생 클래스에서 구현)
|
||||
/// </summary>
|
||||
protected abstract UniTask<bool> OnUndoAsync();
|
||||
|
||||
/// <summary>
|
||||
/// 실제 Redo 로직 (파생 클래스에서 구현)
|
||||
/// </summary>
|
||||
protected abstract UniTask<bool> OnRedoAsync();
|
||||
#endregion
|
||||
|
||||
#region ICommand Implementation
|
||||
public async UniTask<bool> ExecuteAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
ToolkitLogger.LogDebug("Command", $" Executing: {CommandName}");
|
||||
ExecutedAt = DateTime.UtcNow;
|
||||
bool result = await OnExecuteAsync();
|
||||
|
||||
if (result)
|
||||
{
|
||||
ToolkitLogger.LogDebug("Command", $" Executed successfully: {CommandName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
ToolkitLogger.LogWarning("Command", $" Execution failed: {CommandName}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("Command", $" Exception during execution: {CommandName}\n{ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async UniTask<bool> UndoAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
ToolkitLogger.LogDebug("Command", $" Undoing: {CommandName}");
|
||||
bool result = await OnUndoAsync();
|
||||
|
||||
if (result)
|
||||
{
|
||||
ToolkitLogger.LogDebug("Command", $" Undo successful: {CommandName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
ToolkitLogger.LogWarning("Command", $" Undo failed: {CommandName}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("Command", $" Exception during undo: {CommandName}\n{ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async UniTask<bool> RedoAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
ToolkitLogger.LogDebug("Command", $" Redoing: {CommandName}");
|
||||
bool result = await OnRedoAsync();
|
||||
|
||||
if (result)
|
||||
{
|
||||
ToolkitLogger.LogDebug("Command", $" Redo successful: {CommandName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
ToolkitLogger.LogWarning("Command", $" Redo failed: {CommandName}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("Command", $" Exception during redo: {CommandName}\n{ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Serialization
|
||||
public virtual string Serialize()
|
||||
{
|
||||
// 기본 직렬화 (파생 클래스에서 오버라이드 가능)
|
||||
return JsonUtility.ToJson(new CommandData
|
||||
{
|
||||
commandId = CommandId,
|
||||
commandName = CommandName,
|
||||
executedAt = ExecutedAt.ToString("o")
|
||||
});
|
||||
}
|
||||
|
||||
public virtual void Deserialize(string json)
|
||||
{
|
||||
// 기본 역직렬화 (파생 클래스에서 오버라이드 가능)
|
||||
var data = JsonUtility.FromJson<CommandData>(json);
|
||||
CommandId = data.commandId;
|
||||
CommandName = data.commandName;
|
||||
ExecutedAt = DateTime.Parse(data.executedAt);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Serialization Data
|
||||
[Serializable]
|
||||
protected class CommandData
|
||||
{
|
||||
public string commandId;
|
||||
public string commandName;
|
||||
public string executedAt;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4cc7fda14659f4345be98770ea61234e
|
||||
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
namespace UnityEditorToolkit.Editor.Database.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Command Factory
|
||||
/// 데이터베이스에서 로드한 명령을 적절한 Command 인스턴스로 복원
|
||||
/// </summary>
|
||||
public static class CommandFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// command_type과 command_data로부터 ICommand 인스턴스 생성
|
||||
/// </summary>
|
||||
/// <param name="commandType">Command 타입 이름 (예: "CreateGameObjectCommand")</param>
|
||||
/// <param name="commandData">직렬화된 JSON 데이터</param>
|
||||
/// <returns>복원된 ICommand 인스턴스, 실패 시 null</returns>
|
||||
public static ICommand CreateFromDatabase(string commandType, string commandData)
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (commandType)
|
||||
{
|
||||
case "CreateGameObjectCommand":
|
||||
return CreateGameObjectCommand.FromJson(commandData);
|
||||
|
||||
case "TransformChangeCommand":
|
||||
return TransformChangeCommand.FromJson(commandData);
|
||||
|
||||
// DeleteGameObjectCommand는 CanPersist = false이므로 데이터베이스에 저장되지 않음
|
||||
|
||||
default:
|
||||
ToolkitLogger.LogWarning("CommandFactory", $" 알 수 없는 Command 타입: {commandType}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("CommandFactory", $" Command 복원 실패 - Type: {commandType}, Error: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Command 타입이 지원되는지 확인
|
||||
/// </summary>
|
||||
public static bool IsSupported(string commandType)
|
||||
{
|
||||
return commandType switch
|
||||
{
|
||||
"CreateGameObjectCommand" => true,
|
||||
"TransformChangeCommand" => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2d6bb958df2f6a242a02c7274b376c67
|
||||
@@ -0,0 +1,514 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
namespace UnityEditorToolkit.Editor.Database.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Command History 관리자
|
||||
/// Undo/Redo 스택 관리 및 세션 간 영속성
|
||||
/// </summary>
|
||||
public class CommandHistory
|
||||
{
|
||||
#region Fields
|
||||
private readonly Stack<ICommand> undoStack;
|
||||
private readonly Stack<ICommand> redoStack;
|
||||
private readonly DatabaseManager databaseManager;
|
||||
|
||||
private const int MaxHistorySize = 100; // 최대 100개 명령 기록
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>
|
||||
/// Undo 가능한 명령 개수
|
||||
/// </summary>
|
||||
public int UndoCount => undoStack.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Redo 가능한 명령 개수
|
||||
/// </summary>
|
||||
public int RedoCount => redoStack.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Undo 가능 여부
|
||||
/// </summary>
|
||||
public bool CanUndo => undoStack.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Redo 가능 여부
|
||||
/// </summary>
|
||||
public bool CanRedo => redoStack.Count > 0;
|
||||
#endregion
|
||||
|
||||
#region Events
|
||||
/// <summary>
|
||||
/// History 변경 이벤트 (UI 업데이트용)
|
||||
/// </summary>
|
||||
public event Action OnHistoryChanged;
|
||||
#endregion
|
||||
|
||||
#region Constructor
|
||||
public CommandHistory(DatabaseManager databaseManager)
|
||||
{
|
||||
this.databaseManager = databaseManager ?? throw new ArgumentNullException(nameof(databaseManager));
|
||||
undoStack = new Stack<ICommand>();
|
||||
redoStack = new Stack<ICommand>();
|
||||
|
||||
ToolkitLogger.LogDebug("CommandHistory", "생성 완료.");
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Execute Command
|
||||
/// <summary>
|
||||
/// 명령 실행 및 히스토리 추가
|
||||
/// </summary>
|
||||
public async UniTask<bool> ExecuteCommandAsync(ICommand command)
|
||||
{
|
||||
if (command == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(command));
|
||||
}
|
||||
|
||||
// 명령 실행
|
||||
bool success = await command.ExecuteAsync();
|
||||
|
||||
if (success)
|
||||
{
|
||||
// Undo 스택에 추가
|
||||
undoStack.Push(command);
|
||||
|
||||
// Redo 스택 초기화 (새로운 명령이 실행되면 Redo 불가)
|
||||
redoStack.Clear();
|
||||
|
||||
// 히스토리 크기 제한
|
||||
TrimHistory();
|
||||
|
||||
// 데이터베이스에 저장 (선택적)
|
||||
if (command.CanPersist && databaseManager.IsConnected)
|
||||
{
|
||||
await PersistCommandAsync(command);
|
||||
}
|
||||
|
||||
// 이벤트 발생
|
||||
OnHistoryChanged?.Invoke();
|
||||
|
||||
ToolkitLogger.LogDebug("CommandHistory", $" 명령 실행 및 추가: {command.CommandName} (Undo: {UndoCount}, Redo: {RedoCount})");
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 이미 실행된 명령을 히스토리에 기록 (실행 없이 기록만)
|
||||
/// </summary>
|
||||
public async UniTask RecordCommandAsync(ICommand command)
|
||||
{
|
||||
if (command == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(command));
|
||||
}
|
||||
|
||||
// Undo 스택에 추가 (이미 실행됨)
|
||||
undoStack.Push(command);
|
||||
|
||||
// Redo 스택 초기화 (새로운 명령이 기록되면 Redo 불가)
|
||||
redoStack.Clear();
|
||||
|
||||
// 히스토리 크기 제한
|
||||
TrimHistory();
|
||||
|
||||
// 데이터베이스에 저장 (선택적)
|
||||
if (command.CanPersist && databaseManager.IsConnected)
|
||||
{
|
||||
await PersistCommandAsync(command);
|
||||
}
|
||||
|
||||
// 이벤트 발생
|
||||
OnHistoryChanged?.Invoke();
|
||||
|
||||
ToolkitLogger.LogDebug("CommandHistory", $" 명령 기록: {command.CommandName} (Undo: {UndoCount}, Redo: {RedoCount})");
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Undo/Redo
|
||||
/// <summary>
|
||||
/// Undo 실행
|
||||
/// </summary>
|
||||
public async UniTask<bool> UndoAsync()
|
||||
{
|
||||
if (!CanUndo)
|
||||
{
|
||||
ToolkitLogger.LogWarning("CommandHistory", "Undo 불가능 - 스택이 비어있습니다.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var command = undoStack.Pop();
|
||||
bool success = await command.UndoAsync();
|
||||
|
||||
if (success)
|
||||
{
|
||||
// Redo 스택에 추가
|
||||
redoStack.Push(command);
|
||||
|
||||
// 이벤트 발생
|
||||
OnHistoryChanged?.Invoke();
|
||||
|
||||
ToolkitLogger.LogDebug("CommandHistory", $" Undo 완료: {command.CommandName} (Undo: {UndoCount}, Redo: {RedoCount})");
|
||||
}
|
||||
else
|
||||
{
|
||||
// 실패 시 다시 Undo 스택에 추가
|
||||
undoStack.Push(command);
|
||||
ToolkitLogger.LogError("CommandHistory", $" Undo 실패: {command.CommandName}");
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Undo 실행 (동기 - WebSocket 핸들러용)
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// WebSocket 핸들러에서 결과를 반환받기 위한 동기 메서드입니다.
|
||||
/// 내부적으로 UndoAsync()를 동기 호출합니다.
|
||||
/// </remarks>
|
||||
public bool Undo()
|
||||
{
|
||||
return UndoAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Redo 실행
|
||||
/// </summary>
|
||||
public async UniTask<bool> RedoAsync()
|
||||
{
|
||||
if (!CanRedo)
|
||||
{
|
||||
ToolkitLogger.LogWarning("CommandHistory", "Redo 불가능 - 스택이 비어있습니다.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var command = redoStack.Pop();
|
||||
bool success = await command.RedoAsync();
|
||||
|
||||
if (success)
|
||||
{
|
||||
// Undo 스택에 추가
|
||||
undoStack.Push(command);
|
||||
|
||||
// 이벤트 발생
|
||||
OnHistoryChanged?.Invoke();
|
||||
|
||||
ToolkitLogger.LogDebug("CommandHistory", $" Redo 완료: {command.CommandName} (Undo: {UndoCount}, Redo: {RedoCount})");
|
||||
}
|
||||
else
|
||||
{
|
||||
// 실패 시 다시 Redo 스택에 추가
|
||||
redoStack.Push(command);
|
||||
ToolkitLogger.LogError("CommandHistory", $" Redo 실패: {command.CommandName}");
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Redo 실행 (동기 - WebSocket 핸들러용)
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// WebSocket 핸들러에서 결과를 반환받기 위한 동기 메서드입니다.
|
||||
/// 내부적으로 RedoAsync()를 동기 호출합니다.
|
||||
/// </remarks>
|
||||
public bool Redo()
|
||||
{
|
||||
return RedoAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 다음 Undo할 명령 확인 (스택에서 제거 안함)
|
||||
/// </summary>
|
||||
public ICommand PeekUndo()
|
||||
{
|
||||
return CanUndo ? undoStack.Peek() : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 다음 Redo할 명령 확인 (스택에서 제거 안함)
|
||||
/// </summary>
|
||||
public ICommand PeekRedo()
|
||||
{
|
||||
return CanRedo ? redoStack.Peek() : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Undo 스택 목록 가져오기
|
||||
/// </summary>
|
||||
public List<ICommand> GetUndoStack(int limit = 10)
|
||||
{
|
||||
var result = new List<ICommand>();
|
||||
var temp = new Stack<ICommand>();
|
||||
|
||||
int count = 0;
|
||||
while (undoStack.Count > 0 && count < limit)
|
||||
{
|
||||
var cmd = undoStack.Pop();
|
||||
temp.Push(cmd);
|
||||
result.Add(cmd);
|
||||
count++;
|
||||
}
|
||||
|
||||
// 복원
|
||||
while (temp.Count > 0)
|
||||
{
|
||||
undoStack.Push(temp.Pop());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Redo 스택 목록 가져오기
|
||||
/// </summary>
|
||||
public List<ICommand> GetRedoStack(int limit = 10)
|
||||
{
|
||||
var result = new List<ICommand>();
|
||||
var temp = new Stack<ICommand>();
|
||||
|
||||
int count = 0;
|
||||
while (redoStack.Count > 0 && count < limit)
|
||||
{
|
||||
var cmd = redoStack.Pop();
|
||||
temp.Push(cmd);
|
||||
result.Add(cmd);
|
||||
count++;
|
||||
}
|
||||
|
||||
// 복원
|
||||
while (temp.Count > 0)
|
||||
{
|
||||
redoStack.Push(temp.Pop());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region History Management
|
||||
/// <summary>
|
||||
/// 전체 히스토리 초기화
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
undoStack.Clear();
|
||||
redoStack.Clear();
|
||||
OnHistoryChanged?.Invoke();
|
||||
|
||||
ToolkitLogger.LogDebug("CommandHistory", "히스토리 초기화 완료.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 히스토리 크기 제한
|
||||
/// </summary>
|
||||
private void TrimHistory()
|
||||
{
|
||||
if (undoStack.Count > MaxHistorySize)
|
||||
{
|
||||
// 성능 개선: 리스트로 변환 후 트림하고 스택 재구성
|
||||
var list = undoStack.ToList();
|
||||
list.RemoveRange(0, list.Count - MaxHistorySize);
|
||||
|
||||
undoStack.Clear();
|
||||
for (int i = list.Count - 1; i >= 0; i--)
|
||||
{
|
||||
undoStack.Push(list[i]);
|
||||
}
|
||||
|
||||
ToolkitLogger.LogDebug("CommandHistory", $" 히스토리 크기 제한 적용: {undoStack.Count}개 유지");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 최근 명령 목록 가져오기 (UI 표시용)
|
||||
/// </summary>
|
||||
public List<string> GetRecentCommands(int count = 10)
|
||||
{
|
||||
var commands = new List<string>();
|
||||
var temp = new Stack<ICommand>();
|
||||
|
||||
// Undo 스택에서 가져오기
|
||||
int retrievedCount = 0;
|
||||
while (undoStack.Count > 0 && retrievedCount < count)
|
||||
{
|
||||
var cmd = undoStack.Pop();
|
||||
temp.Push(cmd);
|
||||
commands.Add($"{cmd.ExecutedAt:HH:mm:ss} - {cmd.CommandName}");
|
||||
retrievedCount++;
|
||||
}
|
||||
|
||||
// 복원
|
||||
while (temp.Count > 0)
|
||||
{
|
||||
undoStack.Push(temp.Pop());
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Database Persistence
|
||||
/// <summary>
|
||||
/// 명령을 데이터베이스에 저장
|
||||
/// </summary>
|
||||
private async UniTask PersistCommandAsync(ICommand command)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!databaseManager.IsConnected || databaseManager.Connector == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// JSON 직렬화
|
||||
string json = command.Serialize();
|
||||
|
||||
// SQL INSERT (SQLite 문법)
|
||||
string sql = @"
|
||||
INSERT INTO command_history (
|
||||
command_id, command_name, command_type,
|
||||
command_data, executed_at, executed_by
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?);";
|
||||
|
||||
await UniTask.RunOnThreadPool(() =>
|
||||
{
|
||||
var connection = databaseManager.Connector.Connection;
|
||||
connection.Execute(sql,
|
||||
command.CommandId,
|
||||
command.CommandName,
|
||||
command.GetType().Name,
|
||||
json,
|
||||
command.ExecutedAt.ToString("o"), // ISO 8601 format
|
||||
"EditorUI" // 실행 주체 구분
|
||||
);
|
||||
});
|
||||
|
||||
ToolkitLogger.LogDebug("CommandHistory", $" 명령 저장 완료: {command.CommandName}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("CommandHistory", $" 명령 저장 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 데이터베이스에서 히스토리 로드 (세션 복원)
|
||||
/// </summary>
|
||||
public async UniTask<int> LoadHistoryFromDatabaseAsync(DateTime since)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!databaseManager.IsConnected || databaseManager.Connector == null)
|
||||
{
|
||||
ToolkitLogger.LogWarning("CommandHistory", "데이터베이스 연결되지 않음 - 히스토리 로드 불가.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// SQL SELECT (SQLite 문법)
|
||||
string sql = @"
|
||||
SELECT command_id, command_name, command_type, command_data, executed_at
|
||||
FROM command_history
|
||||
WHERE executed_at >= ?
|
||||
ORDER BY executed_at ASC
|
||||
LIMIT 100";
|
||||
|
||||
int loadedCount = 0;
|
||||
|
||||
// DB 쿼리는 백그라운드 스레드에서 실행
|
||||
List<CommandHistoryRecord> records = null;
|
||||
await UniTask.RunOnThreadPool(() =>
|
||||
{
|
||||
var connection = databaseManager.Connector.Connection;
|
||||
records = connection.Query<CommandHistoryRecord>(sql, since.ToString("o")).ToList();
|
||||
});
|
||||
|
||||
// Command 복원은 메인 스레드에서 실행 (Unity API 호출 가능)
|
||||
await UniTask.SwitchToMainThread();
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
// CommandFactory를 사용하여 Command 복원
|
||||
var command = CommandFactory.CreateFromDatabase(record.command_type, record.command_data);
|
||||
|
||||
if (command != null)
|
||||
{
|
||||
// Undo 스택에 추가 (실행 완료된 명령)
|
||||
undoStack.Push(command);
|
||||
loadedCount++;
|
||||
|
||||
ToolkitLogger.LogDebug("CommandHistory", $" Command 복원: {command.CommandName} (Type: {record.command_type})");
|
||||
}
|
||||
else
|
||||
{
|
||||
ToolkitLogger.LogWarning("CommandHistory", $" Command 복원 실패 - Type: {record.command_type}, ID: {record.command_id}");
|
||||
}
|
||||
}
|
||||
|
||||
ToolkitLogger.LogDebug("CommandHistory", $" 히스토리 로드 완료: {loadedCount}개");
|
||||
return loadedCount;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("CommandHistory", $" 히스토리 로드 실패: {ex.Message}");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Command History 레코드 (SQLite 쿼리 결과용)
|
||||
/// </summary>
|
||||
private class CommandHistoryRecord
|
||||
{
|
||||
public string command_id { get; set; }
|
||||
public string command_name { get; set; }
|
||||
public string command_type { get; set; }
|
||||
public string command_data { get; set; }
|
||||
public string executed_at { get; set; }
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Status
|
||||
/// <summary>
|
||||
/// 히스토리 상태 정보
|
||||
/// </summary>
|
||||
public HistoryStatus GetStatus()
|
||||
{
|
||||
return new HistoryStatus
|
||||
{
|
||||
UndoCount = UndoCount,
|
||||
RedoCount = RedoCount,
|
||||
CanUndo = CanUndo,
|
||||
CanRedo = CanRedo,
|
||||
MaxHistorySize = MaxHistorySize
|
||||
};
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Status Struct
|
||||
public struct HistoryStatus
|
||||
{
|
||||
public int UndoCount;
|
||||
public int RedoCount;
|
||||
public bool CanUndo;
|
||||
public bool CanRedo;
|
||||
public int MaxHistorySize;
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"[HistoryStatus] Undo: {UndoCount}, Redo: {RedoCount}, Max: {MaxHistorySize}";
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b79709016c210a441ad295d149fe3734
|
||||
@@ -0,0 +1,187 @@
|
||||
using System;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
namespace UnityEditorToolkit.Editor.Database.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// GameObject 생성 명령
|
||||
/// </summary>
|
||||
public class CreateGameObjectCommand : CommandBase
|
||||
{
|
||||
#region Fields
|
||||
private readonly string gameObjectName;
|
||||
private readonly Vector3 position;
|
||||
private readonly Quaternion rotation;
|
||||
private readonly int parentInstanceId;
|
||||
private int createdInstanceId;
|
||||
#endregion
|
||||
|
||||
#region Constructor
|
||||
public CreateGameObjectCommand(
|
||||
string gameObjectName,
|
||||
Vector3 position = default,
|
||||
Quaternion rotation = default,
|
||||
GameObject parent = null)
|
||||
: base($"Create GameObject: {gameObjectName}")
|
||||
{
|
||||
this.gameObjectName = gameObjectName;
|
||||
this.position = position;
|
||||
this.rotation = rotation != default ? rotation : Quaternion.identity;
|
||||
parentInstanceId = parent != null ? parent.GetInstanceID() : 0;
|
||||
createdInstanceId = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 이미 생성된 GameObject로부터 Command 생성 (중복 생성 방지)
|
||||
/// </summary>
|
||||
public static CreateGameObjectCommand CreateFromExisting(GameObject existingObject, GameObject parent = null)
|
||||
{
|
||||
if (existingObject == null)
|
||||
throw new ArgumentNullException(nameof(existingObject));
|
||||
|
||||
var command = new CreateGameObjectCommand(
|
||||
existingObject.name,
|
||||
existingObject.transform.position,
|
||||
existingObject.transform.rotation,
|
||||
parent
|
||||
);
|
||||
|
||||
// 이미 생성된 객체의 InstanceID 저장
|
||||
command.createdInstanceId = existingObject.GetInstanceID();
|
||||
|
||||
return command;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Command Implementation
|
||||
protected override async UniTask<bool> OnExecuteAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// GameObject 생성
|
||||
var go = new GameObject(gameObjectName);
|
||||
go.transform.position = position;
|
||||
go.transform.rotation = rotation;
|
||||
|
||||
// 부모 설정
|
||||
if (parentInstanceId != 0)
|
||||
{
|
||||
var parent = UnityEditor.EditorUtility.InstanceIDToObject(parentInstanceId) as GameObject;
|
||||
if (parent != null)
|
||||
{
|
||||
go.transform.SetParent(parent.transform, true);
|
||||
}
|
||||
}
|
||||
|
||||
// 생성된 GameObject ID 저장
|
||||
createdInstanceId = go.GetInstanceID();
|
||||
|
||||
ToolkitLogger.LogDebug("CreateGameObjectCommand", $" GameObject 생성: {gameObjectName} (ID: {createdInstanceId})");
|
||||
|
||||
await UniTask.Yield();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("CreateGameObjectCommand", $" 생성 실패: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async UniTask<bool> OnUndoAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 생성된 GameObject 삭제
|
||||
var go = UnityEditor.EditorUtility.InstanceIDToObject(createdInstanceId) as GameObject;
|
||||
if (go != null)
|
||||
{
|
||||
UnityEngine.Object.DestroyImmediate(go);
|
||||
ToolkitLogger.LogDebug("CreateGameObjectCommand", $" GameObject 삭제 (Undo): {gameObjectName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
ToolkitLogger.LogWarning("CreateGameObjectCommand", $" GameObject를 찾을 수 없음: {gameObjectName}");
|
||||
}
|
||||
|
||||
await UniTask.Yield();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("CreateGameObjectCommand", $" Undo 실패: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async UniTask<bool> OnRedoAsync()
|
||||
{
|
||||
// Redo는 Execute와 동일
|
||||
return await OnExecuteAsync();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Serialization
|
||||
public override string Serialize()
|
||||
{
|
||||
return JsonUtility.ToJson(new CreateGameObjectData
|
||||
{
|
||||
commandId = CommandId,
|
||||
commandName = CommandName,
|
||||
executedAt = ExecutedAt.ToString("o"),
|
||||
gameObjectName = gameObjectName,
|
||||
position = position,
|
||||
rotation = rotation,
|
||||
parentInstanceId = parentInstanceId,
|
||||
createdInstanceId = createdInstanceId
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JSON에서 Command 복원 (세션 영속성용)
|
||||
/// </summary>
|
||||
public static CreateGameObjectCommand FromJson(string json)
|
||||
{
|
||||
var data = JsonUtility.FromJson<CreateGameObjectData>(json);
|
||||
|
||||
// 부모 GameObject 찾기
|
||||
GameObject parent = null;
|
||||
if (data.parentInstanceId != 0)
|
||||
{
|
||||
parent = UnityEditor.EditorUtility.InstanceIDToObject(data.parentInstanceId) as GameObject;
|
||||
}
|
||||
|
||||
// 새 인스턴스 생성
|
||||
var command = new CreateGameObjectCommand(
|
||||
data.gameObjectName,
|
||||
data.position,
|
||||
data.rotation,
|
||||
parent
|
||||
);
|
||||
|
||||
// 메타데이터 복원
|
||||
command.CommandId = data.commandId;
|
||||
command.CommandName = data.commandName;
|
||||
command.ExecutedAt = DateTime.Parse(data.executedAt);
|
||||
command.createdInstanceId = data.createdInstanceId;
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class CreateGameObjectData
|
||||
{
|
||||
public string commandId;
|
||||
public string commandName;
|
||||
public string executedAt;
|
||||
public string gameObjectName;
|
||||
public Vector3 position;
|
||||
public Quaternion rotation;
|
||||
public int parentInstanceId;
|
||||
public int createdInstanceId;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b61e4d05723fa33478874aad39fed96d
|
||||
@@ -0,0 +1,156 @@
|
||||
using System;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
namespace UnityEditorToolkit.Editor.Database.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// GameObject 삭제 명령
|
||||
/// 삭제 전 상태를 저장하여 Undo 지원
|
||||
/// </summary>
|
||||
public class DeleteGameObjectCommand : CommandBase
|
||||
{
|
||||
#region Fields
|
||||
private readonly int gameObjectInstanceId;
|
||||
private readonly string gameObjectName;
|
||||
private readonly Vector3 position;
|
||||
private readonly Quaternion rotation;
|
||||
private readonly Vector3 scale;
|
||||
private readonly int parentInstanceId;
|
||||
private readonly int siblingIndex;
|
||||
private GameObject deletedGameObject; // Undo용 참조 보관
|
||||
#endregion
|
||||
|
||||
#region Constructor
|
||||
public DeleteGameObjectCommand(GameObject gameObject)
|
||||
: base($"Delete GameObject: {gameObject.name}")
|
||||
{
|
||||
gameObjectInstanceId = gameObject.GetInstanceID();
|
||||
gameObjectName = gameObject.name;
|
||||
position = gameObject.transform.position;
|
||||
rotation = gameObject.transform.rotation;
|
||||
scale = gameObject.transform.localScale;
|
||||
parentInstanceId = gameObject.transform.parent != null
|
||||
? gameObject.transform.parent.gameObject.GetInstanceID()
|
||||
: 0;
|
||||
siblingIndex = gameObject.transform.GetSiblingIndex();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Command Implementation
|
||||
protected override async UniTask<bool> OnExecuteAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var go = UnityEditor.EditorUtility.InstanceIDToObject(gameObjectInstanceId) as GameObject;
|
||||
if (go == null)
|
||||
{
|
||||
ToolkitLogger.LogWarning("DeleteGameObjectCommand", $" GameObject를 찾을 수 없음: {gameObjectName}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Undo를 위해 참조 보관 (비활성화)
|
||||
deletedGameObject = go;
|
||||
go.SetActive(false);
|
||||
go.hideFlags = HideFlags.HideInHierarchy;
|
||||
|
||||
ToolkitLogger.LogDebug("DeleteGameObjectCommand", $" GameObject 삭제: {gameObjectName}");
|
||||
|
||||
await UniTask.Yield();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("DeleteGameObjectCommand", $" 삭제 실패: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async UniTask<bool> OnUndoAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (deletedGameObject == null)
|
||||
{
|
||||
ToolkitLogger.LogWarning("DeleteGameObjectCommand", $" 복원할 GameObject 참조가 없음: {gameObjectName}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// GameObject 복원
|
||||
deletedGameObject.SetActive(true);
|
||||
deletedGameObject.hideFlags = HideFlags.None;
|
||||
|
||||
// 부모 및 위치 복원
|
||||
if (parentInstanceId != 0)
|
||||
{
|
||||
var parent = UnityEditor.EditorUtility.InstanceIDToObject(parentInstanceId) as GameObject;
|
||||
if (parent != null)
|
||||
{
|
||||
deletedGameObject.transform.SetParent(parent.transform, false);
|
||||
deletedGameObject.transform.SetSiblingIndex(siblingIndex);
|
||||
}
|
||||
}
|
||||
|
||||
deletedGameObject.transform.position = position;
|
||||
deletedGameObject.transform.rotation = rotation;
|
||||
deletedGameObject.transform.localScale = scale;
|
||||
|
||||
ToolkitLogger.LogDebug("DeleteGameObjectCommand", $" GameObject 복원 (Undo): {gameObjectName}");
|
||||
|
||||
await UniTask.Yield();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("DeleteGameObjectCommand", $" Undo 실패: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async UniTask<bool> OnRedoAsync()
|
||||
{
|
||||
// Redo는 Execute와 동일
|
||||
return await OnExecuteAsync();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Persistence Override
|
||||
public override bool CanPersist => false; // GameObject 참조를 포함하므로 DB 저장 불가
|
||||
#endregion
|
||||
|
||||
#region Serialization
|
||||
public override string Serialize()
|
||||
{
|
||||
return JsonUtility.ToJson(new DeleteGameObjectData
|
||||
{
|
||||
commandId = CommandId,
|
||||
commandName = CommandName,
|
||||
executedAt = ExecutedAt.ToString("o"),
|
||||
gameObjectInstanceId = gameObjectInstanceId,
|
||||
gameObjectName = gameObjectName,
|
||||
position = position,
|
||||
rotation = rotation,
|
||||
scale = scale,
|
||||
parentInstanceId = parentInstanceId,
|
||||
siblingIndex = siblingIndex
|
||||
});
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class DeleteGameObjectData
|
||||
{
|
||||
public string commandId;
|
||||
public string commandName;
|
||||
public string executedAt;
|
||||
public int gameObjectInstanceId;
|
||||
public string gameObjectName;
|
||||
public Vector3 position;
|
||||
public Quaternion rotation;
|
||||
public Vector3 scale;
|
||||
public int parentInstanceId;
|
||||
public int siblingIndex;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 96e777a7e70d2f245be762670a226d75
|
||||
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using Cysharp.Threading.Tasks;
|
||||
|
||||
namespace UnityEditorToolkit.Editor.Database.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Command Pattern 인터페이스
|
||||
/// 모든 실행 가능한 명령은 이 인터페이스를 구현해야 함
|
||||
/// </summary>
|
||||
public interface ICommand
|
||||
{
|
||||
/// <summary>
|
||||
/// 명령 고유 ID (데이터베이스 저장용)
|
||||
/// </summary>
|
||||
string CommandId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 명령 이름 (UI 표시용)
|
||||
/// </summary>
|
||||
string CommandName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 명령 실행 시간
|
||||
/// </summary>
|
||||
DateTime ExecutedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 명령 실행
|
||||
/// </summary>
|
||||
UniTask<bool> ExecuteAsync();
|
||||
|
||||
/// <summary>
|
||||
/// 명령 실행 취소 (Undo)
|
||||
/// </summary>
|
||||
UniTask<bool> UndoAsync();
|
||||
|
||||
/// <summary>
|
||||
/// 명령 재실행 (Redo)
|
||||
/// </summary>
|
||||
UniTask<bool> RedoAsync();
|
||||
|
||||
/// <summary>
|
||||
/// 명령을 데이터베이스에 저장할 수 있는지 여부
|
||||
/// </summary>
|
||||
bool CanPersist { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 명령을 JSON으로 직렬화
|
||||
/// </summary>
|
||||
string Serialize();
|
||||
|
||||
/// <summary>
|
||||
/// JSON에서 명령 복원
|
||||
/// </summary>
|
||||
void Deserialize(string json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 54a0273f9b663c14cb5651614952b46f
|
||||
@@ -0,0 +1,160 @@
|
||||
using System;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
namespace UnityEditorToolkit.Editor.Database.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// GameObject Transform 변경 명령
|
||||
/// Position, Rotation, Scale 변경을 추적하고 Undo/Redo 지원
|
||||
/// </summary>
|
||||
public class TransformChangeCommand : CommandBase
|
||||
{
|
||||
#region Fields
|
||||
private readonly int gameObjectInstanceId;
|
||||
private readonly Vector3 oldPosition;
|
||||
private readonly Quaternion oldRotation;
|
||||
private readonly Vector3 oldScale;
|
||||
private readonly Vector3 newPosition;
|
||||
private readonly Quaternion newRotation;
|
||||
private readonly Vector3 newScale;
|
||||
private readonly string gameObjectName;
|
||||
#endregion
|
||||
|
||||
#region Constructor
|
||||
public TransformChangeCommand(
|
||||
GameObject gameObject,
|
||||
Vector3 oldPosition, Quaternion oldRotation, Vector3 oldScale,
|
||||
Vector3 newPosition, Quaternion newRotation, Vector3 newScale)
|
||||
: base($"Transform Change: {gameObject.name}")
|
||||
{
|
||||
gameObjectInstanceId = gameObject.GetInstanceID();
|
||||
gameObjectName = gameObject.name;
|
||||
|
||||
this.oldPosition = oldPosition;
|
||||
this.oldRotation = oldRotation;
|
||||
this.oldScale = oldScale;
|
||||
|
||||
this.newPosition = newPosition;
|
||||
this.newRotation = newRotation;
|
||||
this.newScale = newScale;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Command Implementation
|
||||
protected override async UniTask<bool> OnExecuteAsync()
|
||||
{
|
||||
var go = GetGameObject();
|
||||
if (go == null) return false;
|
||||
|
||||
// 새로운 Transform 적용
|
||||
go.transform.position = newPosition;
|
||||
go.transform.rotation = newRotation;
|
||||
go.transform.localScale = newScale;
|
||||
|
||||
await UniTask.Yield();
|
||||
return true;
|
||||
}
|
||||
|
||||
protected override async UniTask<bool> OnUndoAsync()
|
||||
{
|
||||
var go = GetGameObject();
|
||||
if (go == null) return false;
|
||||
|
||||
// 이전 Transform 복원
|
||||
go.transform.position = oldPosition;
|
||||
go.transform.rotation = oldRotation;
|
||||
go.transform.localScale = oldScale;
|
||||
|
||||
await UniTask.Yield();
|
||||
return true;
|
||||
}
|
||||
|
||||
protected override async UniTask<bool> OnRedoAsync()
|
||||
{
|
||||
// Redo는 Execute와 동일
|
||||
return await OnExecuteAsync();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
private GameObject GetGameObject()
|
||||
{
|
||||
var go = UnityEditor.EditorUtility.InstanceIDToObject(gameObjectInstanceId) as GameObject;
|
||||
if (go == null)
|
||||
{
|
||||
ToolkitLogger.LogWarning("TransformChangeCommand", $"GameObject not found: {gameObjectName} (ID: {gameObjectInstanceId})");
|
||||
}
|
||||
return go;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Serialization
|
||||
public override string Serialize()
|
||||
{
|
||||
return JsonUtility.ToJson(new TransformChangeData
|
||||
{
|
||||
commandId = CommandId,
|
||||
commandName = CommandName,
|
||||
executedAt = ExecutedAt.ToString("o"),
|
||||
gameObjectInstanceId = gameObjectInstanceId,
|
||||
gameObjectName = gameObjectName,
|
||||
oldPosition = oldPosition,
|
||||
oldRotation = oldRotation,
|
||||
oldScale = oldScale,
|
||||
newPosition = newPosition,
|
||||
newRotation = newRotation,
|
||||
newScale = newScale
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JSON에서 Command 복원 (세션 영속성용)
|
||||
/// </summary>
|
||||
public static TransformChangeCommand FromJson(string json)
|
||||
{
|
||||
var data = JsonUtility.FromJson<TransformChangeData>(json);
|
||||
|
||||
// GameObject 찾기
|
||||
var go = UnityEditor.EditorUtility.InstanceIDToObject(data.gameObjectInstanceId) as GameObject;
|
||||
if (go == null)
|
||||
{
|
||||
ToolkitLogger.LogWarning("TransformChangeCommand", $"GameObject not found (FromJson): {data.gameObjectName} (ID: {data.gameObjectInstanceId})");
|
||||
// GameObject가 없어도 Command는 생성 (히스토리 기록용)
|
||||
go = new GameObject(data.gameObjectName);
|
||||
}
|
||||
|
||||
// 새 인스턴스 생성
|
||||
var command = new TransformChangeCommand(
|
||||
go,
|
||||
data.oldPosition, data.oldRotation, data.oldScale,
|
||||
data.newPosition, data.newRotation, data.newScale
|
||||
);
|
||||
|
||||
// 메타데이터 복원
|
||||
command.CommandId = data.commandId;
|
||||
command.CommandName = data.commandName;
|
||||
command.ExecutedAt = DateTime.Parse(data.executedAt);
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class TransformChangeData
|
||||
{
|
||||
public string commandId;
|
||||
public string commandName;
|
||||
public string executedAt;
|
||||
public int gameObjectInstanceId;
|
||||
public string gameObjectName;
|
||||
public Vector3 oldPosition;
|
||||
public Quaternion oldRotation;
|
||||
public Vector3 oldScale;
|
||||
public Vector3 newPosition;
|
||||
public Quaternion newRotation;
|
||||
public Vector3 newScale;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8a0112613fb1ea24bb6632ee8960a2f2
|
||||
347
skills/assets/unity-package/Editor/Database/DatabaseConfig.cs
Normal file
347
skills/assets/unity-package/Editor/Database/DatabaseConfig.cs
Normal file
@@ -0,0 +1,347 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
namespace UnityEditorToolkit.Editor.Database
|
||||
{
|
||||
/// <summary>
|
||||
/// SQLite 데이터베이스 연결 설정
|
||||
/// 임베디드 SQLite - 설치 불필요, 단일 파일 DB
|
||||
/// EditorPrefs에 개별 키로 저장/로드
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class DatabaseConfig
|
||||
{
|
||||
#region EditorPrefs Keys
|
||||
/// <summary>
|
||||
/// EditorPrefs 키 상수
|
||||
/// </summary>
|
||||
private const string PREF_KEY_CONFIG_VERSION = "UnityEditorToolkit.Database.ConfigVersion";
|
||||
private const string PREF_KEY_ENABLE_DATABASE = "UnityEditorToolkit.Database.EnableDatabase";
|
||||
private const string PREF_KEY_FILE_PATH = "UnityEditorToolkit.Database.FilePath";
|
||||
private const string PREF_KEY_ENABLE_WAL = "UnityEditorToolkit.Database.EnableWAL";
|
||||
private const string PREF_KEY_ENABLE_ENCRYPTION = "UnityEditorToolkit.Database.EnableEncryption";
|
||||
private const string PREF_KEY_ENCRYPTION_KEY = "UnityEditorToolkit.Database.EncryptionKey";
|
||||
|
||||
/// <summary>
|
||||
/// 현재 설정 버전
|
||||
/// </summary>
|
||||
public const int CURRENT_VERSION = 1;
|
||||
#endregion
|
||||
|
||||
#region Private Fields
|
||||
/// <summary>
|
||||
/// 설정 버전 (마이그레이션용)
|
||||
/// v1: EnableWAL 기본값 true 변경, EnableDatabase 기본값 true 변경
|
||||
/// </summary>
|
||||
[SerializeField]
|
||||
private int configVersion = 0;
|
||||
|
||||
/// <summary>
|
||||
/// 데이터베이스 기능 활성화 여부
|
||||
/// </summary>
|
||||
[SerializeField]
|
||||
private bool enableDatabase = true;
|
||||
|
||||
/// <summary>
|
||||
/// SQLite 데이터베이스 파일 경로
|
||||
/// 기본값: Application.persistentDataPath + "/unity_editor_toolkit.db"
|
||||
/// </summary>
|
||||
[SerializeField]
|
||||
private string databaseFilePath = "";
|
||||
|
||||
/// <summary>
|
||||
/// WAL (Write-Ahead Logging) 모드 사용 여부
|
||||
/// 성능 향상 및 동시성 개선
|
||||
/// </summary>
|
||||
[SerializeField]
|
||||
private bool enableWAL = true;
|
||||
|
||||
/// <summary>
|
||||
/// 암호화 사용 여부 (SQLite Multiple Ciphers)
|
||||
/// </summary>
|
||||
[SerializeField]
|
||||
private bool enableEncryption = false;
|
||||
|
||||
/// <summary>
|
||||
/// 암호화 키 (암호화 사용 시 필요)
|
||||
/// 주의: 평문 저장, 프로덕션에서는 보안 저장소 사용 권장
|
||||
/// </summary>
|
||||
[SerializeField]
|
||||
private string encryptionKey = "";
|
||||
#endregion
|
||||
|
||||
#region Public Properties
|
||||
public int ConfigVersion
|
||||
{
|
||||
get => configVersion;
|
||||
set => configVersion = value;
|
||||
}
|
||||
|
||||
public bool EnableDatabase
|
||||
{
|
||||
get => enableDatabase;
|
||||
set => enableDatabase = value;
|
||||
}
|
||||
|
||||
public string DatabaseFilePath
|
||||
{
|
||||
get
|
||||
{
|
||||
// 빈 문자열이면 기본 경로 반환
|
||||
if (string.IsNullOrEmpty(databaseFilePath))
|
||||
{
|
||||
return GetDefaultDatabasePath();
|
||||
}
|
||||
return databaseFilePath;
|
||||
}
|
||||
set => databaseFilePath = value;
|
||||
}
|
||||
|
||||
public bool EnableWAL
|
||||
{
|
||||
get => enableWAL;
|
||||
set => enableWAL = value;
|
||||
}
|
||||
|
||||
public bool EnableEncryption
|
||||
{
|
||||
get => enableEncryption;
|
||||
set => enableEncryption = value;
|
||||
}
|
||||
|
||||
public string EncryptionKey
|
||||
{
|
||||
get => encryptionKey;
|
||||
set => encryptionKey = value;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Connection String
|
||||
/// <summary>
|
||||
/// SQLite 연결 문자열 생성
|
||||
/// </summary>
|
||||
/// <returns>SQLite 연결 문자열</returns>
|
||||
public string GetConnectionString()
|
||||
{
|
||||
string dbPath = DatabaseFilePath;
|
||||
|
||||
// 기본 연결 문자열
|
||||
string connectionString = $"Data Source={dbPath}";
|
||||
|
||||
// 암호화 사용 시
|
||||
if (enableEncryption && !string.IsNullOrEmpty(encryptionKey))
|
||||
{
|
||||
connectionString += $";Password={encryptionKey}";
|
||||
}
|
||||
|
||||
return connectionString;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Static Methods
|
||||
/// <summary>
|
||||
/// 기본 데이터베이스 파일 경로 가져오기
|
||||
/// </summary>
|
||||
/// <returns>기본 경로</returns>
|
||||
public static string GetDefaultDatabasePath()
|
||||
{
|
||||
// Unity persistentDataPath 사용 (플랫폼별 자동 선택)
|
||||
// Windows: %USERPROFILE%\AppData\LocalLow\CompanyName\ProductName
|
||||
// macOS: ~/Library/Application Support/CompanyName/ProductName
|
||||
// Linux: ~/.config/unity3d/CompanyName/ProductName
|
||||
return Path.Combine(Application.persistentDataPath, "unity_editor_toolkit.db");
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Validation
|
||||
/// <summary>
|
||||
/// 설정 유효성 검증
|
||||
/// </summary>
|
||||
/// <returns>유효성 검증 결과</returns>
|
||||
public ValidationResult Validate()
|
||||
{
|
||||
// 데이터베이스 비활성화 시 검증 통과
|
||||
if (!enableDatabase)
|
||||
{
|
||||
return new ValidationResult { IsValid = true };
|
||||
}
|
||||
|
||||
// 파일 경로 검증
|
||||
string dbPath = DatabaseFilePath;
|
||||
if (string.IsNullOrWhiteSpace(dbPath))
|
||||
{
|
||||
return new ValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
ErrorMessage = "Database file path는 필수 항목입니다."
|
||||
};
|
||||
}
|
||||
|
||||
// 디렉토리 존재 여부 확인 (없으면 생성 가능하도록 경고만)
|
||||
string directory = Path.GetDirectoryName(dbPath);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
ErrorMessage = $"디렉토리 생성 실패: {ex.Message}"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 암호화 사용 시 키 검증
|
||||
if (enableEncryption && string.IsNullOrWhiteSpace(encryptionKey))
|
||||
{
|
||||
return new ValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
ErrorMessage = "암호화 사용 시 Encryption Key는 필수 항목입니다."
|
||||
};
|
||||
}
|
||||
|
||||
return new ValidationResult { IsValid = true };
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Reset
|
||||
/// <summary>
|
||||
/// 기본값으로 초기화
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
configVersion = CURRENT_VERSION;
|
||||
enableDatabase = true; // SQLite는 설치 불필요, 기본 활성화
|
||||
databaseFilePath = "";
|
||||
enableWAL = true;
|
||||
enableEncryption = false;
|
||||
encryptionKey = "";
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region EditorPrefs Save/Load
|
||||
/// <summary>
|
||||
/// EditorPrefs에 개별 키로 저장
|
||||
/// </summary>
|
||||
public void SaveToEditorPrefs()
|
||||
{
|
||||
EditorPrefs.SetInt(PREF_KEY_CONFIG_VERSION, configVersion);
|
||||
EditorPrefs.SetBool(PREF_KEY_ENABLE_DATABASE, enableDatabase);
|
||||
EditorPrefs.SetString(PREF_KEY_FILE_PATH, databaseFilePath);
|
||||
EditorPrefs.SetBool(PREF_KEY_ENABLE_WAL, enableWAL);
|
||||
EditorPrefs.SetBool(PREF_KEY_ENABLE_ENCRYPTION, enableEncryption);
|
||||
EditorPrefs.SetString(PREF_KEY_ENCRYPTION_KEY, encryptionKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EditorPrefs에서 개별 키로 로드 (마이그레이션 포함)
|
||||
/// </summary>
|
||||
/// <returns>DatabaseConfig 인스턴스</returns>
|
||||
public static DatabaseConfig LoadFromEditorPrefs()
|
||||
{
|
||||
var config = new DatabaseConfig();
|
||||
|
||||
// ConfigVersion 확인 (마이그레이션 판단용)
|
||||
int savedVersion = EditorPrefs.GetInt(PREF_KEY_CONFIG_VERSION, -1);
|
||||
|
||||
if (savedVersion == -1)
|
||||
{
|
||||
// 첫 실행: 기본값 사용 (저장하지 않음)
|
||||
ToolkitLogger.Log("DatabaseConfig", "첫 실행 감지 - 기본값 사용");
|
||||
config.Reset();
|
||||
return config;
|
||||
}
|
||||
|
||||
// 개별 키에서 로드
|
||||
config.configVersion = savedVersion;
|
||||
config.enableDatabase = EditorPrefs.GetBool(PREF_KEY_ENABLE_DATABASE, true);
|
||||
config.databaseFilePath = EditorPrefs.GetString(PREF_KEY_FILE_PATH, "");
|
||||
config.enableWAL = EditorPrefs.GetBool(PREF_KEY_ENABLE_WAL, true);
|
||||
config.enableEncryption = EditorPrefs.GetBool(PREF_KEY_ENABLE_ENCRYPTION, false);
|
||||
config.encryptionKey = EditorPrefs.GetString(PREF_KEY_ENCRYPTION_KEY, "");
|
||||
|
||||
// 마이그레이션 필요 여부 확인
|
||||
if (config.configVersion < CURRENT_VERSION)
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseConfig", $"마이그레이션: v{config.configVersion} → v{CURRENT_VERSION}");
|
||||
MigrateConfig(config);
|
||||
config.SaveToEditorPrefs(); // 마이그레이션 후 저장
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EditorPrefs 키 모두 삭제
|
||||
/// </summary>
|
||||
public static void ClearEditorPrefs()
|
||||
{
|
||||
EditorPrefs.DeleteKey(PREF_KEY_CONFIG_VERSION);
|
||||
EditorPrefs.DeleteKey(PREF_KEY_ENABLE_DATABASE);
|
||||
EditorPrefs.DeleteKey(PREF_KEY_FILE_PATH);
|
||||
EditorPrefs.DeleteKey(PREF_KEY_ENABLE_WAL);
|
||||
EditorPrefs.DeleteKey(PREF_KEY_ENABLE_ENCRYPTION);
|
||||
EditorPrefs.DeleteKey(PREF_KEY_ENCRYPTION_KEY);
|
||||
ToolkitLogger.Log("DatabaseConfig", "EditorPrefs 초기화 완료");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 설정 마이그레이션 (이전 버전 → 최신 버전)
|
||||
/// </summary>
|
||||
/// <param name="config">마이그레이션할 설정</param>
|
||||
private static void MigrateConfig(DatabaseConfig config)
|
||||
{
|
||||
// v0 → v1: EnableWAL과 EnableDatabase 기본값을 true로 변경
|
||||
if (config.configVersion < 1)
|
||||
{
|
||||
bool changed = false;
|
||||
|
||||
if (!config.enableWAL)
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseConfig", "마이그레이션: EnableWAL을 true로 변경 (v0 → v1)");
|
||||
config.enableWAL = true;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!config.enableDatabase)
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseConfig", "마이그레이션: EnableDatabase를 true로 변경 (v0 → v1)");
|
||||
config.enableDatabase = true;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseConfig", "SQLite는 설치가 필요없으므로 Database 기능이 기본 활성화됩니다.");
|
||||
}
|
||||
|
||||
config.configVersion = 1;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 설정 유효성 검증 결과
|
||||
/// </summary>
|
||||
public struct ValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 유효성 검증 통과 여부
|
||||
/// </summary>
|
||||
public bool IsValid;
|
||||
|
||||
/// <summary>
|
||||
/// 검증 실패 시 에러 메시지
|
||||
/// </summary>
|
||||
public string ErrorMessage;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8f131f462b224eb479a0d27ce5c885b5
|
||||
598
skills/assets/unity-package/Editor/Database/DatabaseManager.cs
Normal file
598
skills/assets/unity-package/Editor/Database/DatabaseManager.cs
Normal file
@@ -0,0 +1,598 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Compilation;
|
||||
using UnityEditorToolkit.Editor.Database.Commands;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
namespace UnityEditorToolkit.Editor.Database
|
||||
{
|
||||
/// <summary>
|
||||
/// SQLite 데이터베이스 관리 싱글톤
|
||||
/// 임베디드 SQLite - 설치 불필요, 단일 파일 DB
|
||||
/// Domain Reload 시 자동으로 연결 정리 및 재연결
|
||||
/// </summary>
|
||||
[InitializeOnLoad]
|
||||
public class DatabaseManager
|
||||
{
|
||||
#region Domain Reload Handling
|
||||
private const string PREF_KEY_DB_WAS_CONNECTED = "UnityEditorToolkit.Database.WasConnected";
|
||||
private const string PREF_KEY_DB_PATH = "UnityEditorToolkit.Database.Path";
|
||||
private const string PREF_KEY_DB_ENABLE_WAL = "UnityEditorToolkit.Database.EnableWAL";
|
||||
|
||||
static DatabaseManager()
|
||||
{
|
||||
// Domain Reload 전: 연결 정리
|
||||
AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload;
|
||||
|
||||
// Domain Reload 후: 자동 재연결
|
||||
EditorApplication.delayCall += OnAfterAssemblyReload;
|
||||
}
|
||||
|
||||
private static void OnBeforeAssemblyReload()
|
||||
{
|
||||
if (instance != null && instance.IsConnected)
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseManager", "Domain Reload 감지 - 연결 상태 저장 및 정리 중...");
|
||||
|
||||
// 연결 상태 저장
|
||||
EditorPrefs.SetBool(PREF_KEY_DB_WAS_CONNECTED, true);
|
||||
if (instance.config != null)
|
||||
{
|
||||
EditorPrefs.SetString(PREF_KEY_DB_PATH, instance.config.DatabaseFilePath);
|
||||
EditorPrefs.SetBool(PREF_KEY_DB_ENABLE_WAL, instance.config.EnableWAL);
|
||||
}
|
||||
|
||||
// 연결 정리 (동기 방식)
|
||||
try
|
||||
{
|
||||
instance.connector?.DisconnectAsync().Forget();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("DatabaseManager", $" Shutdown 중 예외: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnAfterAssemblyReload()
|
||||
{
|
||||
// 이전에 연결되어 있었는지 확인
|
||||
bool wasConnected = EditorPrefs.GetBool(PREF_KEY_DB_WAS_CONNECTED, false);
|
||||
|
||||
if (wasConnected)
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseManager", "Domain Reload 완료 - 자동 재연결 시도...");
|
||||
|
||||
// 연결 상태 플래그 클리어
|
||||
EditorPrefs.DeleteKey(PREF_KEY_DB_WAS_CONNECTED);
|
||||
|
||||
// 설정 복원 및 재연결
|
||||
string dbPath = EditorPrefs.GetString(PREF_KEY_DB_PATH, "");
|
||||
bool enableWAL = EditorPrefs.GetBool(PREF_KEY_DB_ENABLE_WAL, true);
|
||||
|
||||
if (!string.IsNullOrEmpty(dbPath))
|
||||
{
|
||||
var config = new DatabaseConfig
|
||||
{
|
||||
DatabaseFilePath = dbPath,
|
||||
EnableWAL = enableWAL
|
||||
};
|
||||
|
||||
// 비동기 재연결
|
||||
Instance.InitializeAsync(config).Forget();
|
||||
ToolkitLogger.Log("DatabaseManager", "자동 재연결 완료.");
|
||||
}
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Singleton
|
||||
private static DatabaseManager instance;
|
||||
private static readonly object @lock = new object();
|
||||
|
||||
public static DatabaseManager Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (instance == null)
|
||||
{
|
||||
lock (@lock)
|
||||
{
|
||||
if (instance == null)
|
||||
{
|
||||
instance = new DatabaseManager();
|
||||
}
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
private DatabaseManager()
|
||||
{
|
||||
// Private constructor for singleton
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Fields
|
||||
private DatabaseConfig config;
|
||||
private SQLiteConnector connector;
|
||||
private CommandHistory commandHistory;
|
||||
private SyncManager syncManager;
|
||||
private bool isInitialized = false;
|
||||
private bool isConnected = false;
|
||||
private CancellationTokenSource lifecycleCts;
|
||||
private static bool isInitializing = false; // Race condition prevention
|
||||
private static UniTaskCompletionSource<InitializationResult> initializationTcs; // Wait-based: 초기화 결과 공유
|
||||
private bool isMigrationRunning = false; // Migration in progress flag
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>
|
||||
/// 데이터베이스 초기화 완료 여부
|
||||
/// </summary>
|
||||
public bool IsInitialized => isInitialized;
|
||||
|
||||
/// <summary>
|
||||
/// 데이터베이스 연결 상태
|
||||
/// </summary>
|
||||
public bool IsConnected => isConnected && connector != null && connector.IsConnected;
|
||||
|
||||
/// <summary>
|
||||
/// 현재 데이터베이스 설정
|
||||
/// </summary>
|
||||
public DatabaseConfig Config => config;
|
||||
|
||||
/// <summary>
|
||||
/// SQLite 커넥터
|
||||
/// </summary>
|
||||
public SQLiteConnector Connector => connector;
|
||||
|
||||
/// <summary>
|
||||
/// Command History (Undo/Redo)
|
||||
/// </summary>
|
||||
public CommandHistory CommandHistory => commandHistory;
|
||||
|
||||
/// <summary>
|
||||
/// Sync Manager (실시간 동기화)
|
||||
/// </summary>
|
||||
public SyncManager SyncManager => syncManager;
|
||||
|
||||
/// <summary>
|
||||
/// 마이그레이션 실행 중 여부
|
||||
/// </summary>
|
||||
public bool IsMigrationRunning => isMigrationRunning;
|
||||
#endregion
|
||||
|
||||
#region Initialization
|
||||
/// <summary>
|
||||
/// 데이터베이스 초기화
|
||||
/// </summary>
|
||||
/// <param name="config">데이터베이스 설정</param>
|
||||
public async UniTask<InitializationResult> InitializeAsync(DatabaseConfig config)
|
||||
{
|
||||
// Wait-based: 이미 진행 중인 초기화가 있으면 완료 대기
|
||||
UniTaskCompletionSource<InitializationResult> existingTcs = null;
|
||||
|
||||
// Race condition prevention: 이미 초기화 진행 중인 경우
|
||||
lock (@lock)
|
||||
{
|
||||
if (isInitializing)
|
||||
{
|
||||
// 진행 중인 초기화가 있으면 TCS 참조 저장 (lock 밖에서 대기)
|
||||
existingTcs = initializationTcs;
|
||||
}
|
||||
else if (isInitialized)
|
||||
{
|
||||
// 이미 초기화된 경우
|
||||
ToolkitLogger.LogWarning("DatabaseManager", "이미 초기화되었습니다. Shutdown 후 재초기화하세요.");
|
||||
return new InitializationResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = "Already initialized. Call Shutdown() first."
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
// 새로운 초기화 시작
|
||||
isInitializing = true;
|
||||
initializationTcs = new UniTaskCompletionSource<InitializationResult>();
|
||||
}
|
||||
}
|
||||
|
||||
// 진행 중인 초기화가 있으면 완료될 때까지 대기 후 결과 공유
|
||||
if (existingTcs != null)
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseManager", "다른 초기화가 진행 중입니다. 완료를 대기합니다...");
|
||||
return await existingTcs.Task;
|
||||
}
|
||||
|
||||
// 새로운 초기화 시작
|
||||
InitializationResult result = default;
|
||||
|
||||
try
|
||||
{
|
||||
// 데이터베이스 비활성화 시
|
||||
if (!config.EnableDatabase)
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseManager", "데이터베이스 기능이 비활성화되어 있습니다.");
|
||||
result = new InitializationResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Database feature is disabled."
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
// 설정 유효성 검증
|
||||
var validation = config.Validate();
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
ToolkitLogger.LogError("DatabaseManager", $" 설정 유효성 검증 실패: {validation.ErrorMessage}");
|
||||
result = new InitializationResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = validation.ErrorMessage
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
// 설정 저장
|
||||
this.config = config;
|
||||
|
||||
// CancellationTokenSource 생성
|
||||
lifecycleCts = new CancellationTokenSource();
|
||||
|
||||
// SQLite 커넥터 생성
|
||||
connector = new SQLiteConnector(this.config);
|
||||
|
||||
// Command History 생성
|
||||
commandHistory = new CommandHistory(this);
|
||||
|
||||
// 연결 테스트
|
||||
var connectResult = await connector.ConnectAsync(lifecycleCts.Token);
|
||||
if (!connectResult.Success)
|
||||
{
|
||||
ToolkitLogger.LogError("DatabaseManager", $" 연결 실패: {connectResult.ErrorMessage}");
|
||||
await CleanupAsync();
|
||||
result = new InitializationResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = connectResult.ErrorMessage
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
isConnected = true;
|
||||
isInitialized = true;
|
||||
|
||||
// 자동 마이그레이션 실행
|
||||
await RunAutoMigrationAsync(lifecycleCts.Token);
|
||||
|
||||
// SyncManager 초기화
|
||||
syncManager = new SyncManager(this);
|
||||
ToolkitLogger.Log("DatabaseManager", "SyncManager initialized.");
|
||||
|
||||
ToolkitLogger.Log("DatabaseManager", $" 초기화 완료: {this.config.DatabaseFilePath}");
|
||||
result = new InitializationResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Initialization successful."
|
||||
};
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("DatabaseManager", $" 초기화 중 예외 발생: {ex.Message}\n{ex.StackTrace}");
|
||||
await CleanupAsync();
|
||||
result = new InitializationResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = ex.Message
|
||||
};
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// TCS에 결과 설정하여 대기 중인 호출자들에게 결과 전달
|
||||
lock (@lock)
|
||||
{
|
||||
isInitializing = false;
|
||||
var tcs = initializationTcs;
|
||||
initializationTcs = null;
|
||||
tcs?.TrySetResult(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 데이터베이스 종료 및 리소스 정리
|
||||
/// </summary>
|
||||
public async UniTask ShutdownAsync()
|
||||
{
|
||||
if (!isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ToolkitLogger.Log("DatabaseManager", "Shutting down...");
|
||||
|
||||
try
|
||||
{
|
||||
// CancellationToken 취소
|
||||
lifecycleCts?.Cancel();
|
||||
|
||||
// 리소스 정리
|
||||
await CleanupAsync();
|
||||
|
||||
isInitialized = false;
|
||||
isConnected = false;
|
||||
|
||||
ToolkitLogger.Log("DatabaseManager", "Shutdown 완료.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("DatabaseManager", $" Shutdown 중 예외 발생: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 내부 리소스 정리
|
||||
/// </summary>
|
||||
private async UniTask CleanupAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// SyncManager 정리
|
||||
if (syncManager != null)
|
||||
{
|
||||
syncManager.Dispose();
|
||||
syncManager = null;
|
||||
ToolkitLogger.Log("DatabaseManager", "SyncManager disposed.");
|
||||
}
|
||||
|
||||
// Command History 정리
|
||||
if (commandHistory != null)
|
||||
{
|
||||
commandHistory.Clear();
|
||||
commandHistory = null;
|
||||
}
|
||||
|
||||
// 커넥터 정리
|
||||
if (connector != null)
|
||||
{
|
||||
await connector.DisconnectAsync();
|
||||
connector = null;
|
||||
}
|
||||
|
||||
// CancellationTokenSource 정리
|
||||
lifecycleCts?.Dispose();
|
||||
lifecycleCts = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("DatabaseManager", $" Cleanup 중 예외 발생: {ex.Message}");
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Connection Management
|
||||
/// <summary>
|
||||
/// 데이터베이스 연결 해제 (동기 방식 - 서버 종료/Assembly Reload 시 사용)
|
||||
/// </summary>
|
||||
public void Disconnect()
|
||||
{
|
||||
if (!isInitialized || !isConnected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ToolkitLogger.Log("DatabaseManager", "서버 종료로 인한 연결 해제...");
|
||||
|
||||
try
|
||||
{
|
||||
// CancellationToken 취소
|
||||
lifecycleCts?.Cancel();
|
||||
|
||||
// SyncManager 정리
|
||||
if (syncManager != null)
|
||||
{
|
||||
syncManager.Dispose();
|
||||
syncManager = null;
|
||||
}
|
||||
|
||||
// Command History 정리
|
||||
if (commandHistory != null)
|
||||
{
|
||||
commandHistory.Clear();
|
||||
commandHistory = null;
|
||||
}
|
||||
|
||||
// 커넥터 정리 (동기 방식)
|
||||
connector?.Disconnect();
|
||||
connector = null;
|
||||
|
||||
// CancellationTokenSource 정리
|
||||
lifecycleCts?.Dispose();
|
||||
lifecycleCts = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("DatabaseManager", $" Disconnect 중 예외: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 상태 업데이트
|
||||
isConnected = false;
|
||||
isInitialized = false;
|
||||
}
|
||||
|
||||
ToolkitLogger.Log("DatabaseManager", "연결 해제 완료.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 연결 상태 확인
|
||||
/// </summary>
|
||||
public async UniTask<bool> TestConnectionAsync()
|
||||
{
|
||||
if (!isInitialized || connector == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await connector.TestConnectionAsync(lifecycleCts?.Token ?? default);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("DatabaseManager", $" 연결 테스트 실패: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 연결 재시도
|
||||
/// </summary>
|
||||
public async UniTask<bool> ReconnectAsync()
|
||||
{
|
||||
if (!isInitialized || config == null)
|
||||
{
|
||||
ToolkitLogger.LogWarning("DatabaseManager", "초기화되지 않았습니다.");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseManager", "재연결 시도 중...");
|
||||
|
||||
// 기존 연결 종료
|
||||
if (connector != null)
|
||||
{
|
||||
await connector.DisconnectAsync();
|
||||
}
|
||||
|
||||
// 새 커넥터 생성
|
||||
connector = new SQLiteConnector(config);
|
||||
|
||||
// 연결
|
||||
var result = await connector.ConnectAsync(lifecycleCts?.Token ?? default);
|
||||
isConnected = result.Success;
|
||||
|
||||
if (isConnected)
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseManager", "재연결 성공.");
|
||||
}
|
||||
else
|
||||
{
|
||||
ToolkitLogger.LogError("DatabaseManager", $" 재연결 실패: {result.ErrorMessage}");
|
||||
}
|
||||
|
||||
return isConnected;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("DatabaseManager", $" 재연결 중 예외 발생: {ex.Message}");
|
||||
isConnected = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Auto Migration
|
||||
/// <summary>
|
||||
/// 자동 마이그레이션 실행 (연결 시 자동 호출)
|
||||
/// </summary>
|
||||
private async UniTask RunAutoMigrationAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
isMigrationRunning = true;
|
||||
try
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseManager", "자동 마이그레이션 확인 중...");
|
||||
|
||||
var migrationRunner = new MigrationRunner(this);
|
||||
var result = await migrationRunner.RunMigrationsAsync(cancellationToken);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
if (result.MigrationsApplied > 0)
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseManager", $" 자동 마이그레이션 완료: {result.MigrationsApplied}개 적용됨");
|
||||
}
|
||||
else
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseManager", "마이그레이션이 최신 상태입니다.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ToolkitLogger.LogWarning("DatabaseManager", $" 자동 마이그레이션 실패: {result.ErrorMessage}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogWarning("DatabaseManager", $" 자동 마이그레이션 중 예외 발생: {ex.Message}");
|
||||
// 마이그레이션 실패해도 연결은 유지
|
||||
}
|
||||
finally
|
||||
{
|
||||
isMigrationRunning = false;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Health Check
|
||||
/// <summary>
|
||||
/// 데이터베이스 상태 정보 조회
|
||||
/// </summary>
|
||||
public DatabaseHealthStatus GetHealthStatus()
|
||||
{
|
||||
return new DatabaseHealthStatus
|
||||
{
|
||||
IsInitialized = isInitialized,
|
||||
IsConnected = IsConnected,
|
||||
IsEnabled = config?.EnableDatabase ?? false,
|
||||
DatabaseFilePath = config?.DatabaseFilePath ?? "N/A",
|
||||
DatabaseFileExists = connector?.DatabaseFileExists() ?? false
|
||||
};
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Result Structs
|
||||
/// <summary>
|
||||
/// 초기화 결과
|
||||
/// </summary>
|
||||
public struct InitializationResult
|
||||
{
|
||||
public bool Success;
|
||||
public string Message;
|
||||
public string ErrorMessage;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 데이터베이스 상태
|
||||
/// </summary>
|
||||
public struct DatabaseHealthStatus
|
||||
{
|
||||
public bool IsInitialized;
|
||||
public bool IsConnected;
|
||||
public bool IsEnabled;
|
||||
public string DatabaseFilePath;
|
||||
public bool DatabaseFileExists;
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"[DatabaseHealthStatus]\n" +
|
||||
$" Initialized: {IsInitialized}\n" +
|
||||
$" Connected: {IsConnected}\n" +
|
||||
$" Enabled: {IsEnabled}\n" +
|
||||
$" Database File: {DatabaseFilePath}\n" +
|
||||
$" File Exists: {DatabaseFileExists}";
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3c63a562fc80ebc42a10afeb117fa4a2
|
||||
596
skills/assets/unity-package/Editor/Database/MigrationRunner.cs
Normal file
596
skills/assets/unity-package/Editor/Database/MigrationRunner.cs
Normal file
@@ -0,0 +1,596 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
namespace UnityEditorToolkit.Editor.Database
|
||||
{
|
||||
/// <summary>
|
||||
/// 데이터베이스 마이그레이션 자동 실행
|
||||
/// SQL 파일을 순서대로 실행하여 스키마 버전 관리
|
||||
/// SQLite 버전 - 트랜잭션 지원
|
||||
/// </summary>
|
||||
public class MigrationRunner
|
||||
{
|
||||
#region Fields
|
||||
private readonly DatabaseManager databaseManager;
|
||||
private readonly string migrationsPath;
|
||||
#endregion
|
||||
|
||||
#region Constructor
|
||||
public MigrationRunner(DatabaseManager databaseManager, string migrationsPath = null)
|
||||
{
|
||||
this.databaseManager = databaseManager ?? throw new ArgumentNullException(nameof(databaseManager));
|
||||
|
||||
// 마이그레이션 폴더 경로 (기본값: Editor/Database/Migrations)
|
||||
if (string.IsNullOrEmpty(migrationsPath))
|
||||
{
|
||||
// Unity 패키지 내 Migrations 폴더 경로
|
||||
this.migrationsPath = Path.Combine(Application.dataPath, "..", "Packages",
|
||||
"com.devgom.unity-editor-toolkit", "Editor", "Database", "Migrations");
|
||||
}
|
||||
else
|
||||
{
|
||||
this.migrationsPath = migrationsPath;
|
||||
}
|
||||
|
||||
ToolkitLogger.LogDebug("MigrationRunner", $" 생성 완료. Migrations Path: {this.migrationsPath}");
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Migration Execution
|
||||
/// <summary>
|
||||
/// 모든 마이그레이션 실행 (순서대로)
|
||||
/// </summary>
|
||||
public async UniTask<MigrationResult> RunMigrationsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!databaseManager.IsInitialized || !databaseManager.IsConnected)
|
||||
{
|
||||
return new MigrationResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = "DatabaseManager not initialized or not connected."
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ToolkitLogger.LogDebug("MigrationRunner", "마이그레이션 시작...");
|
||||
|
||||
// 1. migrations 테이블 생성 (존재하지 않으면)
|
||||
await EnsureMigrationTableExistsAsync(cancellationToken);
|
||||
|
||||
// 2. 실행된 마이그레이션 목록 조회
|
||||
var appliedMigrations = await GetAppliedMigrationsAsync(cancellationToken);
|
||||
ToolkitLogger.LogDebug("MigrationRunner", $" 이미 실행된 마이그레이션: {appliedMigrations.Count}개");
|
||||
|
||||
// 3. 마이그레이션 파일 목록 조회
|
||||
var migrationFiles = GetMigrationFiles();
|
||||
if (migrationFiles.Count == 0)
|
||||
{
|
||||
ToolkitLogger.LogWarning("MigrationRunner", $" 마이그레이션 파일이 없습니다: {migrationsPath}");
|
||||
return new MigrationResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "No migration files found.",
|
||||
MigrationsApplied = 0
|
||||
};
|
||||
}
|
||||
|
||||
ToolkitLogger.LogDebug("MigrationRunner", $" 발견된 마이그레이션 파일: {migrationFiles.Count}개");
|
||||
|
||||
// 4. 미실행 마이그레이션 필터링
|
||||
var pendingMigrations = migrationFiles
|
||||
.Where(file => !appliedMigrations.Contains(Path.GetFileNameWithoutExtension(file)))
|
||||
.OrderBy(file => file)
|
||||
.ToList();
|
||||
|
||||
if (pendingMigrations.Count == 0)
|
||||
{
|
||||
ToolkitLogger.LogDebug("MigrationRunner", "실행할 마이그레이션이 없습니다.");
|
||||
return new MigrationResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "All migrations already applied.",
|
||||
MigrationsApplied = 0
|
||||
};
|
||||
}
|
||||
|
||||
ToolkitLogger.LogDebug("MigrationRunner", $" 실행할 마이그레이션: {pendingMigrations.Count}개");
|
||||
|
||||
// 5. 마이그레이션 실행
|
||||
int appliedCount = 0;
|
||||
foreach (var migrationFile in pendingMigrations)
|
||||
{
|
||||
string migrationName = Path.GetFileNameWithoutExtension(migrationFile);
|
||||
ToolkitLogger.LogDebug("MigrationRunner", $" 실행 중: {migrationName}");
|
||||
|
||||
var result = await ApplyMigrationAsync(migrationFile, cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
ToolkitLogger.LogError("MigrationRunner", $" 마이그레이션 실패: {migrationName}\n{result.ErrorMessage}");
|
||||
return new MigrationResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = $"Failed to apply migration: {migrationName}\n{result.ErrorMessage}",
|
||||
MigrationsApplied = appliedCount
|
||||
};
|
||||
}
|
||||
|
||||
appliedCount++;
|
||||
ToolkitLogger.LogDebug("MigrationRunner", $" 완료: {migrationName}");
|
||||
}
|
||||
|
||||
ToolkitLogger.LogDebug("MigrationRunner", $" 마이그레이션 완료: {appliedCount}개 적용됨");
|
||||
return new MigrationResult
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Successfully applied {appliedCount} migration(s).",
|
||||
MigrationsApplied = appliedCount
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("MigrationRunner", $" 마이그레이션 중 예외 발생: {ex.Message}\n{ex.StackTrace}");
|
||||
return new MigrationResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = ex.Message,
|
||||
MigrationsApplied = 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 단일 마이그레이션 실행
|
||||
/// </summary>
|
||||
private async UniTask<MigrationResult> ApplyMigrationAsync(string filePath, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// SQL 파일 읽기
|
||||
string sql = File.ReadAllText(filePath);
|
||||
if (string.IsNullOrWhiteSpace(sql))
|
||||
{
|
||||
return new MigrationResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = "Migration file is empty."
|
||||
};
|
||||
}
|
||||
|
||||
string migrationName = Path.GetFileNameWithoutExtension(filePath);
|
||||
|
||||
await UniTask.RunOnThreadPool(() =>
|
||||
{
|
||||
var connection = databaseManager.Connector.Connection;
|
||||
|
||||
// 트랜잭션으로 마이그레이션 원자성 보장
|
||||
// 마이그레이션 중 오류 발생 시 롤백으로 데이터베이스 일관성 유지
|
||||
connection.BeginTransaction();
|
||||
try
|
||||
{
|
||||
// SQL을 완전한 문장 단위로 분리 (BEGIN...END 블록 고려)
|
||||
var sqlStatements = SplitSqlStatements(sql);
|
||||
int executedCount = 0;
|
||||
|
||||
foreach (var statement in sqlStatements)
|
||||
{
|
||||
var trimmedStatement = statement.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmedStatement))
|
||||
continue;
|
||||
|
||||
// 주석만 있는 문장 스킵 (Execute()에 전달하면 "not an error" 발생)
|
||||
string withoutComments = RemoveSqlComments(trimmedStatement);
|
||||
if (string.IsNullOrWhiteSpace(withoutComments))
|
||||
{
|
||||
ToolkitLogger.LogDebug("MigrationRunner", $" 주석만 있는 문장 스킵 (RemoveSqlComments v2 적용됨)");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 주석이 제거된 SQL 사용 (Execute()가 주석을 처리하지 못할 수 있음)
|
||||
string cleanedSql = withoutComments.Trim();
|
||||
|
||||
// SELECT 문 (결과 메시지용)은 스킵 - Execute()는 결과를 반환하지 않음
|
||||
if (cleanedSql.StartsWith("SELECT ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ToolkitLogger.LogDebug("MigrationRunner", $" SELECT 문 스킵");
|
||||
continue;
|
||||
}
|
||||
|
||||
// PRAGMA 문은 스킵 (SQLiteConnector에서 이미 설정됨)
|
||||
if (cleanedSql.StartsWith("PRAGMA ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ToolkitLogger.LogDebug("MigrationRunner", $" PRAGMA 문 스킵: {cleanedSql}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// SQL 실행 (오류 발생 시 어떤 문에서 발생했는지 확인용)
|
||||
try
|
||||
{
|
||||
connection.Execute(cleanedSql);
|
||||
executedCount++;
|
||||
}
|
||||
catch (Exception sqlEx)
|
||||
{
|
||||
// SQL 문의 첫 100자만 출력 (너무 길면 로그가 지저분해짐)
|
||||
string sqlPreview = trimmedStatement.Length > 100
|
||||
? trimmedStatement.Substring(0, 100) + "..."
|
||||
: trimmedStatement;
|
||||
ToolkitLogger.LogError("MigrationRunner", $" SQL 실행 실패 ({executedCount + 1}번째): {sqlEx.Message}\nSQL: {sqlPreview}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
ToolkitLogger.LogDebug("MigrationRunner", $" SQL 문장 실행 완료: {executedCount}개");
|
||||
|
||||
// migrations 테이블에 기록
|
||||
string insertSql = @"
|
||||
INSERT INTO migrations (migration_name, applied_at)
|
||||
VALUES (?, datetime('now'));";
|
||||
|
||||
connection.Execute(insertSql, migrationName);
|
||||
|
||||
// 트랜잭션 커밋
|
||||
connection.Commit();
|
||||
ToolkitLogger.LogDebug("MigrationRunner", $" 마이그레이션 트랜잭션 커밋 완료: {migrationName}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 트랜잭션 롤백
|
||||
connection.Rollback();
|
||||
ToolkitLogger.LogError("MigrationRunner", $" 마이그레이션 실패 - 롤백됨: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}, cancellationToken: cancellationToken);
|
||||
|
||||
return new MigrationResult { Success = true };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new MigrationResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Migration Table Management
|
||||
/// <summary>
|
||||
/// migrations 테이블 생성 (존재하지 않으면)
|
||||
/// </summary>
|
||||
private async UniTask EnsureMigrationTableExistsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
string createTableSql = @"
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
migration_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
migration_name TEXT NOT NULL UNIQUE,
|
||||
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_migrations_name ON migrations(migration_name);
|
||||
";
|
||||
|
||||
await UniTask.RunOnThreadPool(() =>
|
||||
{
|
||||
var connection = databaseManager.Connector.Connection;
|
||||
connection.Execute(createTableSql);
|
||||
}, cancellationToken: cancellationToken);
|
||||
|
||||
ToolkitLogger.LogDebug("MigrationRunner", "migrations 테이블 확인 완료.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 실행된 마이그레이션 목록 조회
|
||||
/// </summary>
|
||||
private async UniTask<List<string>> GetAppliedMigrationsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var appliedMigrations = new List<string>();
|
||||
|
||||
string selectSql = "SELECT migration_name FROM migrations ORDER BY migration_id ASC;";
|
||||
|
||||
await UniTask.RunOnThreadPool(() =>
|
||||
{
|
||||
var connection = databaseManager.Connector.Connection;
|
||||
var results = connection.Query<MigrationRecord>(selectSql);
|
||||
|
||||
foreach (var record in results)
|
||||
{
|
||||
appliedMigrations.Add(record.migration_name);
|
||||
}
|
||||
}, cancellationToken: cancellationToken);
|
||||
|
||||
return appliedMigrations;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Migration 레코드 (SQLite 쿼리 결과용)
|
||||
/// </summary>
|
||||
private class MigrationRecord
|
||||
{
|
||||
public string migration_name { get; set; }
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Pending Migration Check
|
||||
/// <summary>
|
||||
/// 대기 중인 마이그레이션 수 조회 (UI 표시용)
|
||||
/// </summary>
|
||||
public async UniTask<int> GetPendingMigrationCountAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!databaseManager.IsInitialized || !databaseManager.IsConnected)
|
||||
{
|
||||
return -1; // 연결 안됨
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// migrations 테이블 확인
|
||||
await EnsureMigrationTableExistsAsync(cancellationToken);
|
||||
|
||||
// 실행된 마이그레이션 목록 조회
|
||||
var appliedMigrations = await GetAppliedMigrationsAsync(cancellationToken);
|
||||
|
||||
// 마이그레이션 파일 목록 조회
|
||||
var migrationFiles = GetMigrationFiles();
|
||||
|
||||
// 미실행 마이그레이션 필터링
|
||||
var pendingMigrations = migrationFiles
|
||||
.Where(file => !appliedMigrations.Contains(Path.GetFileNameWithoutExtension(file)))
|
||||
.ToList();
|
||||
|
||||
return pendingMigrations.Count;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogWarning("MigrationRunner", $" 펜딩 마이그레이션 확인 실패: {ex.Message}");
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region File Discovery
|
||||
/// <summary>
|
||||
/// 마이그레이션 파일 목록 조회 (.sql 파일)
|
||||
/// </summary>
|
||||
private List<string> GetMigrationFiles()
|
||||
{
|
||||
if (!Directory.Exists(migrationsPath))
|
||||
{
|
||||
ToolkitLogger.LogWarning("MigrationRunner", $" Migrations 폴더가 존재하지 않습니다: {migrationsPath}");
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
var files = Directory.GetFiles(migrationsPath, "*.sql", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(file => file)
|
||||
.ToList();
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SQL 문에서 주석을 제거 (Execute() 호출 전 유효성 검증용)
|
||||
/// </summary>
|
||||
private string RemoveSqlComments(string sql)
|
||||
{
|
||||
var result = new System.Text.StringBuilder();
|
||||
bool inSingleLineComment = false;
|
||||
bool inMultiLineComment = false;
|
||||
bool inString = false;
|
||||
|
||||
for (int i = 0; i < sql.Length; i++)
|
||||
{
|
||||
char c = sql[i];
|
||||
char nextChar = i + 1 < sql.Length ? sql[i + 1] : '\0';
|
||||
|
||||
// 줄바꿈 처리 (단일 줄 주석 종료)
|
||||
if (c == '\n' || c == '\r')
|
||||
{
|
||||
inSingleLineComment = false;
|
||||
if (!inMultiLineComment)
|
||||
{
|
||||
result.Append(c);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 단일 줄 주석 시작
|
||||
if (!inString && !inMultiLineComment && c == '-' && nextChar == '-')
|
||||
{
|
||||
inSingleLineComment = true;
|
||||
i++; // 두 번째 '-' 스킵
|
||||
continue;
|
||||
}
|
||||
|
||||
// 다중 줄 주석 시작
|
||||
if (!inString && !inSingleLineComment && c == '/' && nextChar == '*')
|
||||
{
|
||||
inMultiLineComment = true;
|
||||
i++; // '*' 스킵
|
||||
continue;
|
||||
}
|
||||
|
||||
// 다중 줄 주석 종료
|
||||
if (inMultiLineComment && c == '*' && nextChar == '/')
|
||||
{
|
||||
inMultiLineComment = false;
|
||||
i++; // '/' 스킵
|
||||
continue;
|
||||
}
|
||||
|
||||
// 주석 내부면 스킵
|
||||
if (inSingleLineComment || inMultiLineComment)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 문자열 시작/종료 (작은따옴표)
|
||||
if (c == '\'')
|
||||
{
|
||||
// 이스케이프된 따옴표 확인 ('')
|
||||
if (inString && nextChar == '\'')
|
||||
{
|
||||
result.Append(c);
|
||||
i++; // 다음 따옴표도 추가
|
||||
result.Append('\'');
|
||||
continue;
|
||||
}
|
||||
inString = !inString;
|
||||
}
|
||||
|
||||
result.Append(c);
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SQL 문장을 BEGIN...END 블록을 고려하여 분리
|
||||
/// </summary>
|
||||
private List<string> SplitSqlStatements(string sql)
|
||||
{
|
||||
var statements = new List<string>();
|
||||
var currentStatement = new System.Text.StringBuilder();
|
||||
int beginEndDepth = 0;
|
||||
bool inSingleLineComment = false;
|
||||
bool inMultiLineComment = false;
|
||||
bool inString = false;
|
||||
|
||||
for (int i = 0; i < sql.Length; i++)
|
||||
{
|
||||
char c = sql[i];
|
||||
char nextChar = i + 1 < sql.Length ? sql[i + 1] : '\0';
|
||||
|
||||
// 줄바꿈 처리 (단일 줄 주석 종료)
|
||||
if (c == '\n')
|
||||
{
|
||||
inSingleLineComment = false;
|
||||
currentStatement.Append(c);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 단일 줄 주석 시작 (문자열 내부가 아닐 때)
|
||||
if (!inString && !inMultiLineComment && c == '-' && nextChar == '-')
|
||||
{
|
||||
inSingleLineComment = true;
|
||||
currentStatement.Append(c);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 다중 줄 주석 시작
|
||||
if (!inString && !inSingleLineComment && c == '/' && nextChar == '*')
|
||||
{
|
||||
inMultiLineComment = true;
|
||||
currentStatement.Append(c);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 다중 줄 주석 종료
|
||||
if (inMultiLineComment && c == '*' && nextChar == '/')
|
||||
{
|
||||
inMultiLineComment = false;
|
||||
currentStatement.Append(c);
|
||||
i++; // '/' 스킵
|
||||
currentStatement.Append('/');
|
||||
continue;
|
||||
}
|
||||
|
||||
// 주석 내부면 그대로 추가
|
||||
if (inSingleLineComment || inMultiLineComment)
|
||||
{
|
||||
currentStatement.Append(c);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 문자열 시작/종료 (작은따옴표)
|
||||
if (c == '\'')
|
||||
{
|
||||
// 이스케이프된 따옴표 확인 ('')
|
||||
if (inString && nextChar == '\'')
|
||||
{
|
||||
currentStatement.Append(c);
|
||||
i++; // 다음 따옴표도 추가
|
||||
currentStatement.Append('\'');
|
||||
continue;
|
||||
}
|
||||
inString = !inString;
|
||||
currentStatement.Append(c);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 문자열 내부면 그대로 추가
|
||||
if (inString)
|
||||
{
|
||||
currentStatement.Append(c);
|
||||
continue;
|
||||
}
|
||||
|
||||
// BEGIN 키워드 감지 (대소문자 무시)
|
||||
if (i + 5 <= sql.Length)
|
||||
{
|
||||
string word = sql.Substring(i, 5).ToUpper();
|
||||
if (word == "BEGIN" && (i == 0 || !char.IsLetterOrDigit(sql[i - 1])) &&
|
||||
(i + 5 >= sql.Length || !char.IsLetterOrDigit(sql[i + 5])))
|
||||
{
|
||||
beginEndDepth++;
|
||||
}
|
||||
}
|
||||
|
||||
// END 키워드 감지
|
||||
if (i + 3 <= sql.Length)
|
||||
{
|
||||
string word = sql.Substring(i, 3).ToUpper();
|
||||
if (word == "END" && (i == 0 || !char.IsLetterOrDigit(sql[i - 1])) &&
|
||||
(i + 3 >= sql.Length || !char.IsLetterOrDigit(sql[i + 3])))
|
||||
{
|
||||
beginEndDepth--;
|
||||
}
|
||||
}
|
||||
|
||||
// 세미콜론으로 문장 분리 (BEGIN...END 블록 외부에서만)
|
||||
if (c == ';' && beginEndDepth == 0)
|
||||
{
|
||||
currentStatement.Append(c);
|
||||
string statement = currentStatement.ToString().Trim();
|
||||
if (!string.IsNullOrWhiteSpace(statement))
|
||||
{
|
||||
statements.Add(statement);
|
||||
}
|
||||
currentStatement.Clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
currentStatement.Append(c);
|
||||
}
|
||||
|
||||
// 마지막 문장 추가 (세미콜론 없이 끝나는 경우)
|
||||
string lastStatement = currentStatement.ToString().Trim();
|
||||
if (!string.IsNullOrWhiteSpace(lastStatement))
|
||||
{
|
||||
statements.Add(lastStatement);
|
||||
}
|
||||
|
||||
return statements;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Result Structs
|
||||
/// <summary>
|
||||
/// 마이그레이션 결과
|
||||
/// </summary>
|
||||
public struct MigrationResult
|
||||
{
|
||||
public bool Success;
|
||||
public string Message;
|
||||
public string ErrorMessage;
|
||||
public int MigrationsApplied;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7efe4a498e5f5254ba0be804329e8fd1
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1056308bc40a585459dd7361bc65fd71
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,358 @@
|
||||
-- Migration 001: Initial Schema (SQLite)
|
||||
-- Unity Editor Toolkit - SQLite Database Schema
|
||||
-- Embedded SQLite (설치 불필요)
|
||||
-- Created: 2025-11-14
|
||||
-- Updated: 2025-11-14 (PostgreSQL → SQLite)
|
||||
|
||||
-- ============================================================
|
||||
-- PRAGMA 설정
|
||||
-- ============================================================
|
||||
|
||||
-- 참고: PRAGMA 설정은 SQLiteConnector.ConnectAsync()에서 자동으로 적용됩니다.
|
||||
-- - foreign_keys = ON (Foreign Key 제약 활성화)
|
||||
-- - journal_mode = WAL (Write-Ahead Logging, 성능 향상)
|
||||
-- - synchronous = NORMAL (fsync 최적화)
|
||||
|
||||
-- ============================================================
|
||||
-- TABLE 1: scenes
|
||||
-- 씬 정보 (프로젝트의 모든 씬)
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scenes (
|
||||
scene_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scene_name TEXT NOT NULL,
|
||||
scene_path TEXT NOT NULL UNIQUE,
|
||||
build_index INTEGER,
|
||||
is_loaded INTEGER NOT NULL DEFAULT 0, -- BOOLEAN → INTEGER (0/1)
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_scenes_name ON scenes(scene_name);
|
||||
CREATE INDEX idx_scenes_is_loaded ON scenes(is_loaded);
|
||||
|
||||
-- ============================================================
|
||||
-- TABLE 2: gameobjects
|
||||
-- GameObject 정보 (Closure Table로 계층 구조 관리)
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gameobjects (
|
||||
object_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
instance_id INTEGER NOT NULL UNIQUE,
|
||||
scene_id INTEGER REFERENCES scenes(scene_id) ON DELETE CASCADE,
|
||||
object_name TEXT NOT NULL,
|
||||
parent_id INTEGER REFERENCES gameobjects(object_id) ON DELETE SET NULL,
|
||||
tag TEXT DEFAULT 'Untagged',
|
||||
layer INTEGER DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1, -- BOOLEAN → INTEGER
|
||||
is_static INTEGER NOT NULL DEFAULT 0, -- BOOLEAN → INTEGER
|
||||
is_deleted INTEGER NOT NULL DEFAULT 0, -- BOOLEAN → INTEGER (Soft delete)
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 인덱스
|
||||
CREATE INDEX idx_gameobjects_instance_id ON gameobjects(instance_id);
|
||||
CREATE INDEX idx_gameobjects_scene_id ON gameobjects(scene_id);
|
||||
CREATE INDEX idx_gameobjects_parent_id ON gameobjects(parent_id);
|
||||
CREATE INDEX idx_gameobjects_tag ON gameobjects(tag);
|
||||
CREATE INDEX idx_gameobjects_is_active ON gameobjects(is_active);
|
||||
CREATE INDEX idx_gameobjects_is_deleted ON gameobjects(is_deleted);
|
||||
|
||||
-- ============================================================
|
||||
-- TABLE 2-1: gameobject_closure
|
||||
-- GameObject 계층 구조 (Closure Table)
|
||||
-- PostgreSQL ltree 대체 - 성능이 더 우수함
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gameobject_closure (
|
||||
ancestor_id INTEGER NOT NULL,
|
||||
descendant_id INTEGER NOT NULL,
|
||||
depth INTEGER NOT NULL,
|
||||
PRIMARY KEY (ancestor_id, descendant_id),
|
||||
FOREIGN KEY (ancestor_id) REFERENCES gameobjects(object_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (descendant_id) REFERENCES gameobjects(object_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 인덱스
|
||||
CREATE INDEX idx_closure_ancestor ON gameobject_closure(ancestor_id);
|
||||
CREATE INDEX idx_closure_descendant ON gameobject_closure(descendant_id);
|
||||
CREATE INDEX idx_closure_depth ON gameobject_closure(depth);
|
||||
|
||||
-- ============================================================
|
||||
-- TABLE 3: components
|
||||
-- Component 정보
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS components (
|
||||
component_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
object_id INTEGER NOT NULL REFERENCES gameobjects(object_id) ON DELETE CASCADE,
|
||||
component_type TEXT NOT NULL,
|
||||
component_data TEXT CHECK(json_valid(component_data)), -- JSON validation
|
||||
is_enabled INTEGER NOT NULL DEFAULT 1, -- BOOLEAN → INTEGER
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_components_object_id ON components(object_id);
|
||||
CREATE INDEX idx_components_type ON components(component_type);
|
||||
-- SQLite JSON index는 json_each/json_tree로 쿼리 시 자동 활용
|
||||
|
||||
-- ============================================================
|
||||
-- TABLE 4: transforms
|
||||
-- Transform 히스토리 (위치, 회전, 스케일 변경 추적)
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transforms (
|
||||
transform_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
object_id INTEGER NOT NULL REFERENCES gameobjects(object_id) ON DELETE CASCADE,
|
||||
position_x REAL NOT NULL,
|
||||
position_y REAL NOT NULL,
|
||||
position_z REAL NOT NULL,
|
||||
rotation_x REAL NOT NULL, -- Quaternion X
|
||||
rotation_y REAL NOT NULL, -- Quaternion Y
|
||||
rotation_z REAL NOT NULL, -- Quaternion Z
|
||||
rotation_w REAL NOT NULL, -- Quaternion W
|
||||
scale_x REAL NOT NULL,
|
||||
scale_y REAL NOT NULL,
|
||||
scale_z REAL NOT NULL,
|
||||
recorded_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_transforms_object_id ON transforms(object_id);
|
||||
CREATE INDEX idx_transforms_recorded_at ON transforms(recorded_at);
|
||||
|
||||
-- ============================================================
|
||||
-- TABLE 5: command_history
|
||||
-- 명령 히스토리 (Command Pattern, Undo/Redo 지원)
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS command_history (
|
||||
-- Primary Key: GUID 문자열 (C# Guid.NewGuid().ToString())
|
||||
command_id TEXT PRIMARY KEY,
|
||||
|
||||
-- Command 정보
|
||||
command_name TEXT NOT NULL,
|
||||
command_type TEXT NOT NULL,
|
||||
command_data TEXT NOT NULL CHECK(json_valid(command_data)), -- JSON validation
|
||||
|
||||
-- 실행 정보
|
||||
executed_at TEXT NOT NULL,
|
||||
executed_by TEXT DEFAULT 'System',
|
||||
|
||||
-- 메타데이터
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 인덱스
|
||||
CREATE INDEX idx_command_history_executed_at ON command_history(executed_at DESC);
|
||||
CREATE INDEX idx_command_history_type ON command_history(command_type);
|
||||
CREATE INDEX idx_command_history_name ON command_history(command_name);
|
||||
CREATE INDEX idx_command_history_executed_by ON command_history(executed_by);
|
||||
|
||||
-- ============================================================
|
||||
-- TABLE 6: snapshots
|
||||
-- 씬 스냅샷 (시점 복원용)
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS snapshots (
|
||||
snapshot_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scene_id INTEGER NOT NULL REFERENCES scenes(scene_id) ON DELETE CASCADE,
|
||||
snapshot_name TEXT NOT NULL,
|
||||
snapshot_data TEXT NOT NULL CHECK(json_valid(snapshot_data)), -- JSON validation
|
||||
description TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_snapshots_scene_id ON snapshots(scene_id);
|
||||
CREATE INDEX idx_snapshots_created_at ON snapshots(created_at);
|
||||
|
||||
-- ============================================================
|
||||
-- TABLE 7: metadata
|
||||
-- 프로젝트 메타데이터
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metadata (
|
||||
metadata_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
value TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_metadata_key ON metadata(key);
|
||||
|
||||
-- ============================================================
|
||||
-- TABLE 8: analytics_cache
|
||||
-- 분석 결과 캐시 (성능 최적화)
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS analytics_cache (
|
||||
cache_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cache_key TEXT NOT NULL UNIQUE,
|
||||
cache_data TEXT NOT NULL CHECK(json_valid(cache_data)), -- JSON validation
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_analytics_cache_key ON analytics_cache(cache_key);
|
||||
CREATE INDEX idx_analytics_cache_expires_at ON analytics_cache(expires_at);
|
||||
|
||||
-- ============================================================
|
||||
-- TRIGGERS
|
||||
-- ============================================================
|
||||
|
||||
-- gameobjects.updated_at 자동 업데이트
|
||||
CREATE TRIGGER IF NOT EXISTS trigger_gameobjects_updated_at
|
||||
AFTER UPDATE ON gameobjects
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE gameobjects SET updated_at = CURRENT_TIMESTAMP
|
||||
WHERE object_id = NEW.object_id;
|
||||
END;
|
||||
|
||||
-- components.updated_at 자동 업데이트
|
||||
CREATE TRIGGER IF NOT EXISTS trigger_components_updated_at
|
||||
AFTER UPDATE ON components
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE components SET updated_at = CURRENT_TIMESTAMP
|
||||
WHERE component_id = NEW.component_id;
|
||||
END;
|
||||
|
||||
-- scenes.updated_at 자동 업데이트
|
||||
CREATE TRIGGER IF NOT EXISTS trigger_scenes_updated_at
|
||||
AFTER UPDATE ON scenes
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE scenes SET updated_at = CURRENT_TIMESTAMP
|
||||
WHERE scene_id = NEW.scene_id;
|
||||
END;
|
||||
|
||||
-- metadata.updated_at 자동 업데이트
|
||||
CREATE TRIGGER IF NOT EXISTS trigger_metadata_updated_at
|
||||
AFTER UPDATE ON metadata
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE metadata SET updated_at = CURRENT_TIMESTAMP
|
||||
WHERE metadata_id = NEW.metadata_id;
|
||||
END;
|
||||
|
||||
-- ============================================================
|
||||
-- CLOSURE TABLE TRIGGERS (자동 유지 관리)
|
||||
-- ============================================================
|
||||
|
||||
-- GameObject 생성 시: 자기 자신 + 부모 조상들 추가
|
||||
CREATE TRIGGER IF NOT EXISTS trigger_gameobject_insert_closure
|
||||
AFTER INSERT ON gameobjects
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
-- 1. 자기 자신 (depth = 0)
|
||||
INSERT INTO gameobject_closure (ancestor_id, descendant_id, depth)
|
||||
VALUES (NEW.object_id, NEW.object_id, 0);
|
||||
|
||||
-- 2. 부모가 있으면 부모의 모든 조상 복사 (depth + 1)
|
||||
INSERT INTO gameobject_closure (ancestor_id, descendant_id, depth)
|
||||
SELECT ancestor_id, NEW.object_id, depth + 1
|
||||
FROM gameobject_closure
|
||||
WHERE descendant_id = NEW.parent_id AND NEW.parent_id IS NOT NULL;
|
||||
END;
|
||||
|
||||
-- GameObject 삭제 시: 관련 Closure 레코드 자동 삭제 (CASCADE)
|
||||
-- (FOREIGN KEY ON DELETE CASCADE가 처리)
|
||||
|
||||
-- ============================================================
|
||||
-- VIEWS
|
||||
-- ============================================================
|
||||
|
||||
-- Active GameObjects View (is_deleted = 0, is_active = 1)
|
||||
CREATE VIEW IF NOT EXISTS active_gameobjects AS
|
||||
SELECT
|
||||
object_id,
|
||||
instance_id,
|
||||
scene_id,
|
||||
object_name,
|
||||
parent_id,
|
||||
tag,
|
||||
layer,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM gameobjects
|
||||
WHERE is_deleted = 0 AND is_active = 1;
|
||||
|
||||
-- GameObject Component Count View
|
||||
CREATE VIEW IF NOT EXISTS gameobject_component_count AS
|
||||
SELECT
|
||||
g.object_id,
|
||||
g.object_name,
|
||||
g.parent_id,
|
||||
COUNT(c.component_id) AS component_count
|
||||
FROM gameobjects g
|
||||
LEFT JOIN components c ON g.object_id = c.object_id
|
||||
WHERE g.is_deleted = 0
|
||||
GROUP BY g.object_id, g.object_name, g.parent_id;
|
||||
|
||||
-- Recent Commands View
|
||||
CREATE VIEW IF NOT EXISTS recent_commands AS
|
||||
SELECT
|
||||
command_id,
|
||||
command_name,
|
||||
command_type,
|
||||
executed_at,
|
||||
executed_by
|
||||
FROM command_history
|
||||
ORDER BY executed_at DESC
|
||||
LIMIT 100;
|
||||
|
||||
-- Command Statistics View
|
||||
CREATE VIEW IF NOT EXISTS command_statistics AS
|
||||
SELECT
|
||||
command_type,
|
||||
COUNT(*) AS command_count,
|
||||
MIN(executed_at) AS first_executed,
|
||||
MAX(executed_at) AS last_executed
|
||||
FROM command_history
|
||||
GROUP BY command_type
|
||||
ORDER BY command_count DESC;
|
||||
|
||||
-- ============================================================
|
||||
-- INITIAL DATA
|
||||
-- ============================================================
|
||||
|
||||
-- 기본 메타데이터 삽입
|
||||
INSERT OR IGNORE INTO metadata (key, value) VALUES
|
||||
('schema_version', '1'),
|
||||
('database_type', 'SQLite'),
|
||||
('database_created_at', datetime('now')),
|
||||
('unity_editor_toolkit_version', '0.5.0');
|
||||
|
||||
-- ============================================================
|
||||
-- COMPLETION
|
||||
-- ============================================================
|
||||
|
||||
-- SQLite는 RAISE NOTICE가 없으므로 SELECT로 완료 메시지 출력
|
||||
SELECT '==========================================================' AS message;
|
||||
SELECT 'Migration 001: Initial Schema (SQLite) - 완료' AS message;
|
||||
SELECT '==========================================================' AS message;
|
||||
SELECT '생성된 테이블: 9개' AS message;
|
||||
SELECT ' - scenes, gameobjects, gameobject_closure, components' AS message;
|
||||
SELECT ' - transforms, command_history, snapshots, metadata, analytics_cache' AS message;
|
||||
SELECT '' AS message;
|
||||
SELECT '생성된 인덱스: 23개' AS message;
|
||||
SELECT ' - gameobject_closure: 3개 (ancestor, descendant, depth)' AS message;
|
||||
SELECT ' - command_history: 4개 (GUID, executed_at, type, name, executed_by)' AS message;
|
||||
SELECT '' AS message;
|
||||
SELECT '생성된 트리거: 5개' AS message;
|
||||
SELECT ' - updated_at 자동 업데이트 (gameobjects, components, scenes, metadata)' AS message;
|
||||
SELECT ' - Closure Table 자동 유지 (gameobject_insert_closure)' AS message;
|
||||
SELECT '' AS message;
|
||||
SELECT '생성된 뷰: 4개' AS message;
|
||||
SELECT ' - active_gameobjects, gameobject_component_count' AS message;
|
||||
SELECT ' - recent_commands, command_statistics' AS message;
|
||||
SELECT '' AS message;
|
||||
SELECT 'Closure Table 계층 구조 쿼리 예시:' AS message;
|
||||
SELECT ' -- 특정 GameObject의 모든 자식 (ltree 대체):' AS message;
|
||||
SELECT ' SELECT g.* FROM gameobjects g' AS message;
|
||||
SELECT ' INNER JOIN gameobject_closure c ON g.object_id = c.descendant_id' AS message;
|
||||
SELECT ' WHERE c.ancestor_id = ? AND c.depth > 0;' AS message;
|
||||
SELECT '==========================================================' AS message;
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5345bdb2f717d2e409cdd34c2a7262a3
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Migration 002: Add GUID to GameObject
|
||||
-- Unity Editor Toolkit - Add persistent GUID column to gameobjects table
|
||||
-- Created: 2025-11-18
|
||||
-- Purpose: Replace instance_id with GUID for persistent GameObject identification
|
||||
|
||||
-- ============================================================
|
||||
-- ALTER TABLE: gameobjects
|
||||
-- Add guid column for persistent identification
|
||||
-- ============================================================
|
||||
|
||||
-- Add guid column (nullable initially for migration)
|
||||
ALTER TABLE gameobjects ADD COLUMN guid TEXT;
|
||||
|
||||
-- Create unique index on guid (after data migration)
|
||||
-- Note: Index will be created after populating existing rows with GUIDs
|
||||
-- CREATE UNIQUE INDEX idx_gameobjects_guid ON gameobjects(guid);
|
||||
|
||||
-- ============================================================
|
||||
-- Migration Notes
|
||||
-- ============================================================
|
||||
--
|
||||
-- GUID migration strategy:
|
||||
-- 1. Add guid column (nullable)
|
||||
-- 2. Existing rows will have NULL guid
|
||||
-- 3. SyncManager will generate GUIDs when syncing GameObjects
|
||||
-- 4. GameObjectGuid component ensures all GameObjects have GUIDs
|
||||
-- 5. After full scene sync, make guid NOT NULL and UNIQUE
|
||||
--
|
||||
-- Future migration (Migration_003):
|
||||
-- ALTER TABLE gameobjects ALTER COLUMN guid SET NOT NULL;
|
||||
-- CREATE UNIQUE INDEX idx_gameobjects_guid ON gameobjects(guid);
|
||||
@@ -0,0 +1,10 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d06228fa3660f4f43a33b92f2cc4e994
|
||||
ScriptedImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
script: {fileID: 11500000, guid: ba03993677dd740da91b9a13653538e9, type: 3}
|
||||
8
skills/assets/unity-package/Editor/Database/Models.meta
Normal file
8
skills/assets/unity-package/Editor/Database/Models.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b6b69cd3d00bc704c863538202e5efb3
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
skills/assets/unity-package/Editor/Database/Queries.meta
Normal file
8
skills/assets/unity-package/Editor/Database/Queries.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8952d02cfc5f21e4b82dc98c5e3e0d21
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
344
skills/assets/unity-package/Editor/Database/SQLiteConnector.cs
Normal file
344
skills/assets/unity-package/Editor/Database/SQLiteConnector.cs
Normal file
@@ -0,0 +1,344 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using SQLite;
|
||||
using UnityEngine;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
namespace UnityEditorToolkit.Editor.Database
|
||||
{
|
||||
/// <summary>
|
||||
/// SQLite 데이터베이스 커넥터
|
||||
/// 임베디드 SQLite - 설치 불필요
|
||||
/// </summary>
|
||||
public class SQLiteConnector
|
||||
{
|
||||
#region Fields
|
||||
private readonly DatabaseConfig config;
|
||||
private SQLiteConnection connection;
|
||||
private bool isConnected;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>
|
||||
/// 연결 상태
|
||||
/// </summary>
|
||||
public bool IsConnected => isConnected && connection != null;
|
||||
|
||||
/// <summary>
|
||||
/// SQLite 연결 객체
|
||||
/// </summary>
|
||||
public SQLiteConnection Connection => connection;
|
||||
#endregion
|
||||
|
||||
#region Constructor
|
||||
public SQLiteConnector(DatabaseConfig config)
|
||||
{
|
||||
this.config = config ?? throw new ArgumentNullException(nameof(config));
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Connection Management
|
||||
/// <summary>
|
||||
/// 데이터베이스 연결
|
||||
/// </summary>
|
||||
public async UniTask<ConnectionResult> ConnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (isConnected)
|
||||
{
|
||||
ToolkitLogger.LogWarning("SQLiteConnector", "Already connected.");
|
||||
return new ConnectionResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Already connected."
|
||||
};
|
||||
}
|
||||
|
||||
ToolkitLogger.Log("SQLiteConnector", $" Connecting to: {config.DatabaseFilePath}");
|
||||
|
||||
// SQLite 연결 생성 (동기 작업이지만 UniTask로 래핑)
|
||||
await UniTask.RunOnThreadPool(() =>
|
||||
{
|
||||
// SQLite 연결 옵션 설정
|
||||
var options = new SQLiteConnectionString(
|
||||
config.DatabaseFilePath,
|
||||
SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create | SQLiteOpenFlags.FullMutex,
|
||||
storeDateTimeAsTicks: true
|
||||
);
|
||||
|
||||
connection = new SQLiteConnection(options);
|
||||
|
||||
// WAL 모드 설정 (성능 향상)
|
||||
if (config.EnableWAL)
|
||||
{
|
||||
try
|
||||
{
|
||||
connection.Execute("PRAGMA journal_mode=WAL;");
|
||||
ToolkitLogger.Log("SQLiteConnector", "WAL mode enabled.");
|
||||
}
|
||||
catch (Exception walEx)
|
||||
{
|
||||
ToolkitLogger.LogWarning("SQLiteConnector", $" WAL mode failed (continuing without WAL): {walEx.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Foreign Keys 활성화
|
||||
try
|
||||
{
|
||||
connection.Execute("PRAGMA foreign_keys=ON;");
|
||||
}
|
||||
catch (Exception fkEx)
|
||||
{
|
||||
ToolkitLogger.LogWarning("SQLiteConnector", $" Foreign keys activation failed: {fkEx.Message}");
|
||||
}
|
||||
|
||||
// Synchronous 설정 (NORMAL = 안전하면서도 빠름)
|
||||
try
|
||||
{
|
||||
connection.Execute("PRAGMA synchronous=NORMAL;");
|
||||
}
|
||||
catch (Exception syncEx)
|
||||
{
|
||||
ToolkitLogger.LogWarning("SQLiteConnector", $" Synchronous setting failed: {syncEx.Message}");
|
||||
}
|
||||
|
||||
}, cancellationToken: cancellationToken);
|
||||
|
||||
isConnected = true;
|
||||
|
||||
ToolkitLogger.Log("SQLiteConnector", $" Connected successfully: {config.DatabaseFilePath}");
|
||||
return new ConnectionResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Connection successful."
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
ToolkitLogger.LogWarning("SQLiteConnector", "Connection cancelled.");
|
||||
return new ConnectionResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = "Connection cancelled."
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("SQLiteConnector", $" Connection failed: {ex.Message}\n{ex.StackTrace}");
|
||||
return new ConnectionResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 데이터베이스 연결 해제 (비동기)
|
||||
/// </summary>
|
||||
public async UniTask DisconnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!isConnected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ToolkitLogger.Log("SQLiteConnector", "Disconnecting...");
|
||||
|
||||
await UniTask.RunOnThreadPool(() =>
|
||||
{
|
||||
DisconnectInternal();
|
||||
});
|
||||
|
||||
isConnected = false;
|
||||
|
||||
// 강제 GC (파일 핸들 해제를 위해)
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
|
||||
ToolkitLogger.Log("SQLiteConnector", "Disconnected.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("SQLiteConnector", $" Disconnect error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 데이터베이스 연결 해제 (동기 - Assembly Reload/서버 종료 시 사용)
|
||||
/// </summary>
|
||||
public void Disconnect()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!isConnected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ToolkitLogger.Log("SQLiteConnector", "Disconnecting (sync)...");
|
||||
|
||||
DisconnectInternal();
|
||||
|
||||
isConnected = false;
|
||||
|
||||
ToolkitLogger.Log("SQLiteConnector", "Disconnected (sync).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("SQLiteConnector", $" Disconnect error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 내부 연결 해제 로직
|
||||
/// </summary>
|
||||
private void DisconnectInternal()
|
||||
{
|
||||
// WAL 체크포인트 수행 (파일 잠금 해제를 위해)
|
||||
if (config.EnableWAL && connection != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
connection.Execute("PRAGMA wal_checkpoint(TRUNCATE);");
|
||||
ToolkitLogger.Log("SQLiteConnector", "WAL checkpoint completed.");
|
||||
}
|
||||
catch (Exception walEx)
|
||||
{
|
||||
ToolkitLogger.LogWarning("SQLiteConnector", $" WAL checkpoint failed: {walEx.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
connection?.Close();
|
||||
connection?.Dispose();
|
||||
connection = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 연결 테스트
|
||||
/// </summary>
|
||||
public async UniTask<bool> TestConnectionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!isConnected || connection == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 간단한 쿼리로 연결 테스트
|
||||
await UniTask.RunOnThreadPool(() =>
|
||||
{
|
||||
connection.ExecuteScalar<int>("SELECT 1;");
|
||||
}, cancellationToken: cancellationToken);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("SQLiteConnector", $" Connection test failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Database Operations
|
||||
/// <summary>
|
||||
/// SQL 스크립트 실행 (마이그레이션용)
|
||||
/// </summary>
|
||||
public async UniTask<int> ExecuteScriptAsync(string sql, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!isConnected || connection == null)
|
||||
{
|
||||
throw new InvalidOperationException("Database is not connected.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await UniTask.RunOnThreadPool(() =>
|
||||
{
|
||||
return connection.Execute(sql);
|
||||
}, cancellationToken: cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("SQLiteConnector", $" Execute script failed: {ex.Message}\n{sql}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 단일 값 조회
|
||||
/// </summary>
|
||||
public async UniTask<T> ExecuteScalarAsync<T>(string sql, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!isConnected || connection == null)
|
||||
{
|
||||
throw new InvalidOperationException("Database is not connected.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await UniTask.RunOnThreadPool(() =>
|
||||
{
|
||||
return connection.ExecuteScalar<T>(sql);
|
||||
}, cancellationToken: cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("SQLiteConnector", $" Execute scalar failed: {ex.Message}\n{sql}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 데이터베이스 파일 존재 여부 확인
|
||||
/// </summary>
|
||||
public bool DatabaseFileExists()
|
||||
{
|
||||
return System.IO.File.Exists(config.DatabaseFilePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SQLite 버전 조회
|
||||
/// </summary>
|
||||
public async UniTask<string> GetDatabaseVersionAsync()
|
||||
{
|
||||
if (!isConnected || connection == null)
|
||||
{
|
||||
return "Not Connected";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await UniTask.RunOnThreadPool(() =>
|
||||
{
|
||||
var result = connection.ExecuteScalar<string>("SELECT sqlite_version();");
|
||||
return $"SQLite {result}";
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("SQLiteConnector", $" Failed to get version: {ex.Message}");
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Result Struct
|
||||
/// <summary>
|
||||
/// 연결 결과
|
||||
/// </summary>
|
||||
public struct ConnectionResult
|
||||
{
|
||||
public bool Success;
|
||||
public string Message;
|
||||
public string ErrorMessage;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ad42d3641d3371b4d890f2ebed149635
|
||||
8
skills/assets/unity-package/Editor/Database/Setup.meta
Normal file
8
skills/assets/unity-package/Editor/Database/Setup.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b8dc1710d9b974e47a258b98c60eb5a6
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,182 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
namespace UnityEditorToolkit.Editor.Database.Setup
|
||||
{
|
||||
/// <summary>
|
||||
/// SQLite 데이터베이스 자동 생성
|
||||
/// 파일 기반 DB이므로 설치 불필요
|
||||
/// </summary>
|
||||
public class DatabaseCreator
|
||||
{
|
||||
/// <summary>
|
||||
/// 데이터베이스 파일 생성 및 확인
|
||||
/// </summary>
|
||||
public async UniTask<DatabaseCreationResult> CreateDatabaseAsync(DatabaseConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseCreator", $" 데이터베이스 확인 시작: {config.DatabaseFilePath}");
|
||||
|
||||
// 1. 데이터베이스 파일 존재 여부 확인
|
||||
if (File.Exists(config.DatabaseFilePath))
|
||||
{
|
||||
long fileSize = new FileInfo(config.DatabaseFilePath).Length;
|
||||
ToolkitLogger.Log("DatabaseCreator", $" 데이터베이스 파일이 이미 존재합니다: {config.DatabaseFilePath} ({fileSize} bytes)");
|
||||
|
||||
return new DatabaseCreationResult
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Database file already exists: {config.DatabaseFilePath}",
|
||||
AlreadyExists = true
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 디렉토리 생성 (존재하지 않으면)
|
||||
string directory = Path.GetDirectoryName(config.DatabaseFilePath);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseCreator", $" 디렉토리 생성: {directory}");
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
// 3. SQLite 파일은 첫 연결 시 자동 생성됨
|
||||
// 여기서는 빈 파일을 생성하지 않고, 연결 시 자동 생성되도록 함
|
||||
ToolkitLogger.Log("DatabaseCreator", $" 데이터베이스 준비 완료: {config.DatabaseFilePath}");
|
||||
ToolkitLogger.Log("DatabaseCreator", "첫 연결 시 자동으로 생성됩니다.");
|
||||
|
||||
await UniTask.Yield(cancellationToken);
|
||||
|
||||
return new DatabaseCreationResult
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Database ready to be created: {config.DatabaseFilePath}",
|
||||
AlreadyExists = false
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("DatabaseCreator", $" 예외 발생: {ex.Message}\n{ex.StackTrace}");
|
||||
return new DatabaseCreationResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = ex.Message,
|
||||
AlreadyExists = false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 데이터베이스 파일 삭제 (개발/테스트용)
|
||||
/// </summary>
|
||||
public async UniTask<bool> DeleteDatabaseAsync(DatabaseConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(config.DatabaseFilePath))
|
||||
{
|
||||
ToolkitLogger.LogWarning("DatabaseCreator", $" 데이터베이스 파일이 존재하지 않습니다: {config.DatabaseFilePath}");
|
||||
return true;
|
||||
}
|
||||
|
||||
ToolkitLogger.Log("DatabaseCreator", $" 데이터베이스 삭제: {config.DatabaseFilePath}");
|
||||
File.Delete(config.DatabaseFilePath);
|
||||
|
||||
// WAL 파일도 삭제 (있으면)
|
||||
string walFile = config.DatabaseFilePath + "-wal";
|
||||
string shmFile = config.DatabaseFilePath + "-shm";
|
||||
|
||||
if (File.Exists(walFile))
|
||||
{
|
||||
File.Delete(walFile);
|
||||
ToolkitLogger.Log("DatabaseCreator", $" WAL 파일 삭제: {walFile}");
|
||||
}
|
||||
|
||||
if (File.Exists(shmFile))
|
||||
{
|
||||
File.Delete(shmFile);
|
||||
ToolkitLogger.Log("DatabaseCreator", $" SHM 파일 삭제: {shmFile}");
|
||||
}
|
||||
|
||||
await UniTask.Yield(cancellationToken);
|
||||
|
||||
ToolkitLogger.Log("DatabaseCreator", "데이터베이스 삭제 완료.");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("DatabaseCreator", $" 삭제 실패: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 데이터베이스 파일 정보 조회
|
||||
/// </summary>
|
||||
public DatabaseFileInfo GetDatabaseInfo(DatabaseConfig config)
|
||||
{
|
||||
if (!File.Exists(config.DatabaseFilePath))
|
||||
{
|
||||
return new DatabaseFileInfo
|
||||
{
|
||||
Exists = false,
|
||||
FilePath = config.DatabaseFilePath
|
||||
};
|
||||
}
|
||||
|
||||
var fileInfo = new FileInfo(config.DatabaseFilePath);
|
||||
|
||||
return new DatabaseFileInfo
|
||||
{
|
||||
Exists = true,
|
||||
FilePath = config.DatabaseFilePath,
|
||||
FileSize = fileInfo.Length,
|
||||
CreatedTime = fileInfo.CreationTime,
|
||||
ModifiedTime = fileInfo.LastWriteTime
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#region Result Structs
|
||||
/// <summary>
|
||||
/// 데이터베이스 생성 결과
|
||||
/// </summary>
|
||||
public struct DatabaseCreationResult
|
||||
{
|
||||
public bool Success;
|
||||
public string Message;
|
||||
public string ErrorMessage;
|
||||
public bool AlreadyExists;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 데이터베이스 파일 정보
|
||||
/// </summary>
|
||||
public struct DatabaseFileInfo
|
||||
{
|
||||
public bool Exists;
|
||||
public string FilePath;
|
||||
public long FileSize;
|
||||
public DateTime CreatedTime;
|
||||
public DateTime ModifiedTime;
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
if (!Exists)
|
||||
{
|
||||
return $"[DatabaseFileInfo] File does not exist: {FilePath}";
|
||||
}
|
||||
|
||||
return $"[DatabaseFileInfo]\n" +
|
||||
$" Path: {FilePath}\n" +
|
||||
$" Size: {FileSize / 1024.0:F2} KB\n" +
|
||||
$" Created: {CreatedTime}\n" +
|
||||
$" Modified: {ModifiedTime}";
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dea57a163338a004098266d197d522dd
|
||||
@@ -0,0 +1,222 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
namespace UnityEditorToolkit.Editor.Database.Setup
|
||||
{
|
||||
/// <summary>
|
||||
/// Database 원클릭 자동 설치 마법사
|
||||
/// SQLite - 설치 불필요, DB 파일 생성 → 마이그레이션
|
||||
/// </summary>
|
||||
public class DatabaseSetupWizard
|
||||
{
|
||||
#region Setup Steps
|
||||
public enum SetupStep
|
||||
{
|
||||
NotStarted,
|
||||
PreparingDatabase,
|
||||
RunningMigrations,
|
||||
Completed,
|
||||
Failed
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region State
|
||||
private SetupStep currentStep = SetupStep.NotStarted;
|
||||
private string statusMessage = "";
|
||||
private string errorMessage = "";
|
||||
private bool isRunning = false;
|
||||
private float progress = 0f;
|
||||
|
||||
public SetupStep CurrentStep => currentStep;
|
||||
public string StatusMessage => statusMessage;
|
||||
public string ErrorMessage => errorMessage;
|
||||
public bool IsRunning => isRunning;
|
||||
public float Progress => progress;
|
||||
#endregion
|
||||
|
||||
#region Dependencies
|
||||
private readonly DatabaseCreator databaseCreator;
|
||||
#endregion
|
||||
|
||||
#region Constructor
|
||||
public DatabaseSetupWizard()
|
||||
{
|
||||
databaseCreator = new DatabaseCreator();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Main Setup Flow
|
||||
/// <summary>
|
||||
/// 자동 설치 시작
|
||||
/// </summary>
|
||||
public async UniTask<SetupResult> RunSetupAsync(DatabaseConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (isRunning)
|
||||
{
|
||||
return new SetupResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = "Setup already in progress."
|
||||
};
|
||||
}
|
||||
|
||||
isRunning = true;
|
||||
currentStep = SetupStep.NotStarted;
|
||||
errorMessage = "";
|
||||
progress = 0f;
|
||||
|
||||
try
|
||||
{
|
||||
// Step 1: 데이터베이스 파일 준비
|
||||
if (!await PrepareDatabaseAsync(config, cancellationToken))
|
||||
{
|
||||
return CreateFailureResult("데이터베이스 준비 실패.");
|
||||
}
|
||||
|
||||
// Step 2: 마이그레이션 실행
|
||||
if (!await RunMigrationsAsync(config, cancellationToken))
|
||||
{
|
||||
return CreateFailureResult("마이그레이션 실행 실패.");
|
||||
}
|
||||
|
||||
// 완료
|
||||
currentStep = SetupStep.Completed;
|
||||
statusMessage = "데이터베이스 설치 완료!";
|
||||
progress = 1f;
|
||||
|
||||
ToolkitLogger.Log("DatabaseSetupWizard", "설치 완료!");
|
||||
|
||||
return new SetupResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Database setup completed successfully!"
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
ToolkitLogger.LogWarning("DatabaseSetupWizard", "설치가 취소되었습니다.");
|
||||
currentStep = SetupStep.Failed;
|
||||
return CreateFailureResult("설치가 취소되었습니다.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("DatabaseSetupWizard", $" 설치 중 예외 발생: {ex.Message}");
|
||||
currentStep = SetupStep.Failed;
|
||||
return CreateFailureResult($"예외 발생: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
isRunning = false;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Step Implementations
|
||||
private async UniTask<bool> PrepareDatabaseAsync(DatabaseConfig config, CancellationToken cancellationToken)
|
||||
{
|
||||
currentStep = SetupStep.PreparingDatabase;
|
||||
statusMessage = "데이터베이스 파일 준비 중...";
|
||||
progress = 0.3f;
|
||||
|
||||
ToolkitLogger.Log("DatabaseSetupWizard", "데이터베이스 파일 준비 중...");
|
||||
|
||||
var result = await databaseCreator.CreateDatabaseAsync(config, cancellationToken);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
if (result.AlreadyExists)
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseSetupWizard", $" 데이터베이스 파일이 이미 존재합니다: {config.DatabaseFilePath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
ToolkitLogger.Log("DatabaseSetupWizard", $" 데이터베이스 파일 준비 완료: {config.DatabaseFilePath}");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
errorMessage = $"데이터베이스 준비 실패: {result.ErrorMessage}";
|
||||
ToolkitLogger.LogError("DatabaseSetupWizard", $" {errorMessage}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async UniTask<bool> RunMigrationsAsync(DatabaseConfig config, CancellationToken cancellationToken)
|
||||
{
|
||||
currentStep = SetupStep.RunningMigrations;
|
||||
statusMessage = "마이그레이션 실행 중...";
|
||||
progress = 0.6f;
|
||||
|
||||
ToolkitLogger.Log("DatabaseSetupWizard", "마이그레이션 시작...");
|
||||
|
||||
try
|
||||
{
|
||||
// DatabaseManager 초기화 (InitializeAsync가 자동으로 마이그레이션도 실행함)
|
||||
var initResult = await DatabaseManager.Instance.InitializeAsync(config);
|
||||
if (!initResult.Success)
|
||||
{
|
||||
errorMessage = $"DatabaseManager 초기화 실패: {initResult.ErrorMessage}";
|
||||
ToolkitLogger.LogError("DatabaseSetupWizard", $" {errorMessage}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 마이그레이션은 InitializeAsync에서 자동으로 실행됨
|
||||
// 중복 실행을 방지하기 위해 별도 호출하지 않음
|
||||
ToolkitLogger.Log("DatabaseSetupWizard", "마이그레이션 완료 (DatabaseManager.InitializeAsync에서 실행됨)");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"마이그레이션 중 예외 발생: {ex.Message}";
|
||||
ToolkitLogger.LogError("DatabaseSetupWizard", $" {errorMessage}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
private SetupResult CreateFailureResult(string error)
|
||||
{
|
||||
currentStep = SetupStep.Failed;
|
||||
errorMessage = error;
|
||||
progress = 0f;
|
||||
|
||||
return new SetupResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = error
|
||||
};
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Reset
|
||||
/// <summary>
|
||||
/// 상태 초기화
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
currentStep = SetupStep.NotStarted;
|
||||
statusMessage = "";
|
||||
errorMessage = "";
|
||||
isRunning = false;
|
||||
progress = 0f;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Result Struct
|
||||
/// <summary>
|
||||
/// Setup 결과
|
||||
/// </summary>
|
||||
public struct SetupResult
|
||||
{
|
||||
public bool Success;
|
||||
public string Message;
|
||||
public string ErrorMessage;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 64255a616a004d548b7250bb0302633f
|
||||
866
skills/assets/unity-package/Editor/Database/SyncManager.cs
Normal file
866
skills/assets/unity-package/Editor/Database/SyncManager.cs
Normal file
@@ -0,0 +1,866 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEditorToolkit.Runtime;
|
||||
using UnityEditorToolkit.Editor.Utils;
|
||||
|
||||
namespace UnityEditorToolkit.Editor.Database
|
||||
{
|
||||
/// <summary>
|
||||
/// Unity ↔ PostgreSQL 실시간 동기화 관리자
|
||||
/// Phase 1: 기본 동기화 프레임워크
|
||||
/// Phase 2+: GameObject/Component 실시간 추적, 배치 업데이트
|
||||
/// </summary>
|
||||
public class SyncManager : IDisposable
|
||||
{
|
||||
#region Fields
|
||||
private readonly DatabaseManager databaseManager;
|
||||
private bool isRunning = false;
|
||||
private bool isDisposed = false;
|
||||
private CancellationTokenSource syncCts;
|
||||
|
||||
// 동기화 설정
|
||||
private const int SyncIntervalMilliseconds = 1000; // 1초마다 동기화
|
||||
private const int BatchSize = 500; // 배치당 최대 500개 객체
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>
|
||||
/// 동기화 실행 중 여부
|
||||
/// </summary>
|
||||
public bool IsRunning => isRunning;
|
||||
|
||||
/// <summary>
|
||||
/// 마지막 동기화 시간
|
||||
/// </summary>
|
||||
public DateTime LastSyncTime { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 동기화 성공 횟수
|
||||
/// </summary>
|
||||
public int SuccessfulSyncCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 동기화 실패 횟수
|
||||
/// </summary>
|
||||
public int FailedSyncCount { get; private set; }
|
||||
#endregion
|
||||
|
||||
#region Constructor
|
||||
public SyncManager(DatabaseManager databaseManager)
|
||||
{
|
||||
this.databaseManager = databaseManager ?? throw new ArgumentNullException(nameof(databaseManager));
|
||||
LastSyncTime = DateTime.MinValue;
|
||||
SuccessfulSyncCount = 0;
|
||||
FailedSyncCount = 0;
|
||||
|
||||
ToolkitLogger.Log("SyncManager", "생성 완료.");
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Sync Control
|
||||
/// <summary>
|
||||
/// 동기화 시작
|
||||
/// </summary>
|
||||
public void StartSync()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (isRunning)
|
||||
{
|
||||
ToolkitLogger.LogWarning("SyncManager", "이미 동기화가 실행 중입니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!databaseManager.IsInitialized || !databaseManager.IsConnected)
|
||||
{
|
||||
ToolkitLogger.LogError("SyncManager", "DatabaseManager가 초기화되지 않았거나 연결되지 않았습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
ToolkitLogger.Log("SyncManager", "동기화 시작...");
|
||||
|
||||
syncCts = new CancellationTokenSource();
|
||||
isRunning = true;
|
||||
|
||||
// 백그라운드 동기화 루프 시작 (UniTask)
|
||||
RunSyncLoopAsync(syncCts.Token).Forget();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 동기화 중지
|
||||
/// </summary>
|
||||
public void StopSync()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (!isRunning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ToolkitLogger.Log("SyncManager", "동기화 중지 중...");
|
||||
|
||||
syncCts?.Cancel();
|
||||
syncCts?.Dispose();
|
||||
syncCts = null;
|
||||
|
||||
isRunning = false;
|
||||
|
||||
ToolkitLogger.Log("SyncManager", "동기화 중지 완료.");
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Sync Loop
|
||||
/// <summary>
|
||||
/// 백그라운드 동기화 루프 (UniTask)
|
||||
/// </summary>
|
||||
private async UniTaskVoid RunSyncLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ToolkitLogger.Log("SyncManager", "동기화 루프 시작.");
|
||||
|
||||
try
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// 동기화 수행
|
||||
await PerformSyncAsync(cancellationToken);
|
||||
|
||||
// 대기 (1초)
|
||||
await UniTask.Delay(SyncIntervalMilliseconds, cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
ToolkitLogger.Log("SyncManager", "동기화 루프가 취소되었습니다.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("SyncManager", $" 동기화 루프 중 예외 발생: {ex.Message}\n{ex.StackTrace}");
|
||||
isRunning = false;
|
||||
}
|
||||
|
||||
ToolkitLogger.Log("SyncManager", "동기화 루프 종료.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 단일 동기화 수행 (모든 로드된 씬)
|
||||
/// </summary>
|
||||
private async UniTask PerformSyncAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Phase 1: 단순 연결 테스트만 수행
|
||||
// Phase 2: GameObject 변경 감지 및 배치 업데이트
|
||||
|
||||
bool isConnected = await databaseManager.TestConnectionAsync();
|
||||
if (!isConnected)
|
||||
{
|
||||
ToolkitLogger.LogWarning("SyncManager", "데이터베이스 연결이 끊어졌습니다.");
|
||||
FailedSyncCount++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 2: 모든 로드된 씬에 대해 GameObject 변경 감지 및 배치 업데이트
|
||||
int sceneCount = SceneManager.sceneCount;
|
||||
if (sceneCount == 0)
|
||||
{
|
||||
ToolkitLogger.LogWarning("SyncManager", "로드된 씬이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 모든 로드된 씬 순회
|
||||
for (int i = 0; i < sceneCount; i++)
|
||||
{
|
||||
var scene = SceneManager.GetSceneAt(i);
|
||||
|
||||
// 로드되지 않은 씬은 건너뛰기
|
||||
if (!scene.isLoaded)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 1. Unity Scene에서 모든 GameObject 수집
|
||||
var allGameObjects = CollectAllGameObjects(scene);
|
||||
if (allGameObjects.Count == 0)
|
||||
{
|
||||
continue; // 빈 씬은 건너뛰기
|
||||
}
|
||||
|
||||
// 2. DB에서 현재 씬의 GameObject 목록 가져오기
|
||||
Dictionary<string, DbGameObject> dbGameObjects;
|
||||
try
|
||||
{
|
||||
dbGameObjects = await GetDatabaseGameObjectsAsync(scene, cancellationToken);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
ToolkitLogger.LogError("SyncManager", $" DB 연결 실패로 씬 '{scene.name}' 동기화 건너뜀: {ex.Message}");
|
||||
continue; // 이 씬은 건너뛰고 다음 씬 처리
|
||||
}
|
||||
|
||||
// 3. 변경 감지
|
||||
var changes = DetectChanges(allGameObjects, dbGameObjects);
|
||||
|
||||
// 4. 배치 업데이트 실행
|
||||
if (changes.Updated.Count > 0)
|
||||
{
|
||||
await BatchUpdateGameObjectsAsync(changes.Updated, cancellationToken);
|
||||
}
|
||||
|
||||
if (changes.Inserted.Count > 0)
|
||||
{
|
||||
await BatchInsertGameObjectsAsync(scene, changes.Inserted, cancellationToken);
|
||||
}
|
||||
|
||||
if (changes.Deleted.Count > 0)
|
||||
{
|
||||
await BatchMarkDeletedAsync(changes.Deleted, cancellationToken);
|
||||
}
|
||||
|
||||
// 취소 확인
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
}
|
||||
|
||||
LastSyncTime = DateTime.UtcNow;
|
||||
SuccessfulSyncCount++;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw; // 취소는 상위로 전파
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("SyncManager", $" 동기화 중 예외 발생: {ex.Message}");
|
||||
FailedSyncCount++;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Manual Sync
|
||||
/// <summary>
|
||||
/// 수동 동기화 (즉시 실행)
|
||||
/// </summary>
|
||||
public async UniTask<SyncResult> SyncNowAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (!databaseManager.IsInitialized || !databaseManager.IsConnected)
|
||||
{
|
||||
return new SyncResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = "Database not initialized or not connected."
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ToolkitLogger.Log("SyncManager", "수동 동기화 시작...");
|
||||
|
||||
await PerformSyncAsync(cancellationToken);
|
||||
|
||||
ToolkitLogger.Log("SyncManager", "수동 동기화 완료.");
|
||||
return new SyncResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Manual sync completed successfully."
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
ToolkitLogger.LogWarning("SyncManager", "수동 동기화가 취소되었습니다.");
|
||||
return new SyncResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = "Manual sync was canceled."
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToolkitLogger.LogError("SyncManager", $" 수동 동기화 중 예외 발생: {ex.Message}");
|
||||
return new SyncResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Batch Operations (Phase 2+)
|
||||
/// <summary>
|
||||
/// GameObject 배치 업데이트
|
||||
/// </summary>
|
||||
/// <param name="gameObjects">업데이트할 GameObject 목록</param>
|
||||
public async UniTask<int> BatchUpdateGameObjectsAsync(List<GameObject> gameObjects, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (gameObjects == null || gameObjects.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
await UniTask.SwitchToThreadPool();
|
||||
|
||||
try
|
||||
{
|
||||
var connection = databaseManager.Connector?.Connection;
|
||||
if (connection == null)
|
||||
{
|
||||
ToolkitLogger.LogError("SyncManager", "Database connection is null");
|
||||
return 0;
|
||||
}
|
||||
|
||||
int updatedCount = 0;
|
||||
|
||||
// 배치 크기로 나누어 처리
|
||||
for (int i = 0; i < gameObjects.Count; i += BatchSize)
|
||||
{
|
||||
int batchCount = Math.Min(BatchSize, gameObjects.Count - i);
|
||||
var batch = gameObjects.GetRange(i, batchCount);
|
||||
|
||||
ExecuteInTransaction(connection, () =>
|
||||
{
|
||||
foreach (var obj in batch)
|
||||
{
|
||||
var guidComp = EnsureGameObjectGuid(obj);
|
||||
string guid = guidComp.Guid;
|
||||
int instanceId = obj.GetInstanceID();
|
||||
int? parentId = obj.transform.parent != null ? obj.transform.parent.gameObject.GetInstanceID() : (int?)null;
|
||||
|
||||
var sql = @"
|
||||
UPDATE gameobjects
|
||||
SET object_name = ?,
|
||||
parent_id = ?,
|
||||
tag = ?,
|
||||
layer = ?,
|
||||
is_active = ?,
|
||||
is_static = ?,
|
||||
instance_id = ?,
|
||||
updated_at = datetime('now', 'localtime')
|
||||
WHERE guid = ?
|
||||
";
|
||||
|
||||
connection.Execute(sql, obj.name, parentId, obj.tag, obj.layer, obj.activeSelf, obj.isStatic, instanceId, guid);
|
||||
}
|
||||
});
|
||||
|
||||
updatedCount += batchCount;
|
||||
ToolkitLogger.Log("SyncManager", $" 배치 업데이트 완료: {batchCount}개 GameObject");
|
||||
|
||||
// 취소 확인
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
}
|
||||
|
||||
return updatedCount;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 메인 스레드가 살아있는지 확인
|
||||
if (!isDisposed)
|
||||
{
|
||||
await UniTask.SwitchToMainThread();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Component 배치 업데이트 (Phase 2에서 구현)
|
||||
/// </summary>
|
||||
public async UniTask<int> BatchUpdateComponentsAsync(List<Component> components, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (components == null || components.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// TODO Phase 2: Component 배치 업데이트 구현
|
||||
|
||||
await UniTask.Yield();
|
||||
return 0;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Helper Methods (Phase 2)
|
||||
/// <summary>
|
||||
/// 트랜잭션을 안전하게 실행하는 헬퍼 메서드
|
||||
/// </summary>
|
||||
private void ExecuteInTransaction(SQLite.SQLiteConnection connection, Action action)
|
||||
{
|
||||
// SQLite는 중첩 트랜잭션을 지원하지 않으므로 try-catch로 감지
|
||||
bool transactionStarted = false;
|
||||
try
|
||||
{
|
||||
connection.BeginTransaction();
|
||||
transactionStarted = true;
|
||||
|
||||
action();
|
||||
|
||||
connection.Commit();
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("transaction") || ex.Message.Contains("Transaction"))
|
||||
{
|
||||
// 이미 트랜잭션이 시작된 경우, 그냥 액션만 실행
|
||||
ToolkitLogger.LogWarning("SyncManager", $" Transaction already started, executing without nested transaction: {ex.Message}");
|
||||
if (transactionStarted)
|
||||
{
|
||||
connection.Rollback();
|
||||
}
|
||||
action();
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (transactionStarted)
|
||||
{
|
||||
connection.Rollback();
|
||||
}
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unity Scene에서 모든 GameObject 재귀적으로 수집
|
||||
/// </summary>
|
||||
private List<GameObject> CollectAllGameObjects(Scene scene)
|
||||
{
|
||||
var result = new List<GameObject>();
|
||||
var rootObjects = scene.GetRootGameObjects();
|
||||
|
||||
foreach (var root in rootObjects)
|
||||
{
|
||||
CollectGameObjectsRecursive(root, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GameObject를 재귀적으로 수집하는 헬퍼 메서드
|
||||
/// </summary>
|
||||
private void CollectGameObjectsRecursive(GameObject obj, List<GameObject> list)
|
||||
{
|
||||
list.Add(obj);
|
||||
for (int i = 0; i < obj.transform.childCount; i++)
|
||||
{
|
||||
CollectGameObjectsRecursive(obj.transform.GetChild(i).gameObject, list);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DB에서 현재 씬의 GameObject 목록 가져오기 (GUID 기반)
|
||||
/// </summary>
|
||||
private async UniTask<Dictionary<string, DbGameObject>> GetDatabaseGameObjectsAsync(Scene scene, CancellationToken cancellationToken)
|
||||
{
|
||||
await UniTask.SwitchToThreadPool();
|
||||
|
||||
try
|
||||
{
|
||||
var connection = databaseManager.Connector?.Connection;
|
||||
if (connection == null)
|
||||
{
|
||||
ToolkitLogger.LogError("SyncManager", "Database connection is null");
|
||||
throw new InvalidOperationException("Database connection is not available");
|
||||
}
|
||||
|
||||
// 1. scene_id 가져오기
|
||||
var sceneIdSql = "SELECT scene_id FROM scenes WHERE scene_path = ?";
|
||||
var sceneIds = connection.Query<SceneIdRecord>(sceneIdSql, scene.path);
|
||||
|
||||
if (!sceneIds.Any())
|
||||
{
|
||||
// Scene이 DB에 없는 것은 정상 (첫 동기화)
|
||||
ToolkitLogger.Log("SyncManager", $" Scene '{scene.name}'이 DB에 없습니다 (첫 동기화).");
|
||||
return new Dictionary<string, DbGameObject>();
|
||||
}
|
||||
|
||||
int sceneId = sceneIds.First().scene_id;
|
||||
|
||||
// 2. GameObject 목록 가져오기 (guid 포함)
|
||||
var sql = @"
|
||||
SELECT object_id, instance_id, guid, object_name, parent_id, tag, layer, is_active, is_static, is_deleted
|
||||
FROM gameobjects
|
||||
WHERE scene_id = ? AND is_deleted = 0
|
||||
";
|
||||
|
||||
var dbObjects = connection.Query<DbGameObject>(sql, sceneId);
|
||||
|
||||
// Dictionary로 변환 (guid를 키로 사용, guid가 null인 경우 instance_id를 fallback으로 사용)
|
||||
var result = new Dictionary<string, DbGameObject>();
|
||||
foreach (var obj in dbObjects)
|
||||
{
|
||||
string key = !string.IsNullOrEmpty(obj.guid) ? obj.guid : $"instance_{obj.instance_id}";
|
||||
result[key] = obj;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 메인 스레드가 살아있는지 확인
|
||||
if (!isDisposed)
|
||||
{
|
||||
await UniTask.SwitchToMainThread();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scene ID를 가져오기 위한 레코드 클래스
|
||||
/// </summary>
|
||||
private class SceneIdRecord
|
||||
{
|
||||
public int scene_id { get; set; }
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Change Detection (Phase 2)
|
||||
/// <summary>
|
||||
/// GameObject에 GameObjectGuid 컴포넌트 확보 (없으면 추가)
|
||||
/// </summary>
|
||||
private GameObjectGuid EnsureGameObjectGuid(GameObject obj)
|
||||
{
|
||||
var guidComp = obj.GetComponent<GameObjectGuid>();
|
||||
if (guidComp == null)
|
||||
{
|
||||
guidComp = obj.AddComponent<GameObjectGuid>();
|
||||
}
|
||||
return guidComp;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unity GameObject와 DB GameObject 비교하여 변경사항 감지 (GUID 기반)
|
||||
/// </summary>
|
||||
private GameObjectChangeSet DetectChanges(List<GameObject> unityObjects, Dictionary<string, DbGameObject> dbObjects)
|
||||
{
|
||||
var changeSet = new GameObjectChangeSet
|
||||
{
|
||||
Updated = new List<GameObject>(),
|
||||
Inserted = new List<GameObject>(),
|
||||
Deleted = new List<int>()
|
||||
};
|
||||
|
||||
// Unity에 있는 객체 확인 (GUID 기반)
|
||||
var processedGuids = new HashSet<string>();
|
||||
|
||||
foreach (var obj in unityObjects)
|
||||
{
|
||||
// GameObjectGuid 컴포넌트 확보
|
||||
var guidComp = EnsureGameObjectGuid(obj);
|
||||
string guid = guidComp.Guid;
|
||||
|
||||
processedGuids.Add(guid);
|
||||
|
||||
if (dbObjects.TryGetValue(guid, out var dbObj))
|
||||
{
|
||||
// DB에 존재: 변경 여부 확인
|
||||
if (HasChanged(obj, dbObj))
|
||||
{
|
||||
changeSet.Updated.Add(obj);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// DB에 없음: 새로운 객체
|
||||
changeSet.Inserted.Add(obj);
|
||||
}
|
||||
}
|
||||
|
||||
// DB에만 있고 Unity에 없는 객체 확인 (삭제된 객체)
|
||||
foreach (var kvp in dbObjects)
|
||||
{
|
||||
if (!processedGuids.Contains(kvp.Key))
|
||||
{
|
||||
changeSet.Deleted.Add(kvp.Value.object_id);
|
||||
}
|
||||
}
|
||||
|
||||
return changeSet;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GameObject가 DB 레코드와 비교하여 변경되었는지 확인
|
||||
/// </summary>
|
||||
private bool HasChanged(GameObject obj, DbGameObject dbObj)
|
||||
{
|
||||
// 이름 변경
|
||||
if (obj.name != dbObj.object_name)
|
||||
return true;
|
||||
|
||||
// Parent 변경
|
||||
int? currentParentId = obj.transform.parent != null ? obj.transform.parent.gameObject.GetInstanceID() : (int?)null;
|
||||
if (currentParentId != dbObj.parent_id)
|
||||
return true;
|
||||
|
||||
// Tag 변경
|
||||
if (obj.tag != dbObj.tag)
|
||||
return true;
|
||||
|
||||
// Layer 변경
|
||||
if (obj.layer != dbObj.layer)
|
||||
return true;
|
||||
|
||||
// Active 상태 변경
|
||||
if (obj.activeSelf != dbObj.is_active)
|
||||
return true;
|
||||
|
||||
// Static 플래그 변경
|
||||
if (obj.isStatic != dbObj.is_static)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Batch Insert/Delete (Phase 2)
|
||||
/// <summary>
|
||||
/// GameObject 배치 삽입
|
||||
/// </summary>
|
||||
private async UniTask BatchInsertGameObjectsAsync(Scene scene, List<GameObject> gameObjects, CancellationToken cancellationToken)
|
||||
{
|
||||
if (gameObjects == null || gameObjects.Count == 0)
|
||||
return;
|
||||
|
||||
await UniTask.SwitchToThreadPool();
|
||||
|
||||
try
|
||||
{
|
||||
var connection = databaseManager.Connector?.Connection;
|
||||
if (connection == null)
|
||||
{
|
||||
ToolkitLogger.LogError("SyncManager", "Database connection is null");
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. scene_id 가져오기 (또는 생성)
|
||||
int sceneId = EnsureSceneRecord(connection, scene);
|
||||
|
||||
// 2. 배치 INSERT
|
||||
for (int i = 0; i < gameObjects.Count; i += BatchSize)
|
||||
{
|
||||
int batchCount = Math.Min(BatchSize, gameObjects.Count - i);
|
||||
var batch = gameObjects.GetRange(i, batchCount);
|
||||
|
||||
ExecuteInTransaction(connection, () =>
|
||||
{
|
||||
foreach (var obj in batch)
|
||||
{
|
||||
var guidComp = EnsureGameObjectGuid(obj);
|
||||
string guid = guidComp.Guid;
|
||||
int instanceId = obj.GetInstanceID();
|
||||
int? parentId = obj.transform.parent != null ? obj.transform.parent.gameObject.GetInstanceID() : (int?)null;
|
||||
|
||||
var sql = @"
|
||||
INSERT INTO gameobjects (guid, instance_id, scene_id, object_name, parent_id, tag, layer, is_active, is_static, is_deleted, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, datetime('now', 'localtime'), datetime('now', 'localtime'))
|
||||
";
|
||||
|
||||
connection.Execute(sql, guid, instanceId, sceneId, obj.name, parentId, obj.tag, obj.layer, obj.activeSelf, obj.isStatic);
|
||||
}
|
||||
});
|
||||
|
||||
ToolkitLogger.Log("SyncManager", $" 배치 삽입 완료: {batchCount}개 GameObject");
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 메인 스레드가 살아있는지 확인
|
||||
if (!isDisposed)
|
||||
{
|
||||
await UniTask.SwitchToMainThread();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GameObject 배치 삭제 (soft delete)
|
||||
/// </summary>
|
||||
private async UniTask BatchMarkDeletedAsync(List<int> objectIds, CancellationToken cancellationToken)
|
||||
{
|
||||
if (objectIds == null || objectIds.Count == 0)
|
||||
return;
|
||||
|
||||
await UniTask.SwitchToThreadPool();
|
||||
|
||||
try
|
||||
{
|
||||
var connection = databaseManager.Connector?.Connection;
|
||||
if (connection == null)
|
||||
{
|
||||
ToolkitLogger.LogError("SyncManager", "Database connection is null");
|
||||
return;
|
||||
}
|
||||
|
||||
// 배치 UPDATE (soft delete)
|
||||
for (int i = 0; i < objectIds.Count; i += BatchSize)
|
||||
{
|
||||
int batchCount = Math.Min(BatchSize, objectIds.Count - i);
|
||||
var batch = objectIds.GetRange(i, batchCount);
|
||||
|
||||
ExecuteInTransaction(connection, () =>
|
||||
{
|
||||
foreach (var objectId in batch)
|
||||
{
|
||||
var sql = @"
|
||||
UPDATE gameobjects
|
||||
SET is_deleted = 1, updated_at = datetime('now', 'localtime')
|
||||
WHERE object_id = ?
|
||||
";
|
||||
|
||||
connection.Execute(sql, objectId);
|
||||
}
|
||||
});
|
||||
|
||||
ToolkitLogger.Log("SyncManager", $" 배치 삭제 완료: {batchCount}개 GameObject");
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 메인 스레드가 살아있는지 확인
|
||||
if (!isDisposed)
|
||||
{
|
||||
await UniTask.SwitchToMainThread();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scene 레코드가 DB에 존재하는지 확인하고 없으면 생성
|
||||
/// </summary>
|
||||
private int EnsureSceneRecord(SQLite.SQLiteConnection connection, Scene scene)
|
||||
{
|
||||
var sceneIdSql = "SELECT scene_id FROM scenes WHERE scene_path = ?";
|
||||
var sceneIds = connection.Query<SceneIdRecord>(sceneIdSql, scene.path);
|
||||
|
||||
if (sceneIds.Any())
|
||||
{
|
||||
return sceneIds.First().scene_id;
|
||||
}
|
||||
|
||||
// Scene 레코드 생성
|
||||
var insertSql = @"
|
||||
INSERT INTO scenes (scene_path, scene_name, created_at, updated_at)
|
||||
VALUES (?, ?, datetime('now', 'localtime'), datetime('now', 'localtime'))
|
||||
";
|
||||
connection.Execute(insertSql, scene.path, scene.name);
|
||||
|
||||
// 생성된 scene_id 반환
|
||||
var newSceneIds = connection.Query<SceneIdRecord>(sceneIdSql, scene.path);
|
||||
return newSceneIds.First().scene_id;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Health Check
|
||||
/// <summary>
|
||||
/// SyncManager 상태 정보
|
||||
/// </summary>
|
||||
public SyncHealthStatus GetHealthStatus()
|
||||
{
|
||||
return new SyncHealthStatus
|
||||
{
|
||||
IsRunning = isRunning,
|
||||
LastSyncTime = LastSyncTime,
|
||||
SuccessfulSyncCount = SuccessfulSyncCount,
|
||||
FailedSyncCount = FailedSyncCount,
|
||||
SyncIntervalMs = SyncIntervalMilliseconds,
|
||||
BatchSize = BatchSize
|
||||
};
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Disposal
|
||||
public void Dispose()
|
||||
{
|
||||
if (isDisposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StopSync();
|
||||
isDisposed = true;
|
||||
|
||||
ToolkitLogger.Log("SyncManager", "Disposed.");
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
if (isDisposed)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(SyncManager));
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Result Structs
|
||||
/// <summary>
|
||||
/// 동기화 결과
|
||||
/// </summary>
|
||||
public struct SyncResult
|
||||
{
|
||||
public bool Success;
|
||||
public string Message;
|
||||
public string ErrorMessage;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SyncManager 상태
|
||||
/// </summary>
|
||||
public struct SyncHealthStatus
|
||||
{
|
||||
public bool IsRunning;
|
||||
public DateTime LastSyncTime;
|
||||
public int SuccessfulSyncCount;
|
||||
public int FailedSyncCount;
|
||||
public int SyncIntervalMs;
|
||||
public int BatchSize;
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"[SyncHealthStatus]\n" +
|
||||
$" Running: {IsRunning}\n" +
|
||||
$" Last Sync: {LastSyncTime:yyyy-MM-dd HH:mm:ss}\n" +
|
||||
$" Success: {SuccessfulSyncCount}, Failed: {FailedSyncCount}\n" +
|
||||
$" Interval: {SyncIntervalMs}ms, Batch: {BatchSize}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DB GameObject 레코드 (SQLite-net ORM용)
|
||||
/// </summary>
|
||||
public class DbGameObject
|
||||
{
|
||||
public int object_id { get; set; }
|
||||
public int instance_id { get; set; }
|
||||
public string guid { get; set; }
|
||||
public string object_name { get; set; }
|
||||
public int? parent_id { get; set; }
|
||||
public string tag { get; set; }
|
||||
public int layer { get; set; }
|
||||
public bool is_active { get; set; }
|
||||
public bool is_static { get; set; }
|
||||
public bool is_deleted { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GameObject 변경사항 집합
|
||||
/// </summary>
|
||||
public class GameObjectChangeSet
|
||||
{
|
||||
public List<GameObject> Updated { get; set; }
|
||||
public List<GameObject> Inserted { get; set; }
|
||||
public List<int> Deleted { get; set; }
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 97a6b4ca15448704ab5758aa7642bca3
|
||||
Reference in New Issue
Block a user