SQLite를 사용한 자동 Undo/Redo
SQLite를 사용한 자동 Undo/Redo (Automatic Undo/Redo Using SQLite)
이 문서는 SQLite를 애플리케이션 파일 형식으로 사용하는 애플리케이션을 위해 트리거로 undo/redo 로직을 구현하는 방법을 보여줘요. UNDOLOG 테이블에 변경을 기록하는 트리거를 만들고, 그 기록을 재생해 변경을 되돌리는 방식이에요. 예제 코드는 TCL로 작성됐어요.
출처: 문서
본문
이 페이지는 SQLite를 애플리케이션 파일 형식으로 사용하는 애플리케이션을 위해 트리거를 사용해 undo/redo 로직을 구현하는 방법을 보여줘요.
객체 지향 설계
이 설계 노트는 데이터베이스를 객체의 집합으로 간주해요. 각 SQL 테이블은 클래스이고, 각 행은 그 클래스의 인스턴스예요. 물론 SQL 데이터베이스 스키마를 해석하는 다른 방법도 있고, 여기 설명된 기법은 대체 해석에서도 동일하게 잘 작동하지만, 객체 지향 관점이 대부분의 현대 프로그래머에게 더 자연스러워 보여요.
트리거로 변경 캡처하기
핵심 아이디어는 데이터베이스 변경을 undo/redo하는 데 필요한 정보를 보유하는 특수 테이블(예시에서는 "UNDOLOG"라는 이름)을 만드는 거예요. undo/redo에 참여하려는 데이터베이스의 각 클래스(테이블)에 대해, 참여 클래스의 각 DELETE, INSERT, UPDATE에 대해 UNDOLOG 테이블에 항목이 만들어지게 하는 트리거를 생성해요. UNDOLOG 항목은 변경을 되돌리기 위해 재생할 수 있는 일반적인 SQL 문으로 구성돼요.
예를 들어 다음과 같은 클래스(테이블)에 대해 undo/redo를 원한다고 가정해요:
CREATE TABLE ex1(a,b,c);
EX1 테이블에 대한 변경을 기록하는 트리거는 다음과 같을 수 있어요:
CREATE TEMP TRIGGER ex1_it AFTER INSERT ON ex1 BEGIN
INSERT INTO undolog VALUES(NULL,'DELETE FROM ex1 WHERE rowid='||new.rowid);
END;
CREATE TEMP TRIGGER ex1_ut AFTER UPDATE ON ex1 BEGIN
INSERT INTO undolog VALUES(NULL,'UPDATE ex1
SET a='||quote(old.a)||',b='||quote(old.b)||',c='||quote(old.c)||'
WHERE rowid='||old.rowid);
END;
CREATE TEMP TRIGGER ex1_dt BEFORE DELETE ON ex1 BEGIN
INSERT INTO undolog VALUES(NULL,'INSERT INTO ex1(rowid,a,b,c)
VALUES('||old.rowid||','||quote(old.a)||','||quote(old.b)||
','||quote(old.c)||')');
END;
ex1에 대한 각 INSERT 후에 ex1_it 트리거는 그 INSERT를 되돌릴 DELETE 문의 텍스트를 구성해요. ex1_ut 트리거는 UPDATE의 효과를 되돌릴 UPDATE 문을 구성해요. 그리고 ex1_dt 트리거는 DELETE의 효과를 되돌릴 문을 구성해요.
이 트리거에서 quote() SQL 함수의 사용을 주목하세요. quote() 함수는 인자를 SQL 문에 포함하기에 적절한 형태로 변환해요. 숫자 값은 그대로 나와요. 문자열 앞뒤에 작은따옴표가 추가되고 내부의 작은따옴표는 이스케이프돼요. BLOB 값은 SQL 표준 16진수 BLOB 표기법으로 렌더링돼요. quote() 함수의 사용은 undo와 redo에 사용되는 SQL 문이 항상 SQL 인젝션으로부터 안전함을 보장해요.
트리거의 자동 생성
위와 같은 트리거는 수동으로 입력할 수 있지만 지루해요. 아래에 설명된 기법의 중요한 특징은 트리거가 자동으로 생성된다는 것이에요.
예제 코드의 구현 언어는 TCL이지만, 다른 프로그래밍 언어로도 쉽게 같은 일을 할 수 있어요. 여기 코드는 기법의 시연이지, 모든 것을 자동으로 해줄 drop-in 모듈이 아니라는 점을 기억하세요. 아래에 보인 시연 코드는 실제 사용 중인 코드에서 파생된 것이에요. 하지만 애플리케이션에 맞게 맞추려면 변경을 해야 할 거예요.
undo/redo 로직을 활성화하려면 undo::activate 명령을 undo/redo에 참여시킬 모든 클래스(테이블)를 인자로 호출해요. undo::deactivate, undo::freeze, undo::unfreeze를 사용해 undo/redo 메커니즘의 상태를 제어해요.
undo::activate 명령은 인자에 명명된 테이블에 이루어진 모든 변경을 기록하는 임시 트리거를 데이터베이스에 만들어요.
애플리케이션 인터페이스
단일 undo/redo 단계를 정의하는 일련의 변경 후에, undo::barrier 명령을 호출해 그 단계의 한계를 정의해요. 대화형 프로그램에서 임의의 변경 후 undo::event를 호출할 수 있고, undo::barrier가 idle 콜백으로 자동 호출될 거예요.
사용자가 Undo 버튼을 누르면 undo::undo를 호출해요. 사용자가 Redo 버튼을 누르면 undo::redo를 호출해요.
undo::undo나 undo::redo를 호출할 때마다, undo/redo 모듈은 모든 최상위 네임스페이스에서 status_refresh와 reload_all 메서드를 자동으로 호출해요. 이 메서드는 데이터베이스의 undo/redo된 변경에 기반해 화면을 재구성하거나 프로그램 상태를 갱신하도록 정의되어야 해요.
아래 시연 코드에는 undo/redo할 것이 있는지 여부에 따라 Undo와 Redo 버튼과 메뉴 항목을 회색 처리하거나 활성화하는 status_refresh 메서드가 포함돼 있어요. 애플리케이션에서 Undo와 Redo 버튼을 제어하려면 이 메서드를 재정의해야 해요.
시연 코드는 SQLite 데이터베이스가 "db"라는 데이터베이스 객체로 열리고 사용된다고 가정해요.
예제 코드
# Everything goes in a private namespace
namespace eval ::undo {
# proc: ::undo::activate TABLE ...
# title: Start up the undo/redo system
#
# Arguments should be one or more database tables (in the database associated
# with the handle "db") whose changes are to be recorded for undo/redo
# purposes.
#
proc activate {args} {
variable _undo
if {$_undo(active)} return
eval _create_triggers db $args
set _undo(undostack) {}
set _undo(redostack) {}
set _undo(active) 1
set _undo(freeze) -1
_start_interval
}
# proc: ::undo::deactivate
# title: Halt the undo/redo system and delete the undo/redo stacks
#
proc deactivate {} {
variable _undo
if {!$_undo(active)} return
_drop_triggers db
set _undo(undostack) {}
set _undo(redostack) {}
set _undo(active) 0
set _undo(freeze) -1
}
# proc: ::undo::freeze
# title: Stop accepting database changes into the undo stack
#
# From the point when this routine is called up until the next unfreeze,
# new database changes are rejected from the undo stack.
#
proc freeze {} {
variable _undo
if {![info exists _undo(freeze)]} return
if {$_undo(freeze)>=0} {error "recursive call to ::undo::freeze"}
set _undo(freeze) [db one {SELECT coalesce(max(seq),0) FROM undolog}]
}
# proc: ::undo::unfreeze
# title: Begin accepting undo actions again.
#
proc unfreeze {} {
variable _undo
if {![info exists _undo(freeze)]} return
if {$_undo(freeze)<0} {error "called ::undo::unfreeze while not frozen"}
db eval "DELETE FROM undolog WHERE seq>$_undo(freeze)"
set _undo(freeze) -1
}
# proc: ::undo::event
# title: Something undoable has happened
#
# This routine is called whenever an undoable action occurs. Arrangements
# are made to invoke ::undo::barrier no later than the next idle moment.
#
proc event {} {
variable _undo
if {$_undo(pending)==""} {
set _undo(pending) [after idle ::undo::barrier]
}
}
# proc: ::undo::barrier
# title: Create an undo barrier right now.
#
proc barrier {} {
variable _undo
catch {after cancel $_undo(pending)}
set _undo(pending) {}
if {!$_undo(active)} {
refresh
return
}
set end [db one {SELECT coalesce(max(seq),0) FROM undolog}]
if {$_undo(freeze)>=0 && $end>$_undo(freeze)} {set end $_undo(freeze)}
set begin $_undo(firstlog)
_start_interval
if {$begin==$_undo(firstlog)} {
refresh
return
}
lappend _undo(undostack) [list $begin $end]
set _undo(redostack) {}
refresh
}
# proc: ::undo::undo
# title: Do a single step of undo
#
proc undo {} {
_step undostack redostack
}
# proc: ::undo::redo
# title: Redo a single step
#
proc redo {} {
_step redostack undostack
}
# proc: ::undo::refresh
# title: Update the status of controls after a database change
#
# The undo module calls this routine after any undo/redo in order to
# cause controls gray out appropriately depending on the current state
# of the database. This routine works by invoking the status_refresh
# module in all top-level namespaces.
#
proc refresh {} {
set body {}
foreach ns [namespace children ::] {
if {[info proc ${ns}::status_refresh]==""} continue
append body ${ns}::status_refresh\n
}
proc ::undo::refresh {} $body
refresh
}
# proc: ::undo::reload_all
# title: Redraw everything based on the current database
#
# The undo module calls this routine after any undo/redo in order to
# cause the screen to be completely redrawn based on the current database
# contents. This is accomplished by calling the "reload" module in
# every top-level namespace other than ::undo.
#
proc reload_all {} {
set body {}
foreach ns [namespace children ::] {
if {[info proc ${ns}::reload]==""} continue
append body ${ns}::reload\n
}
proc ::undo::reload_all {} $body
reload_all
}
##############################################################################
# The public interface to this module is above. Routines and variables that
# follow (and whose names begin with "_") are private to this module.
##############################################################################
# state information
#
set _undo(active) 0
set _undo(undostack) {}
set _undo(redostack) {}
set _undo(pending) {}
set _undo(firstlog) 1
set _undo(startstate) {}
# proc: ::undo::status_refresh
# title: Enable and/or disable menu options a buttons
#
proc status_refresh {} {
variable _undo
if {!$_undo(active) || [llength $_undo(undostack)]==0} {
.mb.edit entryconfig Undo -state disabled
.bb.undo config -state disabled
} else {
.mb.edit entryconfig Undo -state normal
.bb.undo config -state normal
}
if {!$_undo(active) || [llength $_undo(redostack)]==0} {
.mb.edit entryconfig Redo -state disabled
.bb.redo config -state disabled
} else {
.mb.edit entryconfig Redo -state normal
.bb.redo config -state normal
}
}
# xproc: ::undo::_create_triggers DB TABLE1 TABLE2 ...
# title: Create change recording triggers for all tables listed
#
# Create a temporary table in the database named "undolog". Create
# triggers that fire on any insert, delete, or update of TABLE1, TABLE2, ....
# When those triggers fire, insert records in undolog that contain
# SQL text for statements that will undo the insert, delete, or update.
#
proc _create_triggers {db args} {
catch {$db eval {DROP TABLE undolog}}
$db eval {CREATE TEMP TABLE undolog(seq integer primary key, sql text)}
foreach tbl $args {
set collist [$db eval "pragma table_info($tbl)"]
set sql "CREATE TEMP TRIGGER _${tbl}_it AFTER INSERT ON $tbl BEGIN\n"
append sql " INSERT INTO undolog VALUES(NULL,"
append sql "'DELETE FROM $tbl WHERE rowid='||new.rowid);\nEND;\n"
append sql "CREATE TEMP TRIGGER _${tbl}_ut AFTER UPDATE ON $tbl BEGIN\n"
append sql " INSERT INTO undolog VALUES(NULL,"
append sql "'UPDATE $tbl "
set sep "SET "
foreach {x1 name x2 x3 x4 x5} $collist {
append sql "$sep$name='||quote(old.$name)||'"
set sep ","
}
append sql " WHERE rowid='||old.rowid);\nEND;\n"
append sql "CREATE TEMP TRIGGER _${tbl}_dt BEFORE DELETE ON $tbl BEGIN\n"
append sql " INSERT INTO undolog VALUES(NULL,"
append sql "'INSERT INTO ${tbl}(rowid"
foreach {x1 name x2 x3 x4 x5} $collist {append sql ,$name}
append sql ") VALUES('||old.rowid||'"
foreach {x1 name x2 x3 x4 x5} $collist {append sql ,'||quote(old.$name)||'}
append sql ")');\nEND;\n"
$db eval $sql
}
}
# xproc: ::undo::_drop_triggers DB
# title: Drop all of the triggers that _create_triggers created
#
proc _drop_triggers {db} {
set tlist [$db eval {SELECT name FROM sqlite_temp_schema
WHERE type='trigger'}]
foreach trigger $tlist {
if {![regexp {_.*_(i|u|d)t$} $trigger]} continue
$db eval "DROP TRIGGER $trigger;"
}
catch {$db eval {DROP TABLE undolog}}
}
# xproc: ::undo::_start_interval
# title: Record the starting conditions of an undo interval
#
proc _start_interval {} {
variable _undo
set _undo(firstlog) [db one {SELECT coalesce(max(seq),0)+1 FROM undolog}]
}
# xproc: ::undo::_step V1 V2
# title: Do a single step of undo or redo
#
# For an undo V1=="undostack" and V2=="redostack". For a redo,
# V1=="redostack" and V2=="undostack".
#
proc _step {v1 v2} {
variable _undo
set op [lindex $_undo($v1) end]
set _undo($v1) [lrange $_undo($v1) 0 end-1]
foreach {begin end} $op break
db eval BEGIN
set q1 "SELECT sql FROM undolog WHERE seq>=$begin AND seq<=$end
ORDER BY seq DESC"
set sqllist [db eval $q1]
db eval "DELETE FROM undolog WHERE seq>=$begin AND seq<=$end"
set _undo(firstlog) [db one {SELECT coalesce(max(seq),0)+1 FROM undolog}]
foreach sql $sqllist {
db eval $sql
}
db eval COMMIT
reload_all
set end [db one {SELECT coalesce(max(seq),0) FROM undolog}]
set begin $_undo(firstlog)
lappend _undo($v2) [list $begin $end]
_start_interval
refresh
}
# End of the ::undo namespace
}
더 알아보기 (Learn more)
- The Tcl interface to the SQLite library — Tcl SQLite 확장
- CREATE TRIGGER — 트리거 구문
- Object-oriented design (Tcl) — Tcl 네임스페이스
- quote() function — quote SQL 함수