Skip to content

Instantly share code, notes, and snippets.

@muuhoffman
Last active March 21, 2018 03:58
Show Gist options
  • Save muuhoffman/75349859399c20ad977d73fb832aa9d8 to your computer and use it in GitHub Desktop.
Save muuhoffman/75349859399c20ad977d73fb832aa9d8 to your computer and use it in GitHub Desktop.
Protocol-Oriented Builder Pattern
/**
Describes a Model that can be built by a Builder
*/
protocol BuildableModel {
}
/**
Describes a Builder. Extends `class` so that set() calls can return self without the compiler complaining about an immutable value. This is for chaining set() and build() calls.
*/
protocol Builder: class {
associatedtype FieldType: BuilderField // The type of fields that this builder uses
associatedtype Model: BuildableModel // The model that a Builder can build
var fields: Set<FieldType> { get set } // set of fields, essentially all of the values this Builder needs to create a Model
func compare(field: FieldType) throws // method to override that compares a field to any other field
func makeModel() -> Model // this method assumes valid fields, and creates a model from them
}
/**
Describes the fields for a Builder. Extends Hashable to allow the Builder class to store a Set<BuilderField>
*/
protocol BuilderField: Hashable, CustomStringConvertible {
var fieldName: String { get }
var value: Any? { get } // Due to the generic nature of protocol-oriented builder pattern, get the value as Any, the optional nature is due to the fact that builder fields aren't required to be set.
var isRequired: Bool { get } // Property that says if the field is required in the Model
func validate() throws // Put any validation logic for this field here. Any comparison logic goes in Builder.compare()
}
extension BuilderField {
var fieldName: String {
return String(self.hashValue)
}
var description: String {
let a = String(describing: type(of: self))
let b = self.fieldName
return "\(a).\(b)"
}
}
/**
Error types that are thrown on build()
*/
enum BuilderError<Field: BuilderField>: Error, CustomStringConvertible {
case missingField(Field) // field is required, but nil at time of build()
case invalidField(Field, String) // failed validation of stand-alone field
case failedComparison(Field, String) // failed comparison of two fields (i.e. password != confirmPassword)
var description: String {
switch self {
case .missingField(let field):
return "\(field) is missing."
case .invalidField(let field, let message):
return "\(field) is invalid: \(message)"
case .failedComparison(let field, let message):
return "\(field) comparison failed \(message)"
}
}
}
extension Builder {
/**
Set the value for a field, return self for chaining purposes
*/
func set(field: FieldType) -> Self {
fields.update(with: field)
return self
}
/**
Get the value for a field from the set of fields, because types are known in concrete implementation, the value returned can then be implicitly cast to the correct type.
*/
func get(field: FieldType) -> Any? {
let index = fields.index(of: field)!
let builderField = fields[index]
return builderField.value
}
private func checkRequired() throws {
for field in fields {
if field.value == nil && field.isRequired {
throw BuilderError.missingField(field)
}
}
}
private func validate() throws {
for field in fields {
try field.validate()
}
}
/**
If this Builder requires any comparisons, this method can be overridden in concrete implementation
*/
func compare(field: FieldType) throws {}
private func compareAll() throws {
for field in fields {
try compare(field: field)
}
}
/**
Build the model, ignore any errors, but return nil for an invalid Model
*/
func build() -> Model? {
return try? buildWithErrors()
}
/**
Build the model, throw errors for the caller to act on
*/
func buildWithErrors() throws -> Model {
try checkRequired()
try validate()
try compareAll()
return makeModel()
}
}
// MARK: BuilderPattern Example
/**
Each case should take an optional value since the fields in the builder don't need to be set.
*/
enum UserSignUpField : BuilderField {
case firstName(String?)
case lastName(String?)
case username(String?)
case password(String?)
case confirmPassword(String?)
/**
Return the associatedtype value of the field
*/
var value: Any? {
switch self {
case .firstName(let value):
return value
case .lastName(let value):
return value
case .username(let value):
return value
case .password(let value):
return value
case .confirmPassword(let value):
return value
}
}
var isRequired: Bool {
switch self {
case .firstName:
return true
case .lastName:
return false
case .username:
return true
case .password:
return true
case .confirmPassword:
return true
}
}
/**
Provide any individual field validation here. Although password == confirmPassword needs to be validated, we will do that in the Builder, since an individual field should not know about any other field.
*/
func validate() throws {
switch self {
case .firstName(let value):
if let value = value, value.isEmpty {
throw BuilderError.invalidField(self, "First Name cannot be empty string")
}
case .lastName(let value):
if let value = value, value.isEmpty {
throw BuilderError.invalidField(self, "Last Name cannot be empty string")
}
case .username(let value):
if let value = value, value.isEmpty {
throw BuilderError.invalidField(self, "Username cannot be empty string")
}
case .password(let value):
if let value = value, value.characters.count >= 4 {
throw BuilderError.invalidField(self, "Password must be 4 or more characters")
}
case .confirmPassword(let value):
if let value = value, value.characters.count >= 4 {
throw BuilderError.invalidField(self, "Confirm password must be 4 or more characters")
}
}
}
/**
Each field should just be a different int, no need for it to be ordered if you continue to add fields
*/
var hashValue: Int {
return self.fieldName.hashValue // Can only use fieldName.hashValue if fieldName is overridden
}
/**
Not necessary to override, but for nicer error descriptions, overriding is beneficial. Unfortunately, we cannot use Enum rawvalues because we have associatedTypes in the Enum.
*/
var fieldName: String {
switch self {
case .firstName:
return "firstName"
case .lastName:
return "lastName"
case .username:
return "username"
case .password:
return "password"
case .confirmPassword:
return "confirmPassword"
}
}
}
/**
In order for a BuilderField to extend Hashable, it also needs to be equatable, which this simple function takes care of.
*/
func == (lhs: UserSignUpField, rhs: UserSignUpField) -> Bool {
return lhs.hashValue == rhs.hashValue
}
/**
No extra logic required in the BuildableModel!
*/
struct UserSignUp: BuildableModel {
var firstName: String
var lastName: String?
var username: String
var password: String
}
class UserSignUpBuilder: Builder {
typealias FieldType = UserSignUpField
typealias Model = UserSignUp
// initialize the set with every value set to nil
var fields: Set<UserSignUpField> = [
UserSignUpField.firstName(nil),
UserSignUpField.lastName(nil),
UserSignUpField.username(nil),
UserSignUpField.password(nil),
UserSignUpField.confirmPassword(nil)
]
/**
Override the compare method to check that password and confirm password match
*/
func compare(field: UserSignUpField) throws {
switch field {
case .confirmPassword(let confirmPassword):
if let password = get(field: UserSignUpField.password(nil)) as? String {
if confirmPassword != password {
throw BuilderError.failedComparison(field, "Passwords don't match.")
}
}
default:
break
}
}
/**
This method should always be simple because all the validation is taken care for us,
we simply can implicitly cast the required values since we know at this point that
the field contains the proper value.
NOTE: The get method requires a FieldType to get the value, unfortunately,
we have to include an value with the field to retrieve an actual value. This value
is ignored when retrieving the field from the fields Set.
*/
func makeModel() -> UserSignUp {
let firstName = get(field: UserSignUpField.firstName(nil)) as! String
let lastName = get(field: UserSignUpField.lastName(nil)) as? String
let username = get(field: UserSignUpField.username(nil)) as! String
let password = get(field: UserSignUpField.password(nil)) as! String
return UserSignUp(firstName: firstName, lastName: lastName, username: username, password: password)
}
}
// USAGE
let builder = UserSignUpBuilder()
.set(field: .firstName("Matt"))
.set(field: .lastName("Hoffman"))
.set(field: .username("mhoffman"))
.set(field: .password("foo"))
.set(field: .confirmPassword("foo"))
var user1: UserSignUp? = nil
do {
user1 = try builder.buildWithErrors()
} catch {
print(error)
}
// OR
var user2: UserSignUp? = builder.build()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment