CRUD Operations & Validation

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

In the last segment, you implemented loading tasks from disk. The app now remembers tasks after restart. However, there’s still a problem: you can’t save changes.

The missing piece is to write operations utilizing CRUD (Create, Read, Update, Delete). In this segment, you’ll complete the persistence layer by:

  • Adding saveTasks() to write tasks to disk.
  • Implementing updateTask() and deleteTask() in TaskManager with validation.
  • Creating JNI exports to bridge Swift methods to Kotlin.

By the end, you’ll have a complete CRUD system where every change persists immediately to disk.

Understanding the CRUD Pattern

CRUD is a standard acronym in software development representing the four fundamental operations for managing data:

Why CRUD Matters

CRUD provides a standard vocabulary for data operations. When you say “implement CRUD,” other developers immediately understand you’re building Create, Read, Update, and Delete functions. This consistency makes codebases easier to understand and maintain.

Architecture Overview

The data flow for mutations follows this path:

Kotlin UI → Repository → JNI Bridge → TaskManager → TaskStorage → tasks.json

Completing TaskStorage with saveTasks()

Earlier in the lesson, you implemented loadTasks() to read tasks from disk. Now you’ll complete TaskStorage by adding the write operation.

Implementing saveTasks()

Open taskmanager-lib/Sources/TaskManagerKit/TaskStorage.swift. Add this method after loadTasks():

public func saveTasks(_ tasks: [Task]) -> Result<Void, StorageError> {
  do {
    // 1
    let encoder = JSONEncoder()
    // 2
    encoder.outputFormatting = .prettyPrinted
    // 3
    let data = try encoder.encode(tasks)
    
    // 4
    try data.write(to: fileURL, options: [.atomic])
    
    // 5
    return .success(())
    
    // 6
  } catch let error as EncodingError {
    return .failure(.encodingFailed)
  } catch {
    return .failure(.writeFailed(error))
  }
}

Understanding Atomic Writes

The .atomic option deserves special attention because it’s crucial for data integrity:

Why Pretty Printed JSON?

During development, you’ll inspect tasks.json to debug issues. Compare these two formats:

[{"id":"123","title":"Buy groceries","description":"Milk, eggs, bread","priority":"high","isCompleted":false,"photoFilename":null}]
[
  {
    "id": "123",
    "title": "Buy groceries",
    "description": "Milk, eggs, bread",
    "priority": "high",
    "isCompleted": false,
    "photoFilename": null
  }
]

Understanding TaskValidator

Before adding update and delete operations, you need validation logic. The Starter project includes a TaskValidator class that centralizes validation rules. While you could put validation directly in TaskManager, separating it into a dedicated TaskValidator class provides clearer responsibilities and makes testing easier.

Why Separate Validation?

Consider what happens without a separate validator:

public func updateTask(_ updatedTask: Task) -> Bool {
  // Validation mixed with business logic
  let trimmed = updatedTask.title.trimmingCharacters(in: .whitespacesAndNewlines)
  guard trimmed.count >= 3 && trimmed.count <= 50 else {
    return false
  }
  // ... more validation ...
  // ... update logic ...
}
public func updateTask(_ updatedTask: Task) -> Bool {
  guard TaskValidator.validateTitle(updatedTask.title) else {
    return false
  }
  // ... update logic ...
}

Understanding TaskValidator.swift

The Starter project already includes TaskValidator.swift in Sources/TaskManagerKit/. This class was introduced in earlier lessons, so you don’t need to create it—just open it now to review how it works before using it in your CRUD operations:

import Foundation

public class TaskValidator {
  // 1
  private static let minTitleLength = 3
  private static let maxTitleLength = 50
  
  private static let minDescriptionLength = 10
  private static let maxDescriptionLength = 200
  
  // 2
  public static func validateTitle(_ title: String) -> Bool {
    let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
    return trimmed.count >= minTitleLength && trimmed.count <= maxTitleLength
  }
  
  // 3
  public static func validateDescription(_ description: String) -> Bool {
    let trimmed = description.trimmingCharacters(in: .whitespacesAndNewlines)
    return trimmed.count >= minDescriptionLength && trimmed.count <= maxDescriptionLength
  }
  
  // 4
  public static func validatePriority(_ priority: String) -> Bool {
    return Priority(rawValue: priority) != nil
  }
}

Understanding Static Methods

Notice all methods are static. This means you call them on the class itself (TaskValidator.validateTitle()), not on instances (let validator = TaskValidator(); validator.validateTitle()).

Completing TaskManager.addTask()

Before implementing update and delete, you need to complete the existing addTask() method. Currently, it has a TODO comment for saving tasks to disk.

Addressing the TODO

Open taskmanager-lib/Sources/TaskManagerKit/TaskManager.swift and locate the addTask() method:

@discardableResult
public func addTask(_ task: Task) -> Bool {
  guard TaskValidator.validateTitle(task.title),
        TaskValidator.validateDescription(task.description) else {
    return false
  }
  
  tasks.append(task)
  
  // TODO: Save tasks to file after adding
  
  return true
}

Adding Persistence

Replace the TODO comment with a call to saveTasks():

@discardableResult
public func addTask(_ task: Task) -> Bool {
  guard TaskValidator.validateTitle(task.title),
        TaskValidator.validateDescription(task.description) else {
    return false
  }
  
  tasks.append(task)
  
  _ = storage.saveTasks(tasks)
  
  return true
}

Updating Task Model for Photo Storage

Before implementing photo storage, you need to update the Task struct to reflect the proper semantics for how photos are stored.

public struct Task: Codable {
  public let id: String
  public let title: String
  public let description: String
  public let priority: Priority
  public var isCompleted: Bool
  public let photoUri: String?
  
  // TODO: In this lesson, you'll rename photoUri to photoFilename
  // and implement persistent storage via PhotoStorage
  
  public init(
    id: String,
    title: String,
    description: String,
    priority: Priority,
    isCompleted: Bool = false,
    photoUri: String? = nil
  ) {
    // ...
  }
}

Renaming the Field

Update the Task struct to use photoFilename:

public struct Task: Codable {
  public let id: String
  public let title: String
  public let description: String
  public let priority: Priority
  public var isCompleted: Bool
  public let photoFilename: String?
  
  public init(
    id: String,
    title: String,
    description: String,
    priority: Priority,
    isCompleted: Bool = false,
    photoFilename: String? = nil
  ) {
    self.id = id
    self.title = title
    self.description = description
    self.priority = priority
    self.isCompleted = isCompleted
    self.photoFilename = photoFilename
  }
}

Implementing PhotoStorage

Tasks can have associated photos. Now that your Task model uses photoFilename, you need a PhotoStorage class to handle photo file operations.

Why Photo Management Matters

Consider the lifecycle of task photos:

Understanding the Photos Directory

Photos are stored in a subdirectory of the app’s documents directory:

/data/data/com.kodeco.android.swiftsdkforandroid.taskmanager/
  └── files/              (documents directory)
      ├── tasks.json
      └── photos/         (photo storage)
          ├── 123.jpg
          └── 456.jpg

Creating PhotoStorage.swift

Create a new file taskmanager-lib/Sources/TaskManagerKit/PhotoStorage.swift:

import Foundation

// 1
enum PhotoStorageError: Error {
  case invalidPath
  case saveFailed(Error)
  case copyFailed(Error)
  case deleteFailed(Error)
}

class PhotoStorage {
  // 2
  private static func photosDirectory() -> URL? {
    guard let documentsDir = FileManager.default.urls(
      for: .documentDirectory,
      in: .userDomainMask
    ).first else {
      return nil
    }
    
    let photosDir = documentsDir.appendingPathComponent("photos")
    
    if !FileManager.default.fileExists(atPath: photosDir.path) {
      try? FileManager.default.createDirectory(
        at: photosDir,
        withIntermediateDirectories: true
      )
    }
    
    return photosDir
  }
  
  // 3
  public static func savePhoto(
    data: Data,
    withFilename filename: String,
    documentsPath: String
  ) -> Result<String, PhotoStorageError> {
    let photosPath = (documentsPath as NSString).appendingPathComponent("photos")
    let photosURL = URL(fileURLWithPath: photosPath)
    
    do {
      try FileManager.default.createDirectory(
        at: photosURL,
        withIntermediateDirectories: true,
        attributes: nil
      )
    } catch {
      return .failure(.saveFailed(error))
    }
    
    let destinationURL = photosURL.appendingPathComponent(filename)
    
    do {
      try data.write(to: destinationURL)
      return .success(filename)
    } catch {
      return .failure(.saveFailed(error))
    }
  }
  
  // 4
  public static func savePhoto(
    fromPath sourcePath: String,
    withFilename filename: String
  ) -> Result<String, PhotoStorageError> {
    guard let photosDir = photosDirectory() else {
      return .failure(.invalidPath)
    }
    
    let sourceURL = URL(fileURLWithPath: sourcePath)
    let destURL = photosDir.appendingPathComponent(filename)
    
    do {
      if FileManager.default.fileExists(atPath: destURL.path) {
        try FileManager.default.removeItem(at: destURL)
      }
      
      try FileManager.default.copyItem(at: sourceURL, to: destURL)
      return .success(filename)
    } catch {
      return .failure(.copyFailed(error))
    }
  }
  
  // 5
  public static func photoPath(for filename: String, documentsPath: String) -> String? {
    let photosDir = documentsPath + "/photos"
    let photoPath = photosDir + "/" + filename
    return photoPath
  }
  
  // 6
  public static func deletePhoto(filename: String) -> Result<Void, PhotoStorageError> {
    guard let photosDir = photosDirectory() else {
      return .failure(.invalidPath)
    }
    
    let photoURL = photosDir.appendingPathComponent(filename)
    
    do {
      if FileManager.default.fileExists(atPath: photoURL.path) {
        try FileManager.default.removeItem(at: photoURL)
      }
      return .success(())
    } catch {
      return .failure(.deleteFailed(error))
    }
  }
  
  // 7
  public static func photoExists(filename: String) -> Bool {
    guard let photosDir = photosDirectory() else {
      return false
    }
    
    let photoURL = photosDir.appendingPathComponent(filename)
    return FileManager.default.fileExists(atPath: photoURL.path)
  }
}

Why Static Methods?

Notice all public methods are static. This design choice serves multiple purposes:

Implementing TaskManager.updateTask()

With TaskStorage.saveTasks() in place, you can now implement the Update operation. When a user edits a task, you need to find the task, validate the new data, update it in memory, and persist to disk.

Why Update Needs Validation

In Segment 02, you focused on loading tasks. The data was already validated when it was created. But for updates, you must validate again because:

Implementing the Method

Open taskmanager-lib/Sources/TaskManagerKit/TaskManager.swift. Add this method after addTask():

@discardableResult
public func updateTask(_ updatedTask: Task) -> Bool {
  // 1
  guard let index = tasks.firstIndex(where: { $0.id == updatedTask.id }) else {
    return false
  }
  
  // 2
  guard TaskValidator.validateTitle(updatedTask.title),
        TaskValidator.validateDescription(updatedTask.description) else {
    return false
  }
  
  // 3
  let oldTask = tasks[index]
  if let oldPhoto = oldTask.photoFilename,
      oldPhoto != updatedTask.photoFilename {
    _ = PhotoStorage.deletePhoto(filename: oldPhoto)
  }
  
  // 4
  tasks[index] = updatedTask
  
  // 5
  _ = storage.saveTasks(tasks)
  
  // 6
  return true
}

Understanding @discardableResult

The @discardableResult attribute allows callers to ignore the return value without compiler warnings:

// Both are valid:
let success = manager.updateTask(task)  // Use the result
manager.updateTask(task)                // Ignore the result
./gradlew taskmanager-lib:clean
cd taskmanager-lib
swift build

Implementing TaskManager.deleteTask()

Deleting a task is simpler than updating, but it introduces a new concept: cascading deletes. When you delete a task, you must also delete the associated photo file (if it exists).

Implementing the Method

Add this method to TaskManager.swift after updateTask():

@discardableResult
public func deleteTask(id: String) -> Bool {
  // 1
  guard let index = tasks.firstIndex(where: { $0.id == id }) else {
    return false
  }
  
  // 2
  let task = tasks[index]
  
  // 3
  if let photoFilename = task.photoFilename {
    _ = PhotoStorage.deletePhoto(filename: photoFilename)
  }
  
  // 4
  tasks.remove(at: index)
  
  // 5
  _ = storage.saveTasks(tasks)
  
  // 6
  return true
}

Understanding Cascading Deletes

The term cascading delete comes from database design. In SQL databases with foreign keys, you can configure ON DELETE CASCADE to automatically delete child records when you delete a parent record.

Delete Task (parent)
  ↓
Check if task.photoFilename exists
  ↓
If yes, delete photo file (child)
  ↓
Remove task from array
  ↓
Persist to disk

Understanding Idempotent Operations

Notice that deleting a non-existent task returns false, but deleting a photo that doesn’t exist is silently ignored (returns success). Both are examples of idempotent operations—you can call them multiple times with the same result.

Implementing getTask(by:) for Individual Access

You’ve implemented Create (addTask), Update (updateTask), and Delete (deleteTask). To complete the CRUD pattern, you need the Read operation for individual tasks. While getTasks() returns all tasks, you often need to fetch a single task by ID.

public func getTask(by id: String) -> Task? {
  return tasks.first(where: { $0.id == id })
}

Completing CRUD

You’ve now implemented all four CRUD operations:

cd taskmanager-lib
swift build

Exposing Methods to Kotlin with swift-java

Your Swift code is complete and ready to use.

How swift-java Works

The swift-java plugin is a build tool that scans your Swift code and generates JNI bindings automatically. When you build the Android project:

The public static Pattern

For swift-java to expose your methods to Kotlin, they must follow a specific pattern. Looking at your TaskManager methods:

@discardableResult
public func updateTask(_ updatedTask: Task) -> Bool { ... }

@discardableResult
public func deleteTask(id: String) -> Bool { ... }

Adding the getShared() Accessor

swift-java cannot directly expose Swift’s static let shared property because static properties don’t map cleanly to JNI. Instead, you provide a static accessor method.

public static func getShared() -> TaskManager {
  return shared
}
val arena = SwiftArena.ofAuto()
val manager = TaskManager.getShared(arena)
manager.updateTask(task)  // Calls Swift TaskManager.updateTask()

Understanding Object Marshaling

When Kotlin calls manager.updateTask(task), how does the Kotlin Task object become a Swift Task object? swift-java generates marshaling code:

Kotlin Task → JSON serialization → JNI boundary → JSON deserialization → Swift Task
./gradlew taskmanager-lib:clean
cd taskmanager-lib
swift build

Key Takeaways

You’ve completed the Swift CRUD layer with validation, photo management, and automatic Kotlin bindings. Here’s what you learned:

See forum comments
Download course materials from Github
Previous: Persistent Load Workflow Next: Photo Storage & Kotlin Integration