In this note, I will describe the basic principles of the famous classic Crud pattern, the implementation in Swift. Swift is an open, cross -platform language available for Windows, Linux, MacOS, iOS, Android.
There are many decisions of abstracting the storage of data and the logic of the application.One of these solutions is the CRUD approach, this is acronym from C – Create, R -read, U – Update, D – Delete.
Typically, the implementation of this principle is ensured by implementing the interface to the database in which work with elements occurs using a unique identifier, such as ID.An interface is created for each letter Crud – Create (Object, ID), Read (ID), Update (Object, ID), Delete (Object, ID).
If the object contains ID inside itself, then the ID argument can be missed in terms of the methods (Create, Update, Delete), since the object is transmitted there entirely with its field – ID.But for – Read requires ID, as we want to get an object from the database by the identifier.
All names are fictitious
Imagine that the Assistantai hypothetical application was created using an Etherrelm free SDK database, the integration was simple, the API was very convenient, as a result, the application was released into markets.
Suddenly, the SDK Etherrelm developer decides to make it paid, setting the price of $ 100 per year per one user of the application.
What?Yes!What now to do to developers from Assistantai, because they already have 1 million active users!Pay $ 100 million?
Instead, it is decided to evaluate the transfer of the application to the Rootdata database, according to programmers, such a transfer will take about six months, this without taking into account the implementation of new features in the application.After short thoughts, it is decided to remove the application from the markets, rewrite it on another free cross -platform framework with a Buems built -in database, this will solve the problem with the database + will simplify the development for other platforms.
A year later, the application is rewritten to Buems, but then suddenly the developer of the framework decides to make it paid.It turns out that the team got into the same trap twice, whether they will manage to get out for the second time, this is a completely different story.
Abstraction to help
These problems would be avoided if the developers used the abstraction of interfaces inside the application.For three whales of OOP – polymorphism, incapsulation, inheritance, not so long ago added one more abstraction.
The abstraction of the data allows you to describe ideas, models in general terms, with a minimum of details, while quite accurate for the implementation of specific implementations that are used to solve business abomes.
How can we abstract the work with the database so that the logic of the application does not depend on it?We use CRUD!
Simplified UML CRUD scheme looks like this:
An example with a fictional data database:
An example with a real SQLite database:
As you have already noticed, when you switch the database, only it changes, the CRUD interface with which the application interacts remains unchanged.CRUD is an option for the implementation of the GOF pattern – adapter, becauseWith it, we adapt the application interfaces to any database, combine incompatible interfaces.
The words are empty, show me the code
To implement abstractions in programming languages, interfaces/protocols/abstract classes are used.All these are phenomena of the same order, however, in interviews you may be asked to name the difference between them, I personally think that there is much sense in this becauseThe only purpose of use is the implementation of data abstraction, otherwise it is an interviewer memory test.
CRUD is often implemented as part of the repository pattern, the repository, however, can implement the CRUD interface, or may not be implemented, it all depends on the ingenuity of the developer.
Consider a rather typical SWIFT code of the Book structures repository, which works directly with the userDefaults database:
import Foundation
struct Book: Codable {
let title: String
let author: String
}
class BookRepository {
func save(book: Book) {
let record = try! JSONEncoder().encode(book)
UserDefaults.standard.set(record, forKey: book.title)
}
func get(bookWithTitle title: String) -> Book? {
guard let data = UserDefaults.standard.data(forKey: title) else { return nil }
let book = try! JSONDecoder().decode(Book.self, from: data)
return book
}
func delete(book: Book) {
UserDefaults.standard.removeObject(forKey: book.title)
}
}
let book = Book(title: "Fear and Loathing in COBOL", author: "Sir Edsger ZX Spectrum")
let repository = BookRepository()
repository.save(book: book)
print(repository.get(bookWithTitle: book.title)!)
repository.delete(book: book)
guard repository.get(bookWithTitle: book.title) == nil else {
print("Error: can't delete Book from repository!")
exit(1)
}
The code above seems simple, however, we calculate the number of violations of the principle of Dry (do not repeat youurself) and the connected code:
Continence to the userdefaults database
The connectedness of the json -jsonencoder, jsondecoder encoders and decoders
Correspondence with the Book structure, and we need an abstract repository in order not to create a repository class for each structure that we will store in the database (Dry Violation)
I meet such a CRUD code of the repository quite often, it is possible to use it, but high connectedness, duplication of code, lead to the fact that over time its support will become very complicated.This will be especially felt when trying to switch to another database, or when the internal logic of working with the database in all repository created in the application.
Instead of duplicating the code, keeping high connectedness – we will write a protocol for the CRUD repository, thus abstracting the interface of the database and business logic of the application, observing DRY, carrying out low connectedness:
protocol CRUDRepository {
typealias Item = Codable
typealias ItemIdentifier = String
func create(id: CRUDRepository.ItemIdentifier, item: T) async throws
func read(id: CRUDRepository.ItemIdentifier) async throws -> T
func update(id: CRUDRepository.ItemIdentifier, item: T) async throws
func delete(id: CRUDRepository.ItemIdentifier) async throws
}
The Crudrepository protocol describes the interfaces and associated data types for the further implementation of a particular CRUD repository.
Next, write a specific implementation for the UserDefaults database:
class UserDefaultsRepository: CRUDRepository {
private typealias RecordIdentifier = String
let tableName: String
let dataTransformer: DataTransformer
init(
tableName: String = "",
dataTransformer: DataTransformer = JSONDataTransformer()
) {
self.tableName = tableName
self.dataTransformer = dataTransformer
}
private func key(id: CRUDRepository.ItemIdentifier) -> RecordIdentifier {
"database_\(tableName)_item_\(id)"
}
private func isExists(id: CRUDRepository.ItemIdentifier) async throws -> Bool {
UserDefaults.standard.data(forKey: key(id: id)) != nil
}
func create(id: CRUDRepository.ItemIdentifier, item: T) async throws {
let data = try await dataTransformer.encode(item)
UserDefaults.standard.set(data, forKey: key(id: id))
UserDefaults.standard.synchronize()
}
func read(id: CRUDRepository.ItemIdentifier) async throws -> T {
guard let data = UserDefaults.standard.data(forKey: key(id: id)) else {
throw CRUDRepositoryError.recordNotFound(id: id)
}
let item: T = try await dataTransformer.decode(data: data)
return item
}
func update(id: CRUDRepository.ItemIdentifier, item: T) async throws {
guard try await isExists(id: id) else {
throw CRUDRepositoryError.recordNotFound(id: id)
}
let data = try await dataTransformer.encode(item)
UserDefaults.standard.set(data, forKey: key(id: id))
UserDefaults.standard.synchronize()
}
func delete(id: CRUDRepository.ItemIdentifier) async throws {
guard try await isExists(id: id) else {
throw CRUDRepositoryError.recordNotFound(id: id)
}
UserDefaults.standard.removeObject(forKey: key(id: id))
UserDefaults.standard.synchronize()
}
}
The code looks long, but contains a complete specific implementation of CRUD repository containing weak contact, details further.
Typealias’s are added to make code smug.
Weakness and strong connectivity
The damping from a specific structure (Struct) is implemented using geneticist T, which in turn should implement CODable protocols.Codable allows you to transform structures using classes that implement the Toplevelencoder and ToplevelDecoder protocols, such as JSONENCODER and JSONDECODER, using the basic types (Int, String, Float, etc.), there is no need to write additional code for transforming structures.
The damping from a particular encoder and decoder occurs using abstracting in the Datatransformer protocol:
protocol DataTransformer {
func encode(_ object: T) async throws -> Data
func decode(data: Data) async throws -> T
}
Using the implementation of the Date-transformer, we implemented an abstraction of the enkoder and decoder interfaces, having implemented a weak connectedness to ensure work with various types of data formats.
The following is the code of a particular Datatransformer, namely for JSON:
class JSONDataTransformer: DataTransformer {
func encode(_ object: T) async throws -> Data where T : Encodable {
let data = try JSONEncoder().encode(object)
return data
}
func decode(data: Data) async throws -> T where T : Decodable {
let item: T = try JSONDecoder().decode(T.self, from: data)
return item
}
}
And so it was possible?
What has changed?Now it is enough to initiate a specific repository for working with any structure that the CODBLE protocol implits, in this way the need for the duplication of code disappears, and a faint connected application is realized.
An example of a client CRUD with a specific repository, the database is userdefaults, the JSON data format, the Client structure, also an example of the recording and reading of the array:
import Foundation
print("One item access example")
do {
let clientRecordIdentifier = "client"
let clientOne = Client(name: "Chill Client")
let repository = UserDefaultsRepository(
tableName: "Clients Database",
dataTransformer: JSONDataTransformer()
)
try await repository.create(id: clientRecordIdentifier, item: clientOne)
var clientRecord: Client = try await repository.read(id: clientRecordIdentifier)
print("Client Name: \(clientRecord.name)")
clientRecord.name = "Busy Client"
try await repository.update(id: clientRecordIdentifier, item: clientRecord)
let updatedClient: Client = try await repository.read(id: clientRecordIdentifier)
print("Updated Client Name: \(updatedClient.name)")
try await repository.delete(id: clientRecordIdentifier)
let removedClientRecord: Client = try await repository.read(id: clientRecordIdentifier)
print(removedClientRecord)
}
catch {
print(error.localizedDescription)
}
print("Array access example")
let clientArrayRecordIdentifier = "clientArray"
let clientOne = Client(name: "Chill Client")
let repository = UserDefaultsRepository(
tableName: "Clients Database",
dataTransformer: JSONDataTransformer()
)
let array = [clientOne]
try await repository.create(id: clientArrayRecordIdentifier, item: array)
let savedArray: [Client] = try await repository.read(id: clientArrayRecordIdentifier)
print(savedArray.first!)
At the first check, CRUD has been processed an exception in which the reading of the remote Aitem will already be unavailable.
Switch the database
Now I will show how to transfer the current code to another database.For example, I will take the code of the SQLite repository that ChatGPT generated:
import SQLite3
class SQLiteRepository: CRUDRepository {
private typealias RecordIdentifier = String
let tableName: String
let dataTransformer: DataTransformer
private var db: OpaquePointer?
init(
tableName: String,
dataTransformer: DataTransformer = JSONDataTransformer()
) {
self.tableName = tableName
self.dataTransformer = dataTransformer
self.db = openDatabase()
createTableIfNeeded()
}
private func openDatabase() -> OpaquePointer? {
var db: OpaquePointer? = nil
let fileURL = try! FileManager.default
.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
.appendingPathComponent("\(tableName).sqlite")
if sqlite3_open(fileURL.path, &db) != SQLITE_OK {
print("error opening database")
return nil
}
return db
}
private func createTableIfNeeded() {
let createTableString = """
CREATE TABLE IF NOT EXISTS \(tableName) (
id TEXT PRIMARY KEY NOT NULL,
data BLOB NOT NULL
);
"""
var createTableStatement: OpaquePointer? = nil
if sqlite3_prepare_v2(db, createTableString, -1, &createTableStatement, nil) == SQLITE_OK {
if sqlite3_step(createTableStatement) == SQLITE_DONE {
print("\(tableName) table created.")
} else {
print("\(tableName) table could not be created.")
}
} else {
print("CREATE TABLE statement could not be prepared.")
}
sqlite3_finalize(createTableStatement)
}
private func isExists(id: CRUDRepository.ItemIdentifier) async throws -> Bool {
let queryStatementString = "SELECT data FROM \(tableName) WHERE id = ?;"
var queryStatement: OpaquePointer? = nil
if sqlite3_prepare_v2(db, queryStatementString, -1, &queryStatement, nil) == SQLITE_OK {
sqlite3_bind_text(queryStatement, 1, id, -1, nil)
if sqlite3_step(queryStatement) == SQLITE_ROW {
sqlite3_finalize(queryStatement)
return true
} else {
sqlite3_finalize(queryStatement)
return false
}
} else {
print("SELECT statement could not be prepared.")
throw CRUDRepositoryError.databaseError
}
}
func create(id: CRUDRepository.ItemIdentifier, item: T) async throws {
let insertStatementString = "INSERT INTO \(tableName) (id, data) VALUES (?, ?);"
var insertStatement: OpaquePointer? = nil
if sqlite3_prepare_v2(db, insertStatementString, -1, &insertStatement, nil) == SQLITE_OK {
let data = try await dataTransformer.encode(item)
sqlite3_bind_text(insertStatement, 1, id, -1, nil)
sqlite3_bind_blob(insertStatement, 2, (data as NSData).bytes, Int32(data.count), nil)
if sqlite3_step(insertStatement) == SQLITE_DONE {
print("Successfully inserted row.")
} else {
print("Could not insert row.")
throw CRUDRepositoryError.databaseError
}
} else {
print("INSERT statement could not be prepared.")
throw CRUDRepositoryError.databaseError
}
sqlite3_finalize(insertStatement)
}
func read(id: CRUDRepository.ItemIdentifier) async throws -> T {
let queryStatementString = "SELECT data FROM \(tableName) WHERE id = ?;"
var queryStatement: OpaquePointer? = nil
var item: T?
if sqlite3_prepare_v2(db, queryStatementString, -1, &queryStatement, nil) == SQLITE_OK {
sqlite3_bind_text(queryStatement, 1, id, -1, nil)
if sqlite3_step(queryStatement) == SQLITE_ROW {
let queryResultCol1 = sqlite3_column_blob(queryStatement, 0)
let queryResultCol1Length = sqlite3_column_bytes(queryStatement, 0)
let data = Data(bytes: queryResultCol1, count: Int(queryResultCol1Length))
item = try await dataTransformer.decode(data: data)
} else {
throw CRUDRepositoryError.recordNotFound(id: id)
}
} else {
print("SELECT statement could not be prepared")
throw CRUDRepositoryError.databaseError
}
sqlite3_finalize(queryStatement)
return item!
}
func update(id: CRUDRepository.ItemIdentifier, item: T) async throws {
guard try await isExists(id: id) else {
throw CRUDRepositoryError.recordNotFound(id: id)
}
let updateStatementString = "UPDATE \(tableName) SET data = ? WHERE id = ?;"
var updateStatement: OpaquePointer? = nil
if sqlite3_prepare_v2(db, updateStatementString, -1, &updateStatement, nil) == SQLITE_OK {
let data = try await dataTransformer.encode(item)
sqlite3_bind_blob(updateStatement, 1, (data as NSData).bytes, Int32(data.count), nil)
sqlite3_bind_text(updateStatement, 2, id, -1, nil)
if sqlite3_step(updateStatement) == SQLITE_DONE {
print("Successfully updated row.")
} else {
print("Could not update row.")
throw CRUDRepositoryError.databaseError
}
} else {
print("UPDATE statement could not be prepared.")
throw CRUDRepositoryError.databaseError
}
sqlite3_finalize(updateStatement)
}
func delete(id: CRUDRepository.ItemIdentifier) async throws {
guard try await isExists(id: id) else {
throw CRUDRepositoryError.recordNotFound(id: id)
}
let deleteStatementString = "DELETE FROM \(tableName) WHERE id = ?;"
var deleteStatement: OpaquePointer? = nil
if sqlite3_prepare_v2(db, deleteStatementString, -1, &deleteStatement, nil) == SQLITE_OK {
sqlite3_bind_text(deleteStatement, 1, id, -1, nil)
if sqlite3_step(deleteStatement) == SQLITE_DONE {
print("Successfully deleted row.")
} else {
print("Could not delete row.")
throw CRUDRepositoryError.databaseError
}
} else {
print("DELETE statement could not be prepared.")
throw CRUDRepositoryError.databaseError
}
sqlite3_finalize(deleteStatement)
}
}
Or CRUD Code for a repository for a file system that also generated ChatGPT:
import Foundation
class FileSystemRepository: CRUDRepository {
private typealias RecordIdentifier = String
let directoryName: String
let dataTransformer: DataTransformer
private let fileManager = FileManager.default
private var directoryURL: URL
init(
directoryName: String = "Database",
dataTransformer: DataTransformer = JSONDataTransformer()
) {
self.directoryName = directoryName
self.dataTransformer = dataTransformer
let paths = fileManager.urls(for: .documentDirectory, in: .userDomainMask)
directoryURL = paths.first!.appendingPathComponent(directoryName)
if !fileManager.fileExists(atPath: directoryURL.path) {
try? fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil)
}
}
private func fileURL(id: CRUDRepository.ItemIdentifier) -> URL {
return directoryURL.appendingPathComponent("item_\(id).json")
}
private func isExists(id: CRUDRepository.ItemIdentifier) async throws -> Bool {
return fileManager.fileExists(atPath: fileURL(id: id).path)
}
func create(id: CRUDRepository.ItemIdentifier, item: T) async throws {
let data = try await dataTransformer.encode(item)
let url = fileURL(id: id)
try data.write(to: url)
}
func read(id: CRUDRepository.ItemIdentifier) async throws -> T {
let url = fileURL(id: id)
guard let data = fileManager.contents(atPath: url.path) else {
throw CRUDRepositoryError.recordNotFound(id: id)
}
let item: T = try await dataTransformer.decode(data: data)
return item
}
func update(id: CRUDRepository.ItemIdentifier, item: T) async throws {
guard try await isExists(id: id) else {
throw CRUDRepositoryError.recordNotFound(id: id)
}
let data = try await dataTransformer.encode(item)
let url = fileURL(id: id)
try data.write(to: url)
}
func delete(id: CRUDRepository.ItemIdentifier) async throws {
guard try await isExists(id: id) else {
throw CRUDRepositoryError.recordNotFound(id: id)
}
let url = fileURL(id: id)
try fileManager.removeItem(at: url)
}
}
We replace the repository in the client code:
import Foundation
print("One item access example")
do {
let clientRecordIdentifier = "client"
let clientOne = Client(name: "Chill Client")
let repository = FileSystemRepository(
directoryName: "Clients Database",
dataTransformer: JSONDataTransformer()
)
try await repository.create(id: clientRecordIdentifier, item: clientOne)
var clientRecord: Client = try await repository.read(id: clientRecordIdentifier)
print("Client Name: \(clientRecord.name)")
clientRecord.name = "Busy Client"
try await repository.update(id: clientRecordIdentifier, item: clientRecord)
let updatedClient: Client = try await repository.read(id: clientRecordIdentifier)
print("Updated Client Name: \(updatedClient.name)")
try await repository.delete(id: clientRecordIdentifier)
let removedClientRecord: Client = try await repository.read(id: clientRecordIdentifier)
print(removedClientRecord)
}
catch {
print(error.localizedDescription)
}
print("Array access example")
let clientArrayRecordIdentifier = "clientArray"
let clientOne = Client(name: "Chill Client")
let repository = FileSystemRepository(
directoryName: "Clients Database",
dataTransformer: JSONDataTransformer()
)
let array = [clientOne]
try await repository.create(id: clientArrayRecordIdentifier, item: array)
let savedArray: [Client] = try await repository.read(id: clientArrayRecordIdentifier)
print(savedArray.first!)
The initialization of userdefaultsrepository is replaced by FilesyStemrepository, with compatible arguments.
After launching the second version of the client code, you will find in the document folder the Clients Database Directory, which will contain a massif serialized in JSON with one Client structure.
Switching data storage format
Now we ask Chatgpt to generate an encoder and a decoder for XML:
class XMLDataTransformer: DataTransformer {
let formatExtension = "xml"
func encode(_ item: T) async throws -> Data {
let encoder = PropertyListEncoder()
encoder.outputFormat = .xml
return try encoder.encode(item)
}
func decode(data: Data) async throws -> T {
let decoder = PropertyListDecoder()
return try decoder.decode(T.self, from: data)
}
}
Thanks to built -in types in SWIFT, the task for the neural network becomes elementary.
Replace JSON with XML in the client code:
import Foundation
print("One item access example")
do {
let clientRecordIdentifier = "client"
let clientOne = Client(name: "Chill Client")
let repository = FileSystemRepository(
directoryName: "Clients Database",
dataTransformer: XMLDataTransformer()
)
try await repository.create(id: clientRecordIdentifier, item: clientOne)
var clientRecord: Client = try await repository.read(id: clientRecordIdentifier)
print("Client Name: \(clientRecord.name)")
clientRecord.name = "Busy Client"
try await repository.update(id: clientRecordIdentifier, item: clientRecord)
let updatedClient: Client = try await repository.read(id: clientRecordIdentifier)
print("Updated Client Name: \(updatedClient.name)")
try await repository.delete(id: clientRecordIdentifier)
let removedClientRecord: Client = try await repository.read(id: clientRecordIdentifier)
print(removedClientRecord)
}
catch {
print(error.localizedDescription)
}
print("Array access example")
let clientArrayRecordIdentifier = "clientArray"
let clientOne = Client(name: "Chill Client")
let repository = FileSystemRepository(
directoryName: "Clients Database",
dataTransformer: XMLDataTransformer()
)
let array = [clientOne]
try await repository.create(id: clientArrayRecordIdentifier, item: array)
let savedArray: [Client] = try await repository.read(id: clientArrayRecordIdentifier)
print(savedArray.first!)
The client code has changed only one expression jsondatatransformer -> XMldatratransformer
Result
CRUD repository is one of the design patterns that can be used to implement the weakness of the components of the application architecture.Another solution is the use of ORM (object mapping), if briefly, the ORM uses an approach in which the structures are completely tolerate on the database, and then changes with models should be displayed (flashing (!)) On the database.
But this is a completely different story.
The full implementation of CRUD repositories for SWIFT is available on the link:
https://gitlab.com/demensdeum/crud-example
By the way, SWIFT has long been supported outside MacOS, the code from the article was written and tested on Arch Linux.
Sources
https://developer.apple.com/documentation/combine/topleveldecoder
https://developer.apple.com/documentation/combine/toplevelencoder
https://en.wikipedia.org/wiki/Create,_read,_update_and_delete