Last active
March 21, 2018 03:58
-
-
Save muuhoffman/75349859399c20ad977d73fb832aa9d8 to your computer and use it in GitHub Desktop.
Protocol-Oriented Builder Pattern
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
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() | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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