Maxi Mus
Maxi Mus

Reputation: 815

Swift Dictionary of Arrays, Data Structure

I am writing a simple program that allows me to monitor some sales in an iOS app. Basically, I need a data structure that allows for customers (as a string), and for each customer I need to be able to save one or multiple 'sales', each consisting of a date of sale and a price. I implemented this as a Swift dictionary which reads from and saves to a plist file, in a singleton Swift class because I need to read and write to the same data structure from multiple view controllers. Here is the code for the data class:

import Foundation
import UIKit

class DataSingleton {

    static let sharedDataSingleton = DataSingleton()
    private(set) var allData = [String: [AnyObject]]()
    private(set) var customers: [String] = []

    init() {
        let fileURL = self.dataFileURL()
        if (NSFileManager.defaultManager().fileExistsAtPath(fileURL.path!)) {
            allData = NSDictionary(contentsOfURL: fileURL) as! [String : [AnyObject]]
            customers = allData.keys.sort()
        }
    }

    func dataFileURL() -> NSURL {
        let url = NSFileManager.defaultManager().URLsForDirectory(.DocumentDirectory, inDomains: .UserDomainMask)
        return url.first!.URLByAppendingPathComponent("data.plist")
    }

    func addCustomer(customerName: String) {
        if !customers.contains(customerName) {
            customers.append(customerName)
            allData[customerName] = [[NSDate(), 0]]
            saveData()
        }
    }

    func addSale(customerName: String, date: NSDate, price: Int) {
        allData[customerName]?.append([date, price])
        saveData()
    }

    func saveData() {
        let fileURL = self.dataFileURL()
        let customerData = allData as NSDictionary
        customerData.writeToURL(fileURL, atomically: true)
    }
}

As you can see my I used a dictionary where the customer name is the key and the sales are an Array of AnyObject. I have doubts whether this is the optimal and most elegant way of implementing this, so maybe someone can help me with the following questions:

  1. What would be a better way to implement this? Would it make sense to implement sales as a struct and then use arrays of this struct?

  2. When creating a new customer, I need to use a placeholder

    allData[customerName] = [[NSDate(), 0]]
    

because a dictionary key without values won't be saved. Is there a better way to do this?

  1. Why does allData.keys.sort() produce an array of strings, but allData.keys does not? This does not make sense to me since it seems that a sort function shouldn't change a type.

  2. In of of my (table) views, each cell shows a customer with the total sales per customer. I want to sort this table by total sum of sales. The current code (see 3.) sorts the customers alphabetically. What would be the best way to implement this: Sort the customers by total sales in the data structure itself or in the view controller? And could someone provide code how this sorting would most elegantly be achieved, perhaps with the sort function and a closure?

Upvotes: 0

Views: 807

Answers (1)

user887210
user887210

Reputation:

Take a look at this editing of your code and see if it works for your purposes:

import Foundation

/// Allows any object to be converted into and from a NSDictionary
///
/// With thanks to [SonoPlot](https://github.com/SonoPlot/PropertyListSwiftPlayground)
protocol PropertyListReadable {
  /// Converts to a NSDictionary
  func propertyListRepresentation() -> NSDictionary

  /// Initializes from a NSDictionary
  init?(propertyListRepresentation:NSDictionary?)
}

/// Converts a plist array to a PropertyListReadable object
func extractValuesFromPropertyListArray<T:PropertyListReadable>(propertyListArray:[AnyObject]?) -> [T] {
  guard let encodedArray = propertyListArray else {return []}
  return encodedArray.map{$0 as? NSDictionary}.flatMap{T(propertyListRepresentation:$0)}
}

/// Saves a PropertyListReadable object to a URL
func saveObject(object:PropertyListReadable, URL:NSURL) {
  if let path = URL.path {
    let encoded = object.propertyListRepresentation()
    encoded.writeToFile(path, atomically: true)
  }
}

/// Holds the information for a sale
struct Sale {
  let date:NSDate
  let price:Int
}

/// Allows a Sale to be converted to and from an NSDictionary
extension Sale: PropertyListReadable {
  /// Convert class to an NSDictionary
  func propertyListRepresentation() -> NSDictionary {
    let representation:[String:AnyObject] = ["date":self.date, "price":self.price]
    return representation
  }

  /// Initialize class from an NSDictionary
  init?(propertyListRepresentation:NSDictionary?) {
    guard let values = propertyListRepresentation,
      date = values["date"] as? NSDate,
      price = values["price"] as? Int else { return nil }
    self.init(date:date, price:price)
  }
}

/// Singleton that holds all the sale Data
final class DataSingleton {
  /// Class variable that returns the singleton
  static let sharedDataSingleton = DataSingleton(dataFileURL: dataFileURL)

  /// Computed property to get the URL for the data file
  static var dataFileURL:NSURL? {
    get {
      let manager = NSFileManager.defaultManager()
      guard let URL = manager.URLsForDirectory(.DocumentDirectory, inDomains: .UserDomainMask).first?.URLByAppendingPathComponent("data.plist"),
        path = URL.path else { return nil }
      let data = NSKeyedArchiver.archivedDataWithRootObject([String:AnyObject]())
      guard manager.fileExistsAtPath(path) &&
        manager.createFileAtPath(path, contents: data, attributes: nil) else { return nil }
      return URL
    }
  }

  /// The dictionary holding all the sale data
  private(set) var allData:[String: [Sale]]

  /// Computed property to return all the customers
  var customers:[String] { get { return Array(allData.keys) }}

  /// Designated initializer
  ///
  /// Made private to disallow additional copies of the singleton
  private init() { allData = [:] }

  /// Adds a customer to the data dictionary
  func addCustomer(customerName: String) {
    if allData[customerName] == nil {
      allData[customerName] = []
      saveData()
    }
  }

  /// Adds a sale to the data dictionary, creating a new customer if neccesary
  func addSale(customerName: String, date: NSDate, price: Int) {
    addCustomer(customerName)
    allData[customerName]?.append(Sale(date: date, price: price))
    saveData()
  }

  // Saves the singleton to the plist file
  private func saveData() {
    if let fileURL = DataSingleton.dataFileURL {
      saveObject(self, URL: fileURL)
    }
  }
}

/// Allows a DataSingleton to be converted to and from an NSDictionary
extension DataSingleton: PropertyListReadable {
  /// Convert class to an NSDictionary
  func propertyListRepresentation() -> NSDictionary {
    return allData.reduce([String:[AnyObject]]()) {
      var retval = $0
      retval[$1.0] = $1.1.map {$0.propertyListRepresentation()}
      return retval
    }
  }

  /// Initialize class from a plist file
  ///
  /// Made private to disallow additional copies of the singleton
  private convenience init?(dataFileURL: NSURL?) {
    guard let fileURL = dataFileURL,
      path = fileURL.path where (NSFileManager.defaultManager().fileExistsAtPath(path)),
      let data = NSDictionary(contentsOfFile: path) else { return nil }
    self.init(propertyListRepresentation: data)
  }

  /// Initialize class from an NSDictionary
  convenience init?(propertyListRepresentation:NSDictionary?) {
    self.init() // satisfies calling the designated init from a convenience init
    guard let values = propertyListRepresentation else {return nil}
    allData = values.reduce([:]) {
      var retvalue = $0
      guard let key = $1.key as? String,
        value = $1.value as? [AnyObject] else { return retvalue }
      retvalue[key] = extractValuesFromPropertyListArray(value)
      return retvalue
    }
  }
}

Usage:

let data = DataSingleton.sharedDataSingleton
data?.addSale("June", date: NSDate(), price: 20)
data?.addSale("June", date: NSDate(), price: 30)

let data2 = DataSingleton.sharedDataSingleton
print(data2?.allData["June"])
// => Optional([Sale(date: 2016-03-10 04:31:49 +0000, price: 20), Sale(date: 2016-03-10 04:31:49 +0000, price: 30)])

To answer 3, you should note the return value of allData.keys. It's a lazy collection, one which doesn't pull out a value until you ask for it. Calling sort on it pulls out all the values since you can't sort without them. Because of that it returns a regular, non-lazy Array. You can get an non-lazy array without using sort by doing: Array(allData.keys)

Your additional questions in the comments:

1: Hopefully fixed, I switched away from NSCoding because of its dependancies on NSObject and NSData. That meant we couldn't use a struct with its very desirable value semantics, among other issues.

2: It's a good idea to add functionality through extensions. This allows you to keep code compartmentalized, for example I add compliance to the protocol PropertyListReadable in an extension and then add the necessary methods there too.

3: In this code allData is initialized in init?(propertyListRepresentation:). In there an NSDictionary is read and each customer and their sales is parsed into the proper data structures. Since I changed Sale from a class to a struct (thanks to the new protocol PropertyListReadable) we can just declare the variables it holds and the init method is auto-generated for us as init(date:NSDate, price:Int)

A struct like Sale is better than a Dictionary because of type checking, auto-completion, the ability to better comment the struct and automatically produce documentation, the possibility for validation of the values, adding protocols for additional functionality, and so on. It's difficult to know exactly what any old Dictionary represents when it's buried in code and having a concrete struct, enum, or class makes your life so much easier.

A Dictionary is great for mapping keys to values but when you want to hold multiple co-dependant values together you want to look toward an enum, struct or class.

Upvotes: 1

Related Questions