Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/docc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
runs-on: macos-12
steps:
- name: git checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: docbuild
run: >
xcodebuild docbuild -scheme DataStore \
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/macOS.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@ name: macOS

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
branches: ["**"]

jobs:
build:
runs-on: macos-latest

steps:
- uses: actions/checkout@v3
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: 16.0
- uses: actions/checkout@v4
- name: Build
run: swift build -v
- name: Run tests
Expand Down
20 changes: 20 additions & 0 deletions .github/workflows/ubuntu.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Ubuntu

on:
push:
branches: ["**"]

jobs:
build:
runs-on: ubuntu-latest

steps:
- name: Set up Swift
uses: swift-actions/setup-swift@v2
with:
swift-version: '6.1.0'
- uses: actions/checkout@v4
- name: Build for release
run: swift build -v -c release
- name: Test
run: swift test -v
21 changes: 21 additions & 0 deletions .github/workflows/windows.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: Windows

on:
push:
branches: ["**"]

jobs:
build:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4

- name: Set up Swift 6.1
uses: compnerd/gha-setup-swift@main
with:
branch: swift-6.1-release
tag: 6.1-RELEASE

- run: swift --version
- run: swift build
- run: swift test
11 changes: 4 additions & 7 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// swift-tools-version: 5.6
// swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription
Expand All @@ -9,22 +9,19 @@ let package = Package(
.macOS(.v10_15),
.iOS(.v13),
.watchOS(.v6),
.tvOS(.v13)
.tvOS(.v13),
.visionOS(.v1)
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "DataStore",
targets: ["DataStore"]
)
],
dependencies: [
// Dependencies declare other packages that this package depends on.
.package(url: "https://github.com/0xLeif/Cache", from: "2.0.0")
.package(url: "https://github.com/0xLeif/Cache", from: "2.0.0")
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "DataStore",
dependencies: [
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
# DataStore

[![macOS Build](https://img.shields.io/github/actions/workflow/status/0xLeif/DataStore/macOS.yml?label=macOS&branch=main)](https://github.com/0xLeif/DataStore/actions/workflows/macOS.yml)
[![Ubuntu Build](https://img.shields.io/github/actions/workflow/status/0xLeif/DataStore/ubuntu.yml?label=Ubuntu&branch=main)](https://github.com/0xLeif/DataStore/actions/workflows/ubuntu.yml)
[![Windows Build](https://img.shields.io/github/actions/workflow/status/0xLeif/DataStore/windows.yml?label=Windows&branch=main)](https://github.com/0xLeif/DataStore/actions/workflows/windows.yml)
[![License](https://img.shields.io/github/license/0xLeif/DataStore)](https://github.com/0xLeif/DataStore/blob/main/LICENSE)
[![Version](https://img.shields.io/github/v/release/0xLeif/DataStore)](https://github.com/0xLeif/DataStore/releases)

*An extendable data storage solution with built-in caching capabilities.*

## Requirements

- **iOS**: 13.0+
- **watchOS**: 6.0+
- **macOS**: 10.15+
- **tvOS**: 13.0+
- **visionOS**: 1.0+
- **Swift**: 6.0+

## What is DataStore?

`DataStore` is a generic data storage and caching library for Swift. It provides a convenient way to load, cache, and store data in your apps. The `DataStore` class, by default, utilizes a cache to improve data loading performance. However, it can be subclassed to support other data storage mechanisms while still benefiting from the caching capabilities.
Expand All @@ -13,6 +28,7 @@
- **Flexible Data Retrieval**: Access loaded data via the `fetch()` method or fetch data based on specific criteria using closure-based filtering.
- **Data Storage**: Store new data objects into the `DataStore` for later retrieval.
- **Data Deletion**: Remove previously stored data objects from the `DataStore`.
- **Cross-Platform**: Supports macOS, iOS, watchOS, tvOS, visionOS, Ubuntu, and Windows.

## Installation

Expand Down
8 changes: 7 additions & 1 deletion Sources/DataStore/ConsumingObservableObject.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
#if canImport(Combine)
import Combine
import Foundation

/// An `ObservableObject` that can consume and observe changes in other `ObservableObject` instances.
open class ConsumingObservableObject: ObservableObject {
open class ConsumingObservableObject: ObservableObject, @unchecked Sendable {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Adding @unchecked Sendable tells the compiler to trust that you've ensured thread safety manually. However, the bag property, which is a Set, is mutated by consume(object:) and deinit without any synchronization. This can lead to a data race if consume is called from multiple threads on the same object instance, which is possible since it's a public method in an open class.

To prevent this data race, you should protect all access to the bag property with a lock. Here is an example of how you could implement this using NSLock:

#if canImport(Combine)
import Combine
import Foundation // For NSLock

open class ConsumingObservableObject: ObservableObject, @unchecked Sendable {
    private let bagLock = NSLock()
    private var bag: Set<AnyCancellable> = Set()

    deinit {
        bagLock.lock()
        bag.removeAll()
        bagLock.unlock()
    }

    public init() { }

    public func consume<Object: ObservableObject>(
        object: Object
    ) where ObjectWillChangePublisher == ObservableObjectPublisher {
        bagLock.lock()
        defer { bagLock.unlock() }
        bag.insert(
            object.objectWillChange.sink(
                receiveCompletion: { _ in },
                receiveValue: { [weak self] _ in
                    self?.objectWillChange.send()
                }
            )
        )
    }
}
#endif

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed — added NSLock synchronization around all bag access in both consume(object:) and deinit.

private let bagLock = NSLock()
private var bag: Set<AnyCancellable> = Set()

deinit {
bagLock.lock()
bag.removeAll()
bagLock.unlock()
}

public init() { }
Expand All @@ -17,6 +21,8 @@ open class ConsumingObservableObject: ObservableObject {
public func consume<Object: ObservableObject>(
object: Object
) where ObjectWillChangePublisher == ObservableObjectPublisher {
bagLock.lock()
defer { bagLock.unlock() }
bag.insert(
object.objectWillChange.sink(
receiveCompletion: { _ in },
Expand Down
12 changes: 9 additions & 3 deletions Sources/DataStore/DataStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ import Cache
- SeeAlso: `Identifiable`
- SeeAlso: `Cache`
*/
#if canImport(Combine)
open class DataStore<DataLoader: DataLoading>: ConsumingObservableObject, DataStoring where DataLoader.DeviceData.To == DataLoader.DeviceData.StoredValue {
#else
open class DataStore<DataLoader: DataLoading>: DataStoring, @unchecked Sendable where DataLoader.DeviceData.To == DataLoader.DeviceData.StoredValue {
#endif

/// A typealias that represents the type of data loaded by the DataLoader.
public typealias LoadedData = DataLoader.LoadedData
Expand Down Expand Up @@ -47,10 +51,12 @@ open class DataStore<DataLoader: DataLoading>: ConsumingObservableObject, DataSt
) {
self.cache = Cache(initialValues: initalValues)
self.loader = loader


#if canImport(Combine)
super.init()

consume(object: cache)
#endif
}

/**
Expand Down Expand Up @@ -108,7 +114,7 @@ open class DataStore<DataLoader: DataLoading>: ConsumingObservableObject, DataSt

- Note: This method filters the fetched data using the given filter closure and returns the filtered results.
*/
open func fetch(where filter: (DeviceData) -> Bool) async -> [DeviceData] {
open func fetch(where filter: @Sendable (DeviceData) -> Bool) async -> [DeviceData] {
let allValues = await fetch()

let filteredValues = allValues.filter(filter)
Expand Down
2 changes: 1 addition & 1 deletion Sources/DataStore/Protocols/DataLoading.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/// A protocol for loading data asynchronously and adapting it to a desired format using the `Adaptable` protocol.
public protocol DataLoading {
public protocol DataLoading: Sendable {
/// The type of loaded data, which should conform to the `Identifiable` protocol.
associatedtype LoadedData: Identifiable

Expand Down
2 changes: 1 addition & 1 deletion Sources/DataStore/Protocols/DataStoring.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public protocol DataStoring {
/// Fetches stored `DeviceData` objects that satisfy the given filter predicate.
/// - Parameter where: A closure that takes a `DeviceData` object and returns a Boolean value indicating whether the object should be included in the result.
/// - Returns: An array of fetched device-specific data that pass the filter.
func fetch(where filter: (DeviceData) -> Bool) async -> [DeviceData]
func fetch(where filter: @Sendable (DeviceData) -> Bool) async -> [DeviceData]

/// Fetches a single stored `DeviceData` object based on its identifier.
/// - Parameter id: The identifier of the data to fetch.
Expand Down
5 changes: 2 additions & 3 deletions Tests/DataStoreTests/DataStoreTests.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import SwiftUI
import XCTest
@testable import DataStore

Expand Down Expand Up @@ -31,7 +30,7 @@ final class DataStoreTests: XCTestCase {
try await store.load()

let values = await store.fetch { data in
data.color == Color(red: 0, green: 1, blue: 0)
data.color == TestColor(red: 0, green: 1, blue: 0)
}

XCTAssertEqual(values.count, 1)
Expand All @@ -46,7 +45,7 @@ final class DataStoreTests: XCTestCase {

let expectedID = UUID().uuidString
let expectedUserName = "test"
let expectedColor = Color.green
let expectedColor = TestColor.green
let expectedEnumValue = TestDeviceData.DeviceEnum.weirdCaseExample

try await store.store(
Expand Down
37 changes: 26 additions & 11 deletions Tests/DataStoreTests/TestObjects.swift
Original file line number Diff line number Diff line change
@@ -1,16 +1,31 @@
import DataStore

#if canImport(SwiftUI)
import SwiftUI
#endif

// MARK: - TestColor

/// A cross-platform color representation for testing.
struct TestColor: Equatable, Sendable {
let red: Double
let green: Double
let blue: Double

static let clear = TestColor(red: 0, green: 0, blue: 0)
static let green = TestColor(red: 0, green: 1, blue: 0)
}

// MARK: - TestLoadedData

struct TestLoadedData: Identifiable {
struct LoadedColor {
struct TestLoadedData: Identifiable, Sendable {
struct LoadedColor: Sendable {
let red: Double
let green: Double
let blue: Double
}

enum LoadedEnum: String {
enum LoadedEnum: String, Sendable {
case WeirdCaseExample
case inconsistent_example
}
Expand All @@ -23,8 +38,8 @@ struct TestLoadedData: Identifiable {

// MARK: - TestDeviceData

struct TestDeviceData: OnDeviceData {
enum DeviceEnum: String, Adaptable {
struct TestDeviceData: OnDeviceData, Sendable {
enum DeviceEnum: String, Adaptable, Sendable {
case weirdCaseExample
case inconsistentExample

Expand All @@ -40,10 +55,10 @@ struct TestDeviceData: OnDeviceData {

let id: String
var userName: String
var color: Color
var color: TestColor
var enumValue: DeviceEnum

init(id: String, userName: String, color: Color, enumValue: DeviceEnum) {
init(id: String, userName: String, color: TestColor, enumValue: DeviceEnum) {
self.id = id
self.userName = userName
self.color = color
Expand All @@ -54,7 +69,7 @@ struct TestDeviceData: OnDeviceData {
self.init(
id: from.id,
userName: from.user_name,
color: Color(
color: TestColor(
red: from.color.red,
green: from.color.green,
blue: from.color.blue
Expand All @@ -75,10 +90,10 @@ struct TestDeviceData: OnDeviceData {

// MARK: - StoredData

struct TestStoredData: StorableData {
struct TestStoredData: StorableData, Sendable {
let id: String
let userName: String
let color: Color
let color: TestColor
let enumValue: TestDeviceData.DeviceEnum

init(from: TestDeviceData) {
Expand All @@ -91,7 +106,7 @@ struct TestStoredData: StorableData {

// MARK: - TestDataLoader

class TestDataLoader: DataLoading {
final class TestDataLoader: DataLoading, Sendable {
typealias LoadedData = TestLoadedData
typealias DeviceData = TestDeviceData

Expand Down