Repository navigation
Expand file tree
/
Copy pathDatabaseInitializer.cs
More file actions
1030 lines (937 loc) · 53.6 KB
/
Copy pathDatabaseInitializer.cs
File metadata and controls
1030 lines (937 loc) · 53.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
using System;
using System.Data;
using System.Diagnostics;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
using NewSchool.Models;
namespace NewSchool.Database;
/// <summary>
/// 데이터베이스 초기화 클래스
/// NEIS 표준 구조로 전면 재작성
/// ⭐ 외래키 문제 해결: TeacherID NULL 허용 + ON DELETE SET NULL
/// ⭐ 시간표 시스템 재설계: Course, ClassTimetable 분리
///
/// <para>여기서 만들지 않는 테이블이 있어도 놀라지 말 것 — 읽고 쓰는 코드가 없던
/// 테이블 아홉 개를 2026-08-28 에 걷어냈다(Subject·CourseSchedule·Evaluation·
/// Attachment·SubjectYearPlan·WeeklyUnitPlan·WeeklyLessonHours·ScheduleUnitMap·
/// UndoHistory). 되살리려면 쓰는 코드부터 있어야 한다.</para>
/// </summary>
public sealed class DatabaseInitializer : IDisposable
{
/// <summary>
/// 현재 스키마 세대 번호(<c>PRAGMA user_version</c>).
///
/// <b>1 = v1.0.0 출시 스키마.</b> 1.0 을 첫 배포로 잡으면서 그 이전의 컬럼 추가·
/// 테이블 재작성 마이그레이션은 모두 <c>CREATE TABLE</c> 정의에 접어 넣고 제거했다
/// (배포된 적 없는 스키마를 위한 코드였다).
///
/// 이 상수와 기록 자체는 남긴다 — 1.0 이 나간 뒤 스키마를 바꾸려면
/// "이 파일이 어느 세대인지" 알아야 하기 때문이다. 앞으로 스키마를 바꿀 때마다
/// 이 값을 올리고 <see cref="MigrateSchemaAsync"/> 에 변환 한 덩이를 더한다.
///
/// <b>2 = 수업 일지 일원화.</b> 수업 일지를 게시판(board.db) 한 곳으로 모으면서
/// 쓰이지 않게 된 <c>LessonLog</c> 테이블을 버렸다.
///
/// <b>3 = 주차별 시수의 키를 주차 번호에서 주 시작일로.</b> 주차 번호는 학기 구간이
/// 바뀌면 다시 세어져, 손으로 고친 시수가 다른 주에 붙었다
/// (<c>Repositories.CourseWeeklyHoursRepository.SchemaSql</c> 의 인덱스 주석).
/// </summary>
public const int SchemaVersion = 3;
private readonly string _dbPath;
private SqliteConnection? _connection;
private bool _disposed;
public DatabaseInitializer(string dbPath)
{
_dbPath = dbPath;
}
/// <summary>
/// 데이터베이스 초기화 (테이블 생성 + 인덱스)
/// </summary>
public async Task<bool> InitializeAsync()
{
try
{
_connection = new SqliteConnection($"Data Source={_dbPath}");
await _connection.OpenAsync();
Debug.WriteLine("[DatabaseInitializer] 데이터베이스 연결 완료");
await MigrateSchemaAsync();
await CreateTablesAsync();
await CreateIndexesAsync();
await CleanupOrphansAsync();
await StampSchemaVersionAsync();
Debug.WriteLine("[DatabaseInitializer] 데이터베이스 초기화 완료");
return true;
}
catch (Exception ex)
{
Debug.WriteLine($"[DatabaseInitializer] 초기화 실패: {ex.Message}");
throw;
}
}
private async Task CreateTablesAsync()
{
if (_connection == null) return;
using var cmd = _connection.CreateCommand();
Debug.WriteLine("[DatabaseInitializer] 테이블 생성 시작...");
// ==========================================
// 1. School 테이블 (학교 정보 - NEIS 표준)
// ==========================================
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS School (
No INTEGER PRIMARY KEY AUTOINCREMENT,
SchoolCode TEXT UNIQUE NOT NULL,
ATPT_OFCDC_SC_CODE TEXT,
ATPT_OFCDC_SC_NAME TEXT,
SchoolName TEXT NOT NULL,
SchoolType TEXT,
FoundationDate TEXT,
Address TEXT,
Phone TEXT,
Fax TEXT,
Website TEXT,
PrincipalName TEXT,
Memo TEXT,
IsActive INTEGER DEFAULT 1,
CreatedAt TEXT NOT NULL,
UpdatedAt TEXT NOT NULL,
IsDeleted INTEGER DEFAULT 0
);";
await cmd.ExecuteNonQueryAsync();
Debug.WriteLine("[DatabaseInitializer] School 테이블 생성 완료");
// ==========================================
// 2. Student 테이블 (학생 기본 정보)
// ==========================================
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS Student (
No INTEGER PRIMARY KEY AUTOINCREMENT,
StudentID TEXT UNIQUE NOT NULL,
Name TEXT NOT NULL,
Sex TEXT,
BirthDate TEXT,
ResidentNumber TEXT,
Photo TEXT,
Phone TEXT,
Email TEXT,
Address TEXT,
Memo TEXT,
CreatedAt TEXT NOT NULL,
UpdatedAt TEXT NOT NULL,
IsDeleted INTEGER DEFAULT 0
);";
await cmd.ExecuteNonQueryAsync();
Debug.WriteLine("[DatabaseInitializer] Student 테이블 생성 완료");
// ==========================================
// 3. StudentDetail 테이블 (학생 상세 정보)
// ==========================================
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS StudentDetail (
No INTEGER PRIMARY KEY AUTOINCREMENT,
StudentID TEXT UNIQUE NOT NULL,
FatherName TEXT,
FatherPhone TEXT,
FatherJob TEXT,
MotherName TEXT,
MotherPhone TEXT,
MotherJob TEXT,
GuardianName TEXT,
GuardianPhone TEXT,
GuardianRelation TEXT,
FamilyInfo TEXT,
Friends TEXT,
Interests TEXT,
Talents TEXT,
CareerGoal TEXT,
HealthInfo TEXT,
Allergies TEXT,
SpecialNeeds TEXT,
Memo TEXT,
CreatedAt TEXT NOT NULL,
UpdatedAt TEXT NOT NULL,
FOREIGN KEY (StudentID) REFERENCES Student(StudentID) ON DELETE CASCADE
);";
await cmd.ExecuteNonQueryAsync();
Debug.WriteLine("[DatabaseInitializer] StudentDetail 테이블 생성 완료");
// ==========================================
// 4. Teacher 테이블 (교사 정보)
// ==========================================
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS Teacher (
No INTEGER PRIMARY KEY AUTOINCREMENT,
TeacherID TEXT UNIQUE NOT NULL,
LoginID TEXT UNIQUE NOT NULL,
Name TEXT NOT NULL,
Status TEXT DEFAULT '재직',
Position TEXT,
Subject TEXT,
Phone TEXT,
Email TEXT,
BirthDate TEXT,
HireDate TEXT,
Photo TEXT,
Memo TEXT,
CreatedAt TEXT NOT NULL,
UpdatedAt TEXT NOT NULL,
LastLoginAt TEXT,
IsDeleted INTEGER DEFAULT 0
);";
await cmd.ExecuteNonQueryAsync();
Debug.WriteLine("[DatabaseInitializer] Teacher 테이블 생성 완료");
// ==========================================
// 5. TeacherSchoolHistory 테이블 (교사 근무 이력)
// ==========================================
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS TeacherSchoolHistory (
No INTEGER PRIMARY KEY AUTOINCREMENT,
TeacherID TEXT NOT NULL,
SchoolCode TEXT NOT NULL,
StartDate TEXT NOT NULL,
EndDate TEXT,
Position TEXT,
Role TEXT,
IsCurrent INTEGER DEFAULT 1,
Memo TEXT,
CreatedAt TEXT NOT NULL,
UpdatedAt TEXT NOT NULL,
FOREIGN KEY (TeacherID) REFERENCES Teacher(TeacherID) ON DELETE CASCADE,
FOREIGN KEY (SchoolCode) REFERENCES School(SchoolCode)
);";
await cmd.ExecuteNonQueryAsync();
Debug.WriteLine("[DatabaseInitializer] TeacherSchoolHistory 테이블 생성 완료");
// ==========================================
// 6. Enrollment 테이블 (학적 정보 - 핵심!)
// ⭐ TeacherID NULL 허용 + ON DELETE SET NULL 추가
// ==========================================
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS Enrollment (
No INTEGER PRIMARY KEY AUTOINCREMENT,
StudentID TEXT NOT NULL,
SchoolCode TEXT NOT NULL,
Year INTEGER NOT NULL,
Grade INTEGER NOT NULL,
Class INTEGER NOT NULL,
Number INTEGER NOT NULL,
IsActive INTEGER NOT NULL DEFAULT 1,
ChangeType TEXT NOT NULL DEFAULT '입학',
ChangeDate TEXT,
Memo TEXT,
TeacherID TEXT NULL,
FOREIGN KEY (StudentID) REFERENCES Student(StudentID) ON DELETE CASCADE,
FOREIGN KEY (SchoolCode) REFERENCES School(SchoolCode),
FOREIGN KEY (TeacherID) REFERENCES Teacher(TeacherID) ON DELETE SET NULL,
UNIQUE(StudentID, SchoolCode, Year)
);";
await cmd.ExecuteNonQueryAsync();
Debug.WriteLine("[DatabaseInitializer] Enrollment 테이블 생성 완료");
// 조회는 모두 이 뷰를 지난다. 이름·성별·사진은 Student 가 정본이고, 학적 쪽에
// 사본을 두던 시절에는 학생을 고칠 때마다 따라 고쳐야 했다(동기화 사고의 원인).
// JOIN 을 열 곳에 되풀이하지 않도록 여기 한 번만 적는다.
cmd.CommandText = @"
DROP VIEW IF EXISTS EnrollmentFull;
CREATE VIEW EnrollmentFull AS
SELECT e.No, e.StudentID, e.SchoolCode, e.Year, e.Grade, e.Class, e.Number,
e.IsActive, e.ChangeType, e.ChangeDate, e.Memo, e.TeacherID,
s.Name, s.Sex, s.Photo
FROM Enrollment e
JOIN Student s ON s.StudentID = e.StudentID;";
await cmd.ExecuteNonQueryAsync();
Debug.WriteLine("[DatabaseInitializer] EnrollmentFull 뷰 생성 완료");
// 7. Subject 테이블(교과목)은 만들지 않는다 — 읽고 쓰는 코드가 한 줄도 없었다.
// 과목명은 Course.Subject 에 문자열로 들어간다.
// ==========================================
// 8. Course 테이블 (수업 개설) - ⭐ 재설계
// ==========================================
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS Course (
No INTEGER PRIMARY KEY AUTOINCREMENT,
SchoolCode TEXT NOT NULL,
Year INTEGER NOT NULL,
Semester INTEGER NOT NULL,
TeacherID TEXT NOT NULL,
Grade INTEGER NOT NULL,
Subject TEXT NOT NULL,
Unit INTEGER DEFAULT 0,
Type TEXT DEFAULT 'Class',
Rooms TEXT,
Remark TEXT,
FOREIGN KEY (SchoolCode) REFERENCES School(SchoolCode),
FOREIGN KEY (TeacherID) REFERENCES Teacher(TeacherID) ON DELETE CASCADE
);";
await cmd.ExecuteNonQueryAsync();
Debug.WriteLine("[DatabaseInitializer] Course 테이블 생성 완료");
// 9. CourseSchedule 테이블(교사 시간표)은 만들지 않는다 — 읽고 쓰는 코드가 없었다.
// 교사 시간표는 Schedule 이, 학급 시간표는 ClassTimetable 이 맡는다.
// ==========================================
// 10. ClassTimetable 테이블 (학급 시간표) - ⭐ 신규
// ==========================================
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS ClassTimetable (
No INTEGER PRIMARY KEY AUTOINCREMENT,
SchoolCode TEXT NOT NULL,
Year INTEGER NOT NULL,
Semester INTEGER NOT NULL,
Grade INTEGER NOT NULL,
Class INTEGER NOT NULL,
DayOfWeek INTEGER NOT NULL,
Period INTEGER NOT NULL,
SubjectName TEXT NOT NULL,
TeacherName TEXT,
Room TEXT,
FOREIGN KEY (SchoolCode) REFERENCES School(SchoolCode),
UNIQUE(SchoolCode, Year, Semester, Grade, Class, DayOfWeek, Period)
);";
await cmd.ExecuteNonQueryAsync();
Debug.WriteLine("[DatabaseInitializer] ClassTimetable 테이블 생성 완료");
// ==========================================
// 11. CourseEnrollment 테이블 (수강 신청)
// ==========================================
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS CourseEnrollment (
No INTEGER PRIMARY KEY AUTOINCREMENT,
EnrollmentNo INTEGER NOT NULL,
CourseNo INTEGER NOT NULL,
Status TEXT DEFAULT '수강중',
Remark TEXT,
Room TEXT,
FOREIGN KEY (EnrollmentNo) REFERENCES Enrollment(No) ON DELETE CASCADE,
FOREIGN KEY (CourseNo) REFERENCES Course(No) ON DELETE CASCADE,
UNIQUE(EnrollmentNo, CourseNo)
);";
await cmd.ExecuteNonQueryAsync();
Debug.WriteLine("[DatabaseInitializer] CourseEnrollment 테이블 생성 완료");
// 배정은 학적을 가리키지만 화면은 학생 이름으로 읽는다. 그 JOIN 을 뷰에 모은다.
// IsActive 가 함께 나오므로 "전출한 학생을 명단에서 뺀다" 가 조회 한 줄로 된다.
cmd.CommandText = @"
DROP VIEW IF EXISTS CourseEnrollmentFull;
CREATE VIEW CourseEnrollmentFull AS
SELECT ce.No, ce.EnrollmentNo, ce.CourseNo, ce.Status, ce.Remark, ce.Room,
e.StudentID, e.Year, e.Grade, e.Class, e.Number, e.IsActive,
s.Name, s.Sex, s.Photo
FROM CourseEnrollment ce
JOIN Enrollment e ON e.No = ce.EnrollmentNo
JOIN Student s ON s.StudentID = e.StudentID;";
await cmd.ExecuteNonQueryAsync();
// ==========================================
// 14. StudentLog 테이블 (학생 기록부) - ⭐ 확장 버전
// Category: INTEGER (LogCategory enum 값)
// 구조화된 활동 기록 필드 추가
// ==========================================
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS StudentLog (
No INTEGER PRIMARY KEY AUTOINCREMENT,
StudentID TEXT NOT NULL,
TeacherID TEXT NULL,
Year INTEGER NOT NULL,
Semester INTEGER NOT NULL,
Date TEXT NOT NULL,
Category INTEGER NOT NULL DEFAULT 0,
CourseNo INTEGER,
SubjectName TEXT,
ClubNo INTEGER,
ClubName TEXT,
Log TEXT,
Tag TEXT,
IsImportant INTEGER DEFAULT 0,
ActivityName TEXT,
Topic TEXT,
Description TEXT,
Role TEXT,
SkillDeveloped TEXT,
StrengthShown TEXT,
ResultOrOutcome TEXT,
FOREIGN KEY (StudentID) REFERENCES Student(StudentID) ON DELETE CASCADE,
FOREIGN KEY (TeacherID) REFERENCES Teacher(TeacherID) ON DELETE SET NULL,
FOREIGN KEY (CourseNo) REFERENCES Course(No) ON DELETE SET NULL
);";
await cmd.ExecuteNonQueryAsync();
Debug.WriteLine("[DatabaseInitializer] StudentLog 테이블 생성 완료");
// ==========================================
// 14-b. StudentLogFile 테이블 (누가기록 첨부)
//
// 실물은 Data\StudentLogFiles\{Year}\{StudentID}\{FileName} 에 산다.
// Year·StudentID 를 여기에 둔 것은 기록이 CASCADE 로 사라진 뒤에도 실물을 치울
// 경로를 알기 위해서다 — CASCADE 는 DB 행만 지우고 파일은 남긴다.
// ==========================================
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS StudentLogFile (
No INTEGER PRIMARY KEY AUTOINCREMENT,
LogNo INTEGER NOT NULL,
-- ⚠ Year·StudentID 에는 FK 를 걸지 않는다 — 참조가 아니라 폴더 이름이다.
-- 실물은 StudentLogFiles\{Year}\{StudentID}\ 에 살고, 이 두 값은 기록이
-- CASCADE 로 사라진 뒤에도 그 실물을 찾아가기 위한 것이다. FK 를 걸어
-- 함께 지워지게 하면 정작 파일을 치울 경로를 잃는다.
-- 학생과의 연쇄는 LogNo → StudentLog(No) → Student(StudentID) 로 이어진다.
-- (StudentID 를 FK 없이 들고 있는 표는 이것과 좌석 둘뿐이고, 좌석의 근거는
-- docs/enrollment-redesign.md 7.5 에 있다.)
Year INTEGER NOT NULL,
StudentID TEXT NOT NULL,
FileName TEXT NOT NULL,
FileSize INTEGER DEFAULT 0,
DateTime TEXT NOT NULL DEFAULT '',
FOREIGN KEY (LogNo) REFERENCES StudentLog(No) ON DELETE CASCADE
);";
await cmd.ExecuteNonQueryAsync();
Debug.WriteLine("[DatabaseInitializer] StudentLogFile 테이블 생성 완료");
// ==========================================
// 15. StudentSpecial 테이블 (학생 특이사항) - ⭐ 수정
// ==========================================
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS StudentSpecial (
No INTEGER PRIMARY KEY AUTOINCREMENT,
StudentID TEXT NOT NULL,
Year INTEGER NOT NULL,
-- 0 = 학년 단위(연간), 1·2 = 해당 학기. 교과활동(교과 세특)만 학기별이다.
-- CourseNo 는 ON DELETE SET NULL 이라 교과목을 지우면 학기를 유도할 수 없으므로 직접 저장한다.
Semester INTEGER NOT NULL DEFAULT 0,
Type TEXT NOT NULL,
Title TEXT NOT NULL,
Content TEXT NOT NULL,
Date TEXT NOT NULL,
TeacherID TEXT NULL,
CourseNo INTEGER,
SubjectName TEXT,
IsActive INTEGER DEFAULT 1,
Tag TEXT,
FOREIGN KEY (StudentID) REFERENCES Student(StudentID) ON DELETE CASCADE,
FOREIGN KEY (TeacherID) REFERENCES Teacher(TeacherID) ON DELETE SET NULL,
FOREIGN KEY (CourseNo) REFERENCES Course(No) ON DELETE SET NULL
);";
await cmd.ExecuteNonQueryAsync();
Debug.WriteLine("[DatabaseInitializer] StudentSpecial 테이블 생성 완료");
// ==========================================
// 기존 테이블들 (유지)
// ==========================================
// Lesson 테이블 — 정의는 LessonRepository.SchemaSql 하나뿐(이중 정의 제거)
cmd.CommandText = Repositories.LessonRepository.SchemaSql;
await cmd.ExecuteNonQueryAsync();
Debug.WriteLine("[DatabaseInitializer] Lesson 테이블 생성 완료");
// ==========================================
// 16. Club 테이블 (동아리 정보)
// ==========================================
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS Club (
No INTEGER PRIMARY KEY AUTOINCREMENT,
SchoolCode TEXT NOT NULL,
TeacherID TEXT NOT NULL,
Year INTEGER NOT NULL,
ClubName TEXT NOT NULL,
ActivityRoom TEXT,
Remark TEXT,
CreatedAt TEXT NOT NULL,
UpdatedAt TEXT NOT NULL,
IsDeleted INTEGER DEFAULT 0,
FOREIGN KEY (SchoolCode) REFERENCES School(SchoolCode),
-- ⚠ 교사에 대해 이 표만 CASCADE 다. 다른 곳(StudentLog·StudentSpecial)은
-- TeacherID 를 SET NULL 로 둔다 — 교사가 떠나도 기록은 남아야 하기 때문이다.
-- 여기 CASCADE 대로면 교사 행을 지우는 순간 그 교사의 동아리가 통째로
-- 사라지고, ClubEnrollment 의 CASCADE 를 타고 부원 배정까지 함께 날아간다.
--
-- 지금은 터지지 않는다: TeacherRepository 에 삭제 메서드가 아예 없다.
-- IsDeleted 를 세우는 길(UpdateAsync)만 있고 그건 행을 남기므로 CASCADE 가
-- 깨어나지 않는다. 즉 이것은 잠재 결함이다.
--
-- ⚠ 교사 삭제 기능을 만든다면 이 FK 부터 SET NULL 로 바꿔야 한다.
-- (이 프로젝트는 ALTER 마이그레이션을 두지 않으므로, 이미 만들어진
-- DB 는 손으로 고쳐야 한다 — Board.cs 의 IsPinned 사례 참고.)
FOREIGN KEY (TeacherID) REFERENCES Teacher(TeacherID) ON DELETE CASCADE
);";
await cmd.ExecuteNonQueryAsync();
Debug.WriteLine("[DatabaseInitializer] Club 테이블 생성 완료");
// ==========================================
// 17. ClubEnrollment 테이블 (동아리 배정)
// ==========================================
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS ClubEnrollment (
No INTEGER PRIMARY KEY AUTOINCREMENT,
EnrollmentNo INTEGER NOT NULL,
ClubNo INTEGER NOT NULL,
Status TEXT DEFAULT '활동중',
Remark TEXT,
FOREIGN KEY (EnrollmentNo) REFERENCES Enrollment(No) ON DELETE CASCADE,
FOREIGN KEY (ClubNo) REFERENCES Club(No) ON DELETE CASCADE,
UNIQUE(EnrollmentNo, ClubNo)
);";
await cmd.ExecuteNonQueryAsync();
Debug.WriteLine("[DatabaseInitializer] ClubEnrollment 테이블 생성 완료");
cmd.CommandText = @"
DROP VIEW IF EXISTS ClubEnrollmentFull;
CREATE VIEW ClubEnrollmentFull AS
SELECT ce.No, ce.EnrollmentNo, ce.ClubNo, ce.Status, ce.Remark,
e.StudentID, e.Year, e.Grade, e.Class, e.Number, e.IsActive,
s.Name, s.Sex, s.Photo
FROM ClubEnrollment ce
JOIN Enrollment e ON e.No = ce.EnrollmentNo
JOIN Student s ON s.StudentID = e.StudentID;";
await cmd.ExecuteNonQueryAsync();
// ==========================================
// 18. ClassDiary 테이블 (학급 일지) - ⭐ 개선 버전
// ==========================================
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS ClassDiary (
No INTEGER PRIMARY KEY AUTOINCREMENT,
SchoolCode TEXT NOT NULL,
TeacherID TEXT NULL,
Year INTEGER NOT NULL,
Semester INTEGER NOT NULL,
Date TEXT NOT NULL,
Grade INTEGER NOT NULL,
Class INTEGER NOT NULL,
Absent TEXT DEFAULT '',
Late TEXT DEFAULT '',
LeaveEarly TEXT DEFAULT '',
Memo TEXT DEFAULT '',
Notice TEXT DEFAULT '',
Life TEXT DEFAULT '',
CreatedAt TEXT,
UpdatedAt TEXT,
FOREIGN KEY (SchoolCode) REFERENCES School(SchoolCode),
FOREIGN KEY (TeacherID) REFERENCES Teacher(TeacherID) ON DELETE SET NULL,
UNIQUE(SchoolCode, Year, Semester, Grade, Class, Date)
);";
await cmd.ExecuteNonQueryAsync();
Debug.WriteLine("[DatabaseInitializer] ClassDiary 테이블 생성 완료");
// SchoolSchedule 테이블 (학사일정 - NEIS API + 수동 입력)
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS SchoolSchedule (
No INTEGER PRIMARY KEY AUTOINCREMENT,
SCHUL_NM TEXT DEFAULT '',
ATPT_OFCDC_SC_CODE TEXT DEFAULT '',
ATPT_OFCDC_SC_NM TEXT DEFAULT '',
SD_SCHUL_CODE TEXT DEFAULT '',
AY INTEGER NOT NULL,
AA_YMD TEXT NOT NULL,
EVENT_NM TEXT NOT NULL,
EVENT_CNTNT TEXT DEFAULT '',
SBTR_DD_SC_NM TEXT DEFAULT '해당없음',
ONE_GRADE_EVENT_YN INTEGER DEFAULT 0,
TW_GRADE_EVENT_YN INTEGER DEFAULT 0,
THREE_GRADE_EVENT_YN INTEGER DEFAULT 0,
FR_GRADE_EVENT_YN INTEGER DEFAULT 0,
FIV_GRADE_EVENT_YN INTEGER DEFAULT 0,
SIX_GRADE_EVENT_YN INTEGER DEFAULT 0,
IsManual INTEGER DEFAULT 0,
CreatedAt TEXT NOT NULL,
UpdatedAt TEXT NOT NULL,
IsDeleted INTEGER DEFAULT 0
)";
await cmd.ExecuteNonQueryAsync();
Debug.WriteLine(" ✓ SchoolSchedule 테이블 생성");
// ==========================================
// SeatArrangement 테이블 (학급별 좌석 배치 메타)
// 저장/복원/출력을 위한 레이아웃 정보
// ==========================================
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS SeatArrangement (
No INTEGER PRIMARY KEY AUTOINCREMENT,
SchoolCode TEXT NOT NULL,
Year INTEGER NOT NULL,
Grade INTEGER NOT NULL,
Class INTEGER NOT NULL,
Jul INTEGER NOT NULL,
Jjak INTEGER NOT NULL,
Rows INTEGER NOT NULL,
ShowPhoto INTEGER DEFAULT 0,
Message TEXT DEFAULT '',
IsLocked INTEGER DEFAULT 0,
OptionsJson TEXT DEFAULT '',
CreatedAt TEXT NOT NULL,
UpdatedAt TEXT NOT NULL,
UNIQUE(SchoolCode, Year, Grade, Class)
);";
await cmd.ExecuteNonQueryAsync();
Debug.WriteLine(" ✓ SeatArrangement 테이블 생성");
// ==========================================
// SeatAssignment 테이블 (좌석별 학생 배정)
// Row/Col 좌표마다 한 학생 또는 미사용 플래그
// ==========================================
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS SeatAssignment (
No INTEGER PRIMARY KEY AUTOINCREMENT,
ArrangementNo INTEGER NOT NULL,
Row INTEGER NOT NULL,
Col INTEGER NOT NULL,
StudentID TEXT NULL,
IsUnUsed INTEGER DEFAULT 0,
IsHidden INTEGER DEFAULT 0,
IsFixed INTEGER DEFAULT 0,
FOREIGN KEY (ArrangementNo) REFERENCES SeatArrangement(No) ON DELETE CASCADE,
UNIQUE(ArrangementNo, Row, Col)
);";
await cmd.ExecuteNonQueryAsync();
Debug.WriteLine(" ✓ SeatAssignment 테이블 생성");
// ==========================================
// SeatHistory 테이블 (짝 이력 — 지난 짝 배제용)
// 저장 시점마다 짝(인접 좌석) 관계를 기록
// ==========================================
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS SeatHistory (
No INTEGER PRIMARY KEY AUTOINCREMENT,
SchoolCode TEXT NOT NULL,
Year INTEGER NOT NULL,
Grade INTEGER NOT NULL,
Class INTEGER NOT NULL,
StudentID_A TEXT NOT NULL,
StudentID_B TEXT NOT NULL,
Round INTEGER NOT NULL,
Kind TEXT NOT NULL DEFAULT 'Pair',
SavedAt TEXT NOT NULL
);";
await cmd.ExecuteNonQueryAsync();
Debug.WriteLine(" ✓ SeatHistory 테이블 생성");
// ==========================================
// SeatPosHistory 테이블 (좌석 위치 이력 — 지난 자리 배제용)
// ==========================================
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS SeatPosHistory (
No INTEGER PRIMARY KEY AUTOINCREMENT,
SchoolCode TEXT NOT NULL,
Year INTEGER NOT NULL,
Grade INTEGER NOT NULL,
Class INTEGER NOT NULL,
StudentID TEXT NOT NULL,
Row INTEGER NOT NULL,
Col INTEGER NOT NULL,
Round INTEGER NOT NULL,
SavedAt TEXT NOT NULL
);";
await cmd.ExecuteNonQueryAsync();
Debug.WriteLine(" ✓ SeatPosHistory 테이블 생성");
await CreateRepositoryOwnedTablesAsync(cmd);
Debug.WriteLine("[DatabaseInitializer] 모든 테이블 생성 완료");
}
/// <summary>
/// 정의를 각 리포지토리가 들고 있는 테이블들을 초기화 시점에 미리 만든다.
///
/// 예전에는 해당 리포지토리 생성자가 처음 호출될 때만 만들어졌는데,
/// 이 중 <c>CourseSection</c> 은 다른 테이블의
/// FK 부모다. <c>foreign_keys=ON</c> 에서 부모 테이블이 없으면 자식 테이블에 대한
/// INSERT/UPDATE 가 준비 단계에서 <c>no such table</c> 로 실패한다
/// 지금까지는 화면들이 우연히 부모 리포지토리를 먼저 열어서 가려져 있었을 뿐이므로,
/// 순서 의존을 없애기 위해 여기서 한 번에 만든다.
///
/// 전부 <c>CREATE TABLE IF NOT EXISTS</c> 라 기존 DB 에는 아무 영향이 없다.
/// 각 리포지토리의 컬럼 추가·재작성 마이그레이션은 종전대로 생성자에서 돈다.
/// 순서는 FK 부모 → 자식.
/// </summary>
private static async Task CreateRepositoryOwnedTablesAsync(SqliteCommand cmd)
{
var schemas = new (string Name, string Sql)[]
{
("CourseSection", Repositories.CourseSectionRepository.SchemaSql),
("LessonProgress", Repositories.LessonProgressRepository.SchemaSql),
("CourseWeeklyHours", Repositories.CourseWeeklyHoursRepository.SchemaSql),
("LessonChange", Repositories.LessonChangeRepository.SchemaSql),
};
foreach (var (name, sql) in schemas)
{
cmd.CommandText = sql;
await cmd.ExecuteNonQueryAsync();
Debug.WriteLine($" ✓ {name} 테이블 생성(리포지토리 정의)");
}
}
private async Task CreateIndexesAsync()
{
if (_connection == null) return;
using var cmd = _connection.CreateCommand();
Debug.WriteLine("[DatabaseInitializer] 인덱스 생성 시작...");
cmd.CommandText = @"
-- School 인덱스
CREATE INDEX IF NOT EXISTS idx_school_code ON School(SchoolCode);
CREATE INDEX IF NOT EXISTS idx_school_active ON School(IsActive, IsDeleted);
-- Student 인덱스
CREATE INDEX IF NOT EXISTS idx_student_id ON Student(StudentID);
CREATE INDEX IF NOT EXISTS idx_student_name ON Student(Name);
CREATE INDEX IF NOT EXISTS idx_student_deleted ON Student(IsDeleted);
-- StudentDetail 인덱스
CREATE INDEX IF NOT EXISTS idx_studentdetail_studentid ON StudentDetail(StudentID);
--SchoolSchedule 인덱스
CREATE INDEX IF NOT EXISTS idx_schedule_year ON SchoolSchedule(AY);
CREATE INDEX IF NOT EXISTS idx_schedule_date ON SchoolSchedule(AA_YMD);
CREATE INDEX IF NOT EXISTS idx_schedule_school_year ON SchoolSchedule(SD_SCHUL_CODE, AY);
CREATE INDEX IF NOT EXISTS idx_schedule_deleted ON SchoolSchedule(IsDeleted);
-- Teacher 인덱스
CREATE INDEX IF NOT EXISTS idx_teacher_id ON Teacher(TeacherID);
CREATE INDEX IF NOT EXISTS idx_teacher_loginid ON Teacher(LoginID);
CREATE INDEX IF NOT EXISTS idx_teacher_status ON Teacher(Status, IsDeleted);
-- TeacherSchoolHistory 인덱스
CREATE INDEX IF NOT EXISTS idx_teacherhistory_teacher ON TeacherSchoolHistory(TeacherID);
CREATE INDEX IF NOT EXISTS idx_teacherhistory_school ON TeacherSchoolHistory(SchoolCode);
CREATE INDEX IF NOT EXISTS idx_teacherhistory_current ON TeacherSchoolHistory(IsCurrent);
-- Enrollment 인덱스 (핵심!)
CREATE INDEX IF NOT EXISTS idx_enrollment_student ON Enrollment(StudentID);
CREATE INDEX IF NOT EXISTS idx_enrollment_school ON Enrollment(SchoolCode);
CREATE INDEX IF NOT EXISTS idx_enrollment_year ON Enrollment(Year);
-- 명단 조회의 주력 인덱스. IsActive 를 끝에 붙여 '이 반의 재적자' 를 그대로 탄다.
CREATE INDEX IF NOT EXISTS idx_enrollment_class ON Enrollment(SchoolCode, Year, Grade, Class, IsActive);
CREATE INDEX IF NOT EXISTS idx_enrollment_teacher ON Enrollment(TeacherID);
-- Course 인덱스 (재설계)
CREATE INDEX IF NOT EXISTS idx_course_teacher ON Course(TeacherID, Year, Semester);
CREATE INDEX IF NOT EXISTS idx_course_grade ON Course(Year, Semester, Grade);
-- CourseSchedule 인덱스는 테이블과 함께 걷어냈다.
-- (없는 테이블에 CREATE INDEX 를 걸면 no such table 오류로 초기화가 멎는다)
-- ClassTimetable 인덱스 (신규)
CREATE INDEX IF NOT EXISTS idx_classtimetable_class ON ClassTimetable(Year, Semester, Grade, Class);
CREATE INDEX IF NOT EXISTS idx_classtimetable_time ON ClassTimetable(DayOfWeek, Period);
-- CourseEnrollment 인덱스
CREATE INDEX IF NOT EXISTS idx_courseenrollment_student ON CourseEnrollment(EnrollmentNo);
CREATE INDEX IF NOT EXISTS idx_courseenrollment_course ON CourseEnrollment(CourseNo);
CREATE INDEX IF NOT EXISTS idx_courseenrollment_status ON CourseEnrollment(Status);
-- StudentLog 인덱스 (성능 최적화)
CREATE INDEX IF NOT EXISTS idx_studentlog_student ON StudentLog(StudentID, Year, Semester);
CREATE INDEX IF NOT EXISTS idx_studentlog_teacher ON StudentLog(TeacherID, Year, Semester);
CREATE INDEX IF NOT EXISTS idx_studentlog_course ON StudentLog(CourseNo);
CREATE INDEX IF NOT EXISTS idx_studentlog_category ON StudentLog(Category);
CREATE INDEX IF NOT EXISTS idx_studentlog_important ON StudentLog(IsImportant);
CREATE INDEX IF NOT EXISTS idx_studentlog_date ON StudentLog(Date);
-- 복합 인덱스 (자주 함께 조회되는 경우)
CREATE INDEX IF NOT EXISTS idx_studentlog_composite ON StudentLog(StudentID, Year, Semester, Date DESC);
CREATE INDEX IF NOT EXISTS idx_studentlog_search ON StudentLog(StudentID, Category, Date DESC);
-- 누가기록 첨부: 기록 하나의 첨부를 모아 읽는 것이 유일한 조회 형태다
CREATE INDEX IF NOT EXISTS idx_studentlogfile_log ON StudentLogFile(LogNo);
-- StudentSpecial 인덱스 (수정)
CREATE INDEX IF NOT EXISTS idx_studentspecial_student ON StudentSpecial(StudentID, Year);
CREATE INDEX IF NOT EXISTS idx_studentspecial_course ON StudentSpecial(CourseNo);
CREATE INDEX IF NOT EXISTS idx_studentspecial_type ON StudentSpecial(Type);
CREATE INDEX IF NOT EXISTS idx_studentspecial_active ON StudentSpecial(IsActive);
CREATE INDEX IF NOT EXISTS idx_studentspecial_teacher ON StudentSpecial(TeacherID);
CREATE INDEX IF NOT EXISTS idx_studentspecial_date ON StudentSpecial(Date);
-- 기존 테이블 인덱스
CREATE INDEX IF NOT EXISTS idx_lesson_teacher ON Lesson(Teacher, Year, Semester);
-- Lesson(Date, Period) 인덱스는 Date 열과 함께 걷어냈다(2026-08-29).
-- 없는 열에 CREATE INDEX 를 걸면 새 DB 의 초기화가 통째로 멎는다
-- (CourseSchedule 인덱스를 지울 때와 같은 자리다).
-- Club 인덱스
CREATE INDEX IF NOT EXISTS idx_club_school ON Club(SchoolCode, Year);
CREATE INDEX IF NOT EXISTS idx_club_teacher ON Club(TeacherID, Year);
CREATE INDEX IF NOT EXISTS idx_club_deleted ON Club(IsDeleted);
-- ClubEnrollment 인덱스
CREATE INDEX IF NOT EXISTS idx_clubenrollment_student ON ClubEnrollment(EnrollmentNo);
CREATE INDEX IF NOT EXISTS idx_clubenrollment_club ON ClubEnrollment(ClubNo);
CREATE INDEX IF NOT EXISTS idx_clubenrollment_status ON ClubEnrollment(Status);
-- ClassDiary 인덱스
CREATE INDEX IF NOT EXISTS idx_classdiary_class ON ClassDiary(SchoolCode, Year, Semester, Grade, Class);
CREATE INDEX IF NOT EXISTS idx_classdiary_date ON ClassDiary(Date);
CREATE INDEX IF NOT EXISTS idx_classdiary_teacher ON ClassDiary(TeacherID);
-- SeatArrangement / SeatAssignment 인덱스
CREATE INDEX IF NOT EXISTS idx_seatarr_class ON SeatArrangement(SchoolCode, Year, Grade, Class);
CREATE INDEX IF NOT EXISTS idx_seatassign_arr ON SeatAssignment(ArrangementNo);
CREATE INDEX IF NOT EXISTS idx_seatassign_student ON SeatAssignment(StudentID);
-- SeatHistory 인덱스 (짝 이력)
CREATE INDEX IF NOT EXISTS idx_seathistory_class ON SeatHistory(SchoolCode, Year, Grade, Class, Round DESC);
CREATE INDEX IF NOT EXISTS idx_seathistory_students ON SeatHistory(StudentID_A, StudentID_B);
-- SeatPosHistory 인덱스 (자리 이력)
CREATE INDEX IF NOT EXISTS idx_seatposhistory_class ON SeatPosHistory(SchoolCode, Year, Grade, Class, Round DESC);
CREATE INDEX IF NOT EXISTS idx_seatposhistory_student ON SeatPosHistory(StudentID);
-- 좌석 이력 UNIQUE 인덱스: 같은 라운드에 같은 짝/자리가 중복 기록되는 것 방지.
-- 제약 없이 운영되던 시기의 중복 행을 먼저 정리해야 인덱스 생성이 실패하지 않는다.
DELETE FROM SeatHistory WHERE No NOT IN (
SELECT MIN(No) FROM SeatHistory
GROUP BY SchoolCode, Year, Grade, Class, Round, Kind, StudentID_A, StudentID_B);
CREATE UNIQUE INDEX IF NOT EXISTS ux_seathistory_pair
ON SeatHistory(SchoolCode, Year, Grade, Class, Round, Kind, StudentID_A, StudentID_B);
DELETE FROM SeatPosHistory WHERE No NOT IN (
SELECT MIN(No) FROM SeatPosHistory
GROUP BY SchoolCode, Year, Grade, Class, Round, StudentID);
CREATE UNIQUE INDEX IF NOT EXISTS ux_seatposhistory_student
ON SeatPosHistory(SchoolCode, Year, Grade, Class, Round, StudentID);
";
await cmd.ExecuteNonQueryAsync();
Debug.WriteLine(" ✓ SchoolSchedule 인덱스 생성");
Debug.WriteLine("[DatabaseInitializer] 인덱스 생성 완료");
}
/// <summary>
/// 세대 변환. <see cref="SchemaVersion"/> 을 올릴 때마다 <c>if (version < N) { …변환… }</c>
/// 한 덩이를 여기에 더한다. 테이블 생성보다 <b>먼저</b> 돈다 — 버릴 테이블을 지운 뒤에
/// 남길 테이블을 만들어야 순서가 꼬이지 않는다. 새 번호는 초기화 끝에
/// <see cref="StampSchemaVersionAsync"/> 가 찍는다.
/// </summary>
private async Task MigrateSchemaAsync()
{
if (_connection == null) return;
int version = await ReadSchemaVersionAsync();
// 지금 판보다 <b>새</b> 자료 파일이다(내려 깔았거나, 새 판에서 만든 파일을 가져왔다).
// 여기서 변환을 돌리면 새 판이 만든 모양을 옛 규칙으로 건드리게 된다 — 손대지 않고
// 넘어가되, 사실은 남긴다. 세대 표식도 내려 찍지 않는다(StampSchemaVersionAsync).
if (version > SchemaVersion)
{
Logging.Log.Warning("DatabaseInitializer",
$"자료 파일의 세대({version})가 이 판({SchemaVersion})보다 새롭다 — 변환을 건너뛴다");
return;
}
// 변환 전체를 한 덩이로 묶는다 — 중간에 끊기면(전원·잠금·공간) 반만 바뀐 자료가
// 남고, 그 상태는 어느 세대도 아니라서 다음 실행이 무엇을 해야 할지 알 수 없다.
// 지금 변환들은 각각 한 문장뿐이라 실질적 차이가 없지만, 다음 변환은 그렇지 않을 수 있다.
using var tx = _connection.BeginTransaction();
using var cmd = _connection.CreateCommand();
cmd.Transaction = tx;
// 새 DB 는 0 으로 시작해 아래를 모두 훑지만, 전부 IF EXISTS 라 실질적으로 no-op 이다.
if (version < 2)
{
// 수업 일지가 게시판 한 곳으로 모이면서 읽는 코드가 사라졌다.
cmd.CommandText = "DROP TABLE IF EXISTS LessonLog;";
await cmd.ExecuteNonQueryAsync();
Debug.WriteLine("[DatabaseInitializer] 마이그레이션 2: LessonLog 테이블 제거");
}
if (version < 3)
{
// 주차별 시수의 키를 (수업, 학급, 주차번호) → (수업, 학급, 주 시작일) 로 옮긴다.
//
// 옛 키로 쌓인 데이터에는 같은 주 시작일이 두 줄일 수 있다(학기 구간이 바뀌어
// 번호가 밀린 뒤 다시 고친 경우). 새 UNIQUE 인덱스를 그대로 만들면 거기서
// 실패하므로, 먼저 겹치는 줄을 정리한다 — <b>나중에 적은 것(No 가 큰 것)</b>을
// 남긴다. 그것이 교사가 마지막으로 고친 값이다.
cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table' AND name='CourseWeeklyHours';";
bool exists = await cmd.ExecuteScalarAsync() != null;
if (exists)
{
cmd.CommandText = @"
DELETE FROM CourseWeeklyHours
WHERE No NOT IN (
SELECT MAX(No) FROM CourseWeeklyHours
GROUP BY CourseNo, Room, WeekStartDate
);";
int removed = await cmd.ExecuteNonQueryAsync();
cmd.CommandText = "DROP INDEX IF EXISTS idx_courseweeklyhours_unique;";
await cmd.ExecuteNonQueryAsync();
Debug.WriteLine($"[DatabaseInitializer] 마이그레이션 3: 주차별 시수 키 전환(겹친 {removed}줄 정리)");
}
}
tx.Commit();
}
/// <summary>현재 자료 파일에 찍힌 세대 번호. 찍힌 적이 없으면 0.</summary>
private async Task<int> ReadSchemaVersionAsync()
{
if (_connection == null) return 0;
using var cmd = _connection.CreateCommand();
cmd.CommandText = "PRAGMA user_version;";
return Convert.ToInt32(await cmd.ExecuteScalarAsync() ?? 0);
}
/// <summary>
/// 스키마 세대 번호를 <c>PRAGMA user_version</c> 에 기록한다.
/// 이미 같은 값이 찍혀 있으면 실질적으로 no-op. (PRAGMA 는 파라미터 바인딩을
/// 지원하지 않지만 <see cref="SchemaVersion"/> 은 코드 내 int 상수라 주입 위험이 없다.)
///
/// <para>⚠ <b>내려 찍지 않는다.</b> 예전에는 조건 없이 대입해서, 새 판으로 쓰던 자료를
/// 옛 판으로 한 번만 열어도 표식이 내려앉았다. 그러면 다음에 새 판으로 열 때 이미 끝난
/// 변환이 다시 돌고(지금 변환들은 여러 번 돌아도 같은 결과지만 앞으로도 그러리라는 보장이
/// 없다), 무엇보다 <b>"이 파일은 더 새 판에서 왔다" 는 사실이 지워진다</b>.</para>
/// </summary>
private async Task StampSchemaVersionAsync()
{
if (_connection == null) return;
int current = await ReadSchemaVersionAsync();
if (current > SchemaVersion)
{
Logging.Log.Warning("DatabaseInitializer",
$"자료 파일의 세대({current})를 이 판({SchemaVersion})으로 낮춰 찍지 않는다");
return;
}
using var cmd = _connection.CreateCommand();
cmd.CommandText = $"PRAGMA user_version = {SchemaVersion};";
await cmd.ExecuteNonQueryAsync();
Debug.WriteLine($"[DatabaseInitializer] 스키마 버전 기록: {SchemaVersion}");
}
/// <summary>
/// 외래키(PRAGMA foreign_keys)가 꺼진 채 운영되던 시기에 쌓였을 수 있는 고아 행 정리.
/// BaseRepository 가 연결마다 foreign_keys=ON 을 켜므로, 기존 위반 데이터를 남겨두면
/// 이후 UPDATE 시 FK 위반으로 실패한다. 스키마의 CASCADE/SET NULL 의도대로 일괄 보정한다.
///
/// <para><b>여기가 보는 방향은 하나뿐이다 — "부모 없는 자식".</b> 반대 방향
/// (자식 없는 부모, 특히 <c>Enrollment</c> 가 하나도 없는 <c>Student</c>)은
/// <b>일부러 치우지 않는다.</b></para>
///
/// <para>학생 관리의 [명부에서 빼기]는 학적만 지우므로, 마지막 학적을 빼면 그런
/// <c>Student</c> 행이 생긴다. 그 학생은 어느 학년도 명부에도 안 나오면서 누가기록·
/// 학생부·사진을 그대로 달고 DB 에 남는다. 쌓이는 것은 맞다. 그런데 여기서 지우면
/// <b>앱을 켜는 순간, 아무도 시키지 않았는데, 되돌릴 수 없게</b> 그 기록들이 함께
/// 사라진다(<c>Student</c> 에 걸린 CASCADE 가 전부 깨어난다). 자동 정리가 감당할
/// 무게가 아니다.</para>
///
/// <para>대신 <b>만들어지기 전에 알린다</b> — 마지막 학적을 빼려 하면 학생 관리 화면이
/// 이름을 들어 경고한다(<c>StudentManagementPage.FindLastEnrollmentsAsync</c>).
/// 진짜로 지우는 기능이 필요해지면 그때는 사용자가 무엇을 지우는지 보고 고르게 해야
/// 한다 — 근거는 <c>FEATURES.md</c> 의 "학생을 완전히 지우는 기능" 항목.</para>
/// </summary>
private async Task CleanupOrphansAsync()
{
if (_connection == null) return;
using var cmd = _connection.CreateCommand();
// 자식 행이 참조하는 SchoolCode 가 School 에 없으면 이후 해당 행 수정이 FK 위반으로 막히므로
// 최소 정보의 School 행을 만들어 참조를 살린다 (초기 설정 누락 등 방어)
cmd.CommandText = @"
INSERT INTO School (SchoolCode, SchoolName, CreatedAt, UpdatedAt)
SELECT DISTINCT SchoolCode, SchoolCode, @Now, @Now FROM (
SELECT SchoolCode FROM Enrollment
UNION SELECT SchoolCode FROM Course
UNION SELECT SchoolCode FROM Club
UNION SELECT SchoolCode FROM ClassTimetable
UNION SELECT SchoolCode FROM ClassDiary
UNION SELECT SchoolCode FROM TeacherSchoolHistory
)
WHERE SchoolCode NOT IN (SELECT SchoolCode FROM School);
-- CASCADE 관계: 부모가 사라진 자식 행 삭제 (부모→자식 순서 유지)
DELETE FROM StudentDetail WHERE StudentID NOT IN (SELECT StudentID FROM Student);
DELETE FROM TeacherSchoolHistory WHERE TeacherID NOT IN (SELECT TeacherID FROM Teacher);
DELETE FROM Enrollment WHERE StudentID NOT IN (SELECT StudentID FROM Student);
DELETE FROM Course WHERE TeacherID NOT IN (SELECT TeacherID FROM Teacher);
DELETE FROM CourseEnrollment
WHERE EnrollmentNo NOT IN (SELECT No FROM Enrollment)
OR CourseNo NOT IN (SELECT No FROM Course);
DELETE FROM Lesson WHERE Course NOT IN (SELECT No FROM Course);
DELETE FROM StudentLog WHERE StudentID NOT IN (SELECT StudentID FROM Student);
-- 누가기록 첨부는 StudentLog 뒤에 지운다(부모→자식 순서). 위 DELETE 가
-- CASCADE 로 함께 지워 주기를 기대하지 않는다 — 이 연결에 foreign_keys 가
-- 켜져 있는지에 결과가 달라지면 안 된다.
-- ⚠ 여기서 사라지는 것은 DB 행뿐이다. 폴더에 남은 실물은
-- StudentLogAttachments.CleanupOrphanFilesAsync 가 치운다.
DELETE FROM StudentLogFile WHERE LogNo NOT IN (SELECT No FROM StudentLog);
DELETE FROM StudentSpecial WHERE StudentID NOT IN (SELECT StudentID FROM Student);
DELETE FROM Club WHERE TeacherID NOT IN (SELECT TeacherID FROM Teacher);
DELETE FROM ClubEnrollment
WHERE EnrollmentNo NOT IN (SELECT No FROM Enrollment)
OR ClubNo NOT IN (SELECT No FROM Club);
DELETE FROM SeatAssignment WHERE ArrangementNo NOT IN (SELECT No FROM SeatArrangement);
-- 좌석 셋은 StudentID 를 직접 들고 있으면서 FK 가 없다(설계 근거는
-- docs/enrollment-redesign.md 7.5). FK 가 없으니 학생을 지워도 아무것도
-- 따라 지워지지 않아, 없는 학생을 가리키는 행이 영영 쌓인다.
--
-- 좌석은 NULL 로 비운다 — 그 행에는 미사용·숨김·고정 같은 배치 상태도 함께
-- 들어 있어서, 지우면 자리 모양이 무너진다. 화면도 짝이 없는 StudentID 는
-- 빈 자리로 그리므로(PageSeats 의 복원 루프) 결과가 같다.
UPDATE SeatAssignment SET StudentID = NULL
WHERE StudentID IS NOT NULL
AND StudentID NOT IN (SELECT StudentID FROM Student);
-- 이력 둘은 지운다. 배치 상태를 함께 들고 있지 않아 남길 것이 없고,
-- 없는 학생의 지난 짝·지난 자리는 다음 배치에서 쓸 일이 없다.
DELETE FROM SeatHistory
WHERE StudentID_A NOT IN (SELECT StudentID FROM Student)
OR StudentID_B NOT IN (SELECT StudentID FROM Student);
DELETE FROM SeatPosHistory
WHERE StudentID NOT IN (SELECT StudentID FROM Student);