1use linkrs_core::{DataType, Value};
2use serde::{Deserialize, Serialize};
3
4use crate::error::MigrationError;
5
6#[cfg(test)]
7mod tests {
8 use super::*;
9
10 #[test]
11 fn test_safety_level_ordering() {
12 assert!(SafetyLevel::Safe.is_safe());
13 assert!(!SafetyLevel::Warning.is_safe());
14 assert!(!SafetyLevel::Dangerous.is_safe());
15 assert_eq!(SafetyLevel::Safe.label(), "SAFE");
16 assert_eq!(SafetyLevel::Warning.label(), "WARNING");
17 assert_eq!(SafetyLevel::Dangerous.label(), "DANGEROUS");
18 }
19
20 #[test]
21 fn test_add_column_safety() {
22 let step = MigrationStep::AddColumn {
23 name: "email".into(),
24 data_type: DataType::String,
25 nullable: true,
26 default_value: None,
27 };
28 assert_eq!(step.safety_level(), SafetyLevel::Safe);
29 assert!(!step.is_data_modifying());
30 }
31
32 #[test]
33 fn test_drop_column_safety() {
34 let step = MigrationStep::DropColumn {
35 name: "email".into(),
36 };
37 assert_eq!(step.safety_level(), SafetyLevel::Dangerous);
38 assert!(step.is_data_modifying());
39 }
40
41 #[test]
42 fn test_rename_column_safety() {
43 let step = MigrationStep::RenameColumn {
44 old_name: "old".into(),
45 new_name: "new".into(),
46 };
47 assert_eq!(step.safety_level(), SafetyLevel::Warning);
48 assert!(step.is_data_modifying());
49 }
50
51 #[test]
52 fn test_convert_type_safety() {
53 let step = MigrationStep::ConvertType {
54 name: "age".into(),
55 from_type: DataType::Int,
56 to_type: DataType::BigInt,
57 };
58 assert_eq!(step.safety_level(), SafetyLevel::Warning);
59 assert!(step.is_data_modifying());
60 }
61
62 #[test]
63 fn test_step_reverse() {
64 let add = MigrationStep::AddColumn {
65 name: "x".into(),
66 data_type: DataType::String,
67 nullable: true,
68 default_value: None,
69 };
70 assert_eq!(
71 add.reverse(),
72 Some(MigrationStep::DropColumn { name: "x".into() })
73 );
74
75 assert_eq!(
76 MigrationStep::DropColumn { name: "x".into() }.reverse(),
77 None
78 );
79
80 let rename = MigrationStep::RenameColumn {
81 old_name: "a".into(),
82 new_name: "b".into(),
83 };
84 assert_eq!(
85 rename.reverse(),
86 Some(MigrationStep::RenameColumn {
87 old_name: "b".into(),
88 new_name: "a".into(),
89 })
90 );
91
92 let convert = MigrationStep::ConvertType {
93 name: "c".into(),
94 from_type: DataType::Int,
95 to_type: DataType::BigInt,
96 };
97 assert_eq!(
98 convert.reverse(),
99 Some(MigrationStep::ConvertType {
100 name: "c".into(),
101 from_type: DataType::BigInt,
102 to_type: DataType::Int,
103 })
104 );
105 }
106
107 #[test]
108 fn test_step_description() {
109 let step = MigrationStep::AddColumn {
110 name: "email".into(),
111 data_type: DataType::String,
112 nullable: false,
113 default_value: None,
114 };
115 assert!(step.description().contains("email"));
116 assert!(step.description().contains("String"));
117
118 let drop = MigrationStep::DropColumn { name: "x".into() };
119 assert!(drop.description().contains("x"));
120 assert!(drop.description().contains("lost"));
121 }
122
123 #[test]
124 fn test_empty_migration_plan() {
125 let plan = MigrationPlan::new(
126 MigrationTarget {
127 space: "test".into(),
128 label: "User".into(),
129 is_edge: false,
130 },
131 VersionRange { from: 1, to: 2 },
132 vec![],
133 0,
134 SafetyLevel::Safe,
135 None,
136 );
137 assert!(plan.is_empty());
138 assert!(plan.print_summary().contains("SAFE"));
139 }
140
141 #[test]
142 fn test_migration_report() {
143 let report = MigrationReport {
144 success: true,
145 steps_completed: 3,
146 rows_migrated: 100,
147 errors: vec![],
148 completed_step_indices: vec![],
149 };
150 assert!(report.print_summary().contains("SUCCESS"));
151 assert!(report.print_summary().contains("100"));
152
153 let failed = MigrationReport {
154 success: false,
155 steps_completed: 1,
156 rows_migrated: 0,
157 errors: vec!["Error converting value".into()],
158 completed_step_indices: vec![],
159 };
160 assert!(failed.print_summary().contains("FAILED"));
161 assert!(failed.print_summary().contains("Error converting value"));
162 }
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
166pub enum SafetyLevel {
167 Safe,
168 Warning,
169 Dangerous,
170}
171
172impl SafetyLevel {
173 pub fn is_safe(&self) -> bool {
174 matches!(self, SafetyLevel::Safe)
175 }
176
177 pub fn label(&self) -> &'static str {
178 match self {
179 SafetyLevel::Safe => "SAFE",
180 SafetyLevel::Warning => "WARNING",
181 SafetyLevel::Dangerous => "DANGEROUS",
182 }
183 }
184}
185
186#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
187pub enum MigrationStep {
188 AddColumn {
189 name: String,
190 data_type: DataType,
191 nullable: bool,
192 default_value: Option<Value>,
193 },
194 DropColumn {
195 name: String,
196 },
197 RenameColumn {
198 old_name: String,
199 new_name: String,
200 },
201 ConvertType {
202 name: String,
203 from_type: DataType,
204 to_type: DataType,
205 },
206 SetDefault {
207 name: String,
208 default_value: Option<Value>,
209 },
210 ChangeNullability {
211 name: String,
212 was_nullable: bool,
213 now_nullable: bool,
214 },
215 CreateLabel {
216 label_name: String,
217 },
218 DropLabel {
219 label_name: String,
220 },
221 CreateEdgeType {
222 edge_type_name: String,
223 },
224 DropEdgeType {
225 edge_type_name: String,
226 },
227}
228
229impl MigrationStep {
230 pub fn safety_level(&self) -> SafetyLevel {
231 match self {
232 MigrationStep::AddColumn { .. } => SafetyLevel::Safe,
233 MigrationStep::DropColumn { .. } => SafetyLevel::Dangerous,
234 MigrationStep::RenameColumn { .. } => SafetyLevel::Warning,
235 MigrationStep::ConvertType { .. } => SafetyLevel::Warning,
236 MigrationStep::SetDefault { .. } => SafetyLevel::Safe,
237 MigrationStep::ChangeNullability { .. } => SafetyLevel::Warning,
238 MigrationStep::CreateLabel { .. } => SafetyLevel::Safe,
239 MigrationStep::DropLabel { .. } => SafetyLevel::Dangerous,
240 MigrationStep::CreateEdgeType { .. } => SafetyLevel::Safe,
241 MigrationStep::DropEdgeType { .. } => SafetyLevel::Dangerous,
242 }
243 }
244
245 pub fn is_data_modifying(&self) -> bool {
246 matches!(
247 self,
248 MigrationStep::DropColumn { .. }
249 | MigrationStep::RenameColumn { .. }
250 | MigrationStep::ConvertType { .. }
251 | MigrationStep::SetDefault { .. }
252 )
253 }
254
255 pub fn is_schema_modifying(&self) -> bool {
256 matches!(
257 self,
258 MigrationStep::CreateLabel { .. }
259 | MigrationStep::DropLabel { .. }
260 | MigrationStep::CreateEdgeType { .. }
261 | MigrationStep::DropEdgeType { .. }
262 )
263 }
264
265 pub fn description(&self) -> String {
266 match self {
267 MigrationStep::AddColumn {
268 name,
269 data_type: dt,
270 nullable,
271 ..
272 } => {
273 format!(
274 "Add column '{}' of type {:?} (nullable: {})",
275 name, dt, nullable
276 )
277 }
278 MigrationStep::DropColumn { name } => {
279 format!("Drop column '{}' - existing data will be lost", name)
280 }
281 MigrationStep::RenameColumn { old_name, new_name } => {
282 format!("Rename column '{}' to '{}'", old_name, new_name)
283 }
284 MigrationStep::ConvertType {
285 name,
286 from_type,
287 to_type,
288 } => {
289 format!(
290 "Convert column '{}' from {:?} to {:?}",
291 name, from_type, to_type
292 )
293 }
294 MigrationStep::SetDefault {
295 name,
296 default_value,
297 } => {
298 format!(
299 "Set default value for column '{}' to {:?}",
300 name, default_value
301 )
302 }
303 MigrationStep::ChangeNullability {
304 name,
305 was_nullable,
306 now_nullable,
307 } => {
308 format!(
309 "Change column '{}' nullability from {} to {}",
310 name, was_nullable, now_nullable
311 )
312 }
313 MigrationStep::CreateLabel { label_name } => {
314 format!("Create label '{}'", label_name)
315 }
316 MigrationStep::DropLabel { label_name } => {
317 format!("Drop label '{}' - all data will be lost", label_name)
318 }
319 MigrationStep::CreateEdgeType { edge_type_name } => {
320 format!("Create edge type '{}'", edge_type_name)
321 }
322 MigrationStep::DropEdgeType { edge_type_name } => {
323 format!(
324 "Drop edge type '{}' - all data will be lost",
325 edge_type_name
326 )
327 }
328 }
329 }
330
331 pub fn reverse(&self) -> Option<MigrationStep> {
332 match self {
333 MigrationStep::AddColumn { name, .. } => {
334 Some(MigrationStep::DropColumn { name: name.clone() })
335 }
336 MigrationStep::DropColumn { name: _ } => None,
337 MigrationStep::RenameColumn { old_name, new_name } => {
338 Some(MigrationStep::RenameColumn {
339 old_name: new_name.clone(),
340 new_name: old_name.clone(),
341 })
342 }
343 MigrationStep::ConvertType {
344 name,
345 from_type,
346 to_type,
347 } => Some(MigrationStep::ConvertType {
348 name: name.clone(),
349 from_type: to_type.clone(),
350 to_type: from_type.clone(),
351 }),
352 MigrationStep::SetDefault {
353 name,
354 default_value,
355 } => Some(MigrationStep::SetDefault {
356 name: name.clone(),
357 default_value: default_value.clone(),
358 }),
359 MigrationStep::ChangeNullability {
360 name,
361 was_nullable,
362 now_nullable,
363 } => Some(MigrationStep::ChangeNullability {
364 name: name.clone(),
365 was_nullable: *now_nullable,
366 now_nullable: *was_nullable,
367 }),
368 MigrationStep::CreateLabel { label_name } => Some(MigrationStep::DropLabel {
369 label_name: label_name.clone(),
370 }),
371 MigrationStep::DropLabel { .. } => None,
372 MigrationStep::CreateEdgeType { edge_type_name } => Some(MigrationStep::DropEdgeType {
373 edge_type_name: edge_type_name.clone(),
374 }),
375 MigrationStep::DropEdgeType { .. } => None,
376 }
377 }
378}
379
380const DEFAULT_BATCH_SIZE: usize = 1000;
381
382#[derive(Debug, Clone, Serialize, Deserialize)]
383pub struct MigrationTarget {
384 pub space: String,
385 pub label: String,
386 pub is_edge: bool,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize)]
390pub struct VersionRange {
391 pub from: u64,
392 pub to: u64,
393}
394
395fn compute_steps_hash(steps: &[MigrationStep]) -> String {
396 use sha2::{Digest, Sha256};
397 let json = serde_json::to_string(steps).unwrap_or_default();
398 let mut hasher = Sha256::new();
399 hasher.update(json.as_bytes());
400 let result = hasher.finalize();
401 result.iter().map(|b| format!("{b:02x}")).collect()
402}
403
404#[derive(Debug, Clone, Serialize, Deserialize)]
405pub struct MigrationPlan {
406 pub target: MigrationTarget,
407 pub version_range: VersionRange,
408 pub steps: Vec<MigrationStep>,
409 pub estimated_rows: u64,
410 pub overall_safety: SafetyLevel,
411 pub rollback_plan: Option<Box<MigrationPlan>>,
412 pub batch_size: usize,
413 pub completed_steps: Vec<usize>,
414 #[serde(default)]
415 pub plan_hash: String,
416 #[serde(default)]
417 pub dry_run: bool,
418 #[serde(default)]
419 pub created_at: Option<u64>,
420 #[serde(default)]
421 pub expand_contract: Option<bool>,
422}
423
424impl MigrationPlan {
425 pub fn new(
426 target: MigrationTarget,
427 version_range: VersionRange,
428 steps: Vec<MigrationStep>,
429 estimated_rows: u64,
430 overall_safety: SafetyLevel,
431 rollback_plan: Option<Box<MigrationPlan>>,
432 ) -> Self {
433 let hash = compute_steps_hash(&steps);
434 let now = std::time::SystemTime::now()
435 .duration_since(std::time::UNIX_EPOCH)
436 .map(|d| d.as_millis() as u64)
437 .unwrap_or(0);
438 Self {
439 target,
440 version_range,
441 steps,
442 estimated_rows,
443 overall_safety,
444 rollback_plan,
445 batch_size: DEFAULT_BATCH_SIZE,
446 completed_steps: Vec::new(),
447 plan_hash: hash,
448 dry_run: false,
449 created_at: Some(now),
450 expand_contract: None,
451 }
452 }
453
454 pub fn new_with_config(
455 target: MigrationTarget,
456 version_range: VersionRange,
457 steps: Vec<MigrationStep>,
458 estimated_rows: u64,
459 overall_safety: SafetyLevel,
460 rollback_plan: Option<Box<MigrationPlan>>,
461 config: &crate::config::MigrationConfig,
462 ) -> Self {
463 let mut plan = Self::new(
464 target,
465 version_range,
466 steps,
467 estimated_rows,
468 overall_safety,
469 rollback_plan,
470 );
471 if config.batch_size != 0 {
472 plan.batch_size = config.batch_size;
473 }
474 plan
475 }
476
477 pub fn compute_hash(&self) -> String {
478 compute_steps_hash(&self.steps)
479 }
480
481 pub fn refresh_hash(&mut self) {
482 self.plan_hash = compute_steps_hash(&self.steps);
483 }
484
485 pub fn is_empty(&self) -> bool {
486 self.steps.is_empty()
487 }
488
489 pub fn has_schema_modifying_steps(&self) -> bool {
490 self.steps.iter().any(|s| s.is_schema_modifying())
491 }
492
493 pub fn requires_backup(&self) -> bool {
496 self.steps.iter().any(|s| {
497 matches!(
498 s,
499 MigrationStep::DropColumn { .. }
500 | MigrationStep::DropLabel { .. }
501 | MigrationStep::DropEdgeType { .. }
502 )
503 })
504 }
505
506 pub fn dropped_columns(&self) -> Vec<String> {
508 self.steps
509 .iter()
510 .filter_map(|s| match s {
511 MigrationStep::DropColumn { name } => Some(name.clone()),
512 _ => None,
513 })
514 .collect()
515 }
516
517 pub fn remaining_steps(&self) -> Vec<usize> {
518 (0..self.steps.len())
519 .filter(|i| !self.completed_steps.contains(i))
520 .collect()
521 }
522
523 pub fn print_summary(&self) -> String {
524 let mut out = String::new();
525 out.push_str(&format!(
526 "Migration Plan: {} v{} → v{} ({} row(s))\n",
527 self.target.label, self.version_range.from, self.version_range.to, self.estimated_rows
528 ));
529 out.push_str(&format!(
530 "Safety: {} ({:?})\n",
531 self.overall_safety.label(),
532 self.overall_safety
533 ));
534 out.push_str(&format!("Steps: {}\n", self.steps.len()));
535 for (i, step) in self.steps.iter().enumerate() {
536 let prefix = if self.completed_steps.contains(&i) {
537 "[DONE] "
538 } else {
539 ""
540 };
541 out.push_str(&format!(
542 " {}. {}[{:?}] {}\n",
543 i + 1,
544 prefix,
545 step.safety_level(),
546 step.description()
547 ));
548 }
549 if self.rollback_plan.is_some() {
550 out.push_str("Rollback plan: Available\n");
551 }
552 out
553 }
554}
555
556#[derive(Debug, Clone, Serialize, Deserialize)]
557pub struct MigrationReport {
558 pub success: bool,
559 pub steps_completed: usize,
560 pub rows_migrated: u64,
561 pub errors: Vec<String>,
562 pub completed_step_indices: Vec<usize>,
563}
564
565impl MigrationReport {
566 pub fn print_summary(&self) -> String {
567 let status = if self.success { "SUCCESS" } else { "FAILED" };
568 let mut out = format!(
569 "Migration {}: {} step(s) completed, {} row(s) migrated, {} error(s)",
570 status,
571 self.steps_completed,
572 self.rows_migrated,
573 self.errors.len()
574 );
575 if !self.errors.is_empty() {
576 out.push_str("\nErrors:");
577 for (i, err) in self.errors.iter().enumerate() {
578 out.push_str(&format!("\n {}. {}", i + 1, err));
579 }
580 }
581 out
582 }
583}
584
585pub fn checkpoint_now_millis() -> u64 {
586 std::time::SystemTime::now()
587 .duration_since(std::time::UNIX_EPOCH)
588 .unwrap()
589 .as_millis() as u64
590}
591
592pub fn irreversible_guidance(plan: &MigrationPlan) -> Option<String> {
595 let mut items = Vec::new();
596 for step in &plan.steps {
597 match step {
598 MigrationStep::DropColumn { name } => items.push(format!(
599 "column '{}' on {}/{} is permanently removed; restore by re-adding the column and reloading data from a backup",
600 name, plan.target.space, plan.target.label
601 )),
602 MigrationStep::DropLabel { label_name } => items.push(format!(
603 "label '{}' on {} is permanently removed; restore by recreating the label and reloading all its vertices from a backup",
604 label_name, plan.target.space
605 )),
606 MigrationStep::DropEdgeType { edge_type_name } => items.push(format!(
607 "edge type '{}' on {} is permanently removed; restore by recreating the edge type and reloading all its edges from a backup",
608 edge_type_name, plan.target.space
609 )),
610 _ => {}
611 }
612 }
613 if items.is_empty() {
614 None
615 } else {
616 Some(format!(
617 "No automated rollback possible: {}",
618 items.join("; ")
619 ))
620 }
621}
622
623#[derive(Debug, Clone, Serialize, Deserialize)]
624pub enum StepResult {
625 Success,
626 Failed(String),
627}
628
629#[derive(Debug, Clone, Serialize, Deserialize)]
630pub struct MigrationCheckpoint {
631 pub completed_step_index: usize,
632 pub rows_migrated_before: u64,
633 pub rows_migrated_after: u64,
634 pub timestamp: u64,
635 pub step_result: StepResult,
636 #[serde(default)]
637 pub completed_steps: Vec<usize>,
638}
639
640impl MigrationCheckpoint {
641 pub fn save(
642 &self,
643 plan: &MigrationPlan,
644 storage_path: &std::path::Path,
645 ) -> Result<(), MigrationError> {
646 let checkpoint_file = storage_path.join(checkpoint_file_name(plan));
647 let content = serde_json::to_string_pretty(self)
648 .map_err(|e| MigrationError::Checkpoint(format!("serialize checkpoint: {e}")))?;
649 std::fs::write(&checkpoint_file, content)
650 .map_err(|e| MigrationError::Checkpoint(format!("write checkpoint: {e}")))?;
651 Ok(())
652 }
653
654 pub fn load(
655 plan: &MigrationPlan,
656 storage_path: &std::path::Path,
657 ) -> Result<Option<Self>, MigrationError> {
658 let checkpoint_file = storage_path.join(checkpoint_file_name(plan));
659 if !checkpoint_file.exists() {
660 return Ok(None);
661 }
662 let content = std::fs::read_to_string(&checkpoint_file)
663 .map_err(|e| MigrationError::Checkpoint(format!("read checkpoint: {e}")))?;
664 let checkpoint: Self = serde_json::from_str(&content)
665 .map_err(|e| MigrationError::Checkpoint(format!("parse checkpoint: {e}")))?;
666 Ok(Some(checkpoint))
667 }
668
669 pub fn cleanup(
670 plan: &MigrationPlan,
671 storage_path: &std::path::Path,
672 ) -> Result<(), MigrationError> {
673 let checkpoint_file = storage_path.join(checkpoint_file_name(plan));
674 if checkpoint_file.exists() {
675 std::fs::remove_file(&checkpoint_file)
676 .map_err(|e| MigrationError::Checkpoint(format!("remove checkpoint: {e}")))?;
677 }
678 Ok(())
679 }
680}
681
682pub(crate) fn push_escaped_path_component(out: &mut String, s: &str) {
685 for b in s.as_bytes() {
686 match b {
687 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' => out.push(*b as char),
688 _ => out.push_str(&format!("%{b:02X}")),
689 }
690 }
691}
692
693fn checkpoint_file_name(plan: &MigrationPlan) -> String {
698 let mut name = String::from("checkpoint_");
699 push_escaped_path_component(&mut name, &plan.target.space);
700 name.push('-');
701 push_escaped_path_component(&mut name, &plan.target.label);
702 name.push('-');
703 push_escaped_path_component(&mut name, &plan.plan_hash);
704 name.push_str(".json");
705 name
706}
707
708#[cfg(test)]
709mod checkpoint_tests {
710 use super::{checkpoint_now_millis, MigrationCheckpoint, MigrationTarget, StepResult};
711
712 fn make_plan(hash: &str) -> super::MigrationPlan {
713 let mut p = super::MigrationPlan::new(
714 MigrationTarget {
715 space: "s".into(),
716 label: "l".into(),
717 is_edge: false,
718 },
719 super::VersionRange { from: 1, to: 2 },
720 vec![],
721 0,
722 super::SafetyLevel::Safe,
723 None,
724 );
725 p.plan_hash = hash.to_string();
726 p
727 }
728
729 #[test]
730 fn test_checkpoint_save_load_cleanup() {
731 let tmp = tempfile::tempdir().unwrap();
732 let plan = make_plan("abc123");
733 let cp = MigrationCheckpoint {
734 completed_step_index: 1,
735 rows_migrated_before: 0,
736 rows_migrated_after: 10,
737 timestamp: checkpoint_now_millis(),
738 step_result: StepResult::Success,
739 completed_steps: vec![1],
740 };
741 cp.save(&plan, tmp.path()).unwrap();
742 let loaded = MigrationCheckpoint::load(&plan, tmp.path())
743 .unwrap()
744 .unwrap();
745 assert_eq!(loaded.completed_step_index, 1);
746 assert_eq!(loaded.rows_migrated_after, 10);
747 MigrationCheckpoint::cleanup(&plan, tmp.path()).unwrap();
748 assert!(MigrationCheckpoint::load(&plan, tmp.path())
749 .unwrap()
750 .is_none());
751 }
752
753 #[test]
754 fn test_checkpoint_missing_returns_none() {
755 let tmp = tempfile::tempdir().unwrap();
756 let plan = make_plan("hash2");
757 assert!(MigrationCheckpoint::load(&plan, tmp.path())
758 .unwrap()
759 .is_none());
760 }
761
762 #[test]
763 fn test_checkpoint_name_escapes_path_separators() {
764 let tmp = tempfile::tempdir().unwrap();
765 let mut p = super::MigrationPlan::new(
766 super::MigrationTarget {
767 space: "../evil".into(),
768 label: "a/b".into(),
769 is_edge: false,
770 },
771 super::VersionRange { from: 1, to: 2 },
772 vec![],
773 0,
774 super::SafetyLevel::Safe,
775 None,
776 );
777 p.plan_hash = "h1".to_string();
778 let cp = MigrationCheckpoint {
779 completed_step_index: 0,
780 rows_migrated_before: 0,
781 rows_migrated_after: 3,
782 timestamp: checkpoint_now_millis(),
783 step_result: StepResult::Success,
784 completed_steps: vec![0],
785 };
786 cp.save(&p, tmp.path()).unwrap();
787 let entries: Vec<_> = std::fs::read_dir(tmp.path())
788 .unwrap()
789 .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
790 .collect();
791 assert_eq!(entries.len(), 1);
792 assert!(entries[0].starts_with("checkpoint_"));
793 assert!(!entries[0].contains('/'));
794 let loaded = MigrationCheckpoint::load(&p, tmp.path()).unwrap().unwrap();
795 assert_eq!(loaded.rows_migrated_after, 3);
796 }
797}