Skip to content
Merged
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 Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ let package = Package(
targets: [
.target(
name: "DevFoundation",
swiftSettings: swiftSettings
swiftSettings: swiftSettings,
),
.testTarget(
name: "DevFoundationTests",
Expand Down
26 changes: 26 additions & 0 deletions Sources/DevFoundation/Paging/OffsetPage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
//
// OffsetPage.swift
// DevFoundation
//
// Created by Prachi Gauriar on 8/8/25.
//

import Foundation

/// A type that has an offset from the first item in a page sequence.
public protocol OffsetPage: Sendable {
/// The offset of the page from the first page.
///
/// The first page has a page offset of 0.
var pageOffset: Int { get }
}


extension OffsetPage {
/// The offset of the next page.
///
/// This is equivalent to `pageOffset + 1`.
public var nextPageOffset: Int {
pageOffset + 1
}
}
109 changes: 109 additions & 0 deletions Sources/DevFoundation/Paging/RandomAccessPager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
//
// RandomAccessPager.swift
// DevFoundation
//
// Created by Prachi Gauriar on 8/8/25.
//

import Foundation
import Synchronization

/// A type that can load pages on behalf of a random access pager.
public protocol RandomAccessPageLoader<Page>: SequentialPageLoader {
/// Returns whether a page exists at the given offset.
///
/// - Parameter offset: The offset of the page whose existence is being checked.
func pageExists(at offset: Int) -> Bool

/// Loads the page at a given offset.
///
/// It is a programming error to call this function with a page for which ``pageExists(at:)`` returns false.
/// Conforming types should trap if this happens.
///
/// - Parameter offset: The offset of the page to load. The first page is at offset 0.
func loadPage(at offset: Int) async throws -> Page
}


extension RandomAccessPageLoader {
public func pageExists(after page: Page) -> Bool {
return pageExists(at: page.nextPageOffset)
}


public func loadPage(after page: Page?) async throws -> Page {
return try await loadPage(at: page?.nextPageOffset ?? 0)
}
}


/// A type that can load pages in any order and provide fast access to previously loaded pages.
///
/// In general, you should use a ``RandomAccessPager`` instead of creating your own conforming type.
public protocol RandomAccessPaging<Page>: RandomAccessPageLoader, SequentialPaging {
/// The page offset of the last loaded page.
///
/// In this context, last refers to the largest page offset, not the most recently loaded page.
var lastLoadedPageOffset: Int? { get }
}


/// A random access page loader that provides fast access to previously loaded pages.
///
/// Each random access pager uses a ``RandomAccessPageLoader`` to load pages on its behalf. You will typically create
/// custom page loaders that, e.g., fetch pages from a web service, and use a `RandomAccessPager` to load those pages as
/// needed and provide access to them.
public final class RandomAccessPager<Page>: RandomAccessPaging where Page: OffsetPage {
private struct LoadedPages {
var loadedPagesByOffset: [Int: Page] = [:]
var lastLoadedPageOffset: Int?
}


/// The page loader that the pager uses to load its pages.
private let pageLoader: any RandomAccessPageLoader<Page>

/// A mutex that synchronizes access to the instance’s loaded pages.
///
/// This dictionary is ordered by loaded page’s page offsets.
private let loadedPagesMutex: Mutex<LoadedPages> = Mutex(.init())


/// Creates a new sequential pager with the specified page loader.
///
/// - Parameter pageLoader: The page loader to use to load pages.
public init(pageLoader: some RandomAccessPageLoader<Page>) {
self.pageLoader = pageLoader
}


public var loadedPages: [Page] {
return loadedPagesMutex.withLock { (loadedPages) in
loadedPages.loadedPagesByOffset.values.sorted { $0.pageOffset < $1.pageOffset }
}
}


public var lastLoadedPageOffset: Int? {
return loadedPagesMutex.withLock(\.lastLoadedPageOffset)
}


public func pageExists(at offset: Int) -> Bool {
return pageLoader.pageExists(at: offset)
}


public func loadPage(at offset: Int) async throws -> Page {
if let loadedPage = loadedPagesMutex.withLock({ $0.loadedPagesByOffset[offset] }) {
return loadedPage
}

let loadedPage = try await pageLoader.loadPage(at: offset)
loadedPagesMutex.withLock { (loadedPages) in
loadedPages.loadedPagesByOffset[offset] = loadedPage
loadedPages.lastLoadedPageOffset = max(loadedPages.lastLoadedPageOffset ?? .min, loadedPage.pageOffset)
}
return loadedPage
}
}
107 changes: 107 additions & 0 deletions Sources/DevFoundation/Paging/SequentialPager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
//
// SequentialPager.swift
// DevFoundation
//
// Created by Prachi Gauriar on 8/8/25.
//

import Foundation
import Synchronization

/// A type that can load pages on behalf of a sequential pager.
public protocol SequentialPageLoader<Page>: Sendable {
/// The type of page that is loaded.
associatedtype Page: OffsetPage

/// Returns whether a page exists after a given page.
///
/// - Parameter page: The page immediately preceding the one whose existence is being checked.
func pageExists(after page: Page) -> Bool

/// Loads the page after a given page.
///
/// If `page` is `nil`, loads the first page.
///
/// It is a programming error to call this function with a page for which ``pageExists(after:)`` returns false.
/// Conforming types should trap if this happens.
///
/// - Parameter page: The page immediately preceding the one being loaded.
func loadPage(after page: Page?) async throws -> Page
}


/// A type that can load pages sequentially and provide fast access to previously loaded pages.
///
/// This protocol exists so that random access pagers can be used in generic algorithms requiring sequential paging
/// functionality. In general, you should use ``SequentialPager`` or ``RandomAccessPager`` instead of creating your own
/// conforming type.
public protocol SequentialPaging<Page>: SequentialPageLoader {
/// The pages that have been previously loaded.
var loadedPages: [Page] { get }

/// The last loaded page.
///
/// This is the loaded page with the greatest page offset. If `nil`, no pages have been loaded. A default
/// implementation is provided that simply returns `loadedPages.last`.
var lastLoadedPage: Page? { get }
}


extension SequentialPaging {
public var lastLoadedPage: Page? {
return loadedPages.last
}
}


/// A sequential page loader that provides fast access to previously loaded pages.
///
/// Each sequential pager uses a ``SequentialPageLoader`` to load pages on its behalf. You will typically create custom
/// page loaders that, e.g., fetch pages from a web service, and use a `SequentialPager` to load those pages as needed
/// and provide access to them.
public final class SequentialPager<Page>: SequentialPaging where Page: OffsetPage {
/// The page loader that the pager uses to load its pages.
private let pageLoader: any SequentialPageLoader<Page>

/// A mutex that synchronizes access to the instance’s loaded pages.
private let loadedPagesMutex = Mutex<[Page]>([])


/// Creates a new sequential pager with the specified page loader.
///
/// - Parameter pageLoader: The page loader to use to load pages.
public init(pageLoader: some SequentialPageLoader<Page>) {
self.pageLoader = pageLoader
}


public var loadedPages: [Page] {
return loadedPagesMutex.withLock { $0 }
}


public func pageExists(after page: Page) -> Bool {
return pageLoader.pageExists(after: page)
}


public func loadPage(after page: Page?) async throws -> Page {
let pageOffset = page?.nextPageOffset ?? 0

if let loadedPage = loadedPagesMutex.withLock({ pageOffset < $0.endIndex ? $0[pageOffset] : nil }) {
return loadedPage
}

let loadedPage = try await pageLoader.loadPage(after: page)

loadedPagesMutex.withLock { (loadedPages) in
if pageOffset >= loadedPages.count {
loadedPages.append(loadedPage)
} else {
loadedPages[pageOffset] = loadedPage
}
}

return loadedPage
}
}
26 changes: 26 additions & 0 deletions Tests/DevFoundationTests/Paging/OffsetPageTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
//
// OffsetPageTests.swift
// DevFoundation
//
// Created by Prachi Gauriar on 8/9/25.
//

import DevFoundation
import DevTesting
import Foundation
import Testing

struct OffsetPageTests: RandomValueGenerating {
var randomNumberGenerator = makeRandomNumberGenerator()


@Test
mutating func nextPageOffsetIsCorrect() {
for _ in 0 ..< 10 {
let mockPage = MockOffsetPage()
let offset = random(Int.self, in: .min ..< .max)
mockPage.pageOffsetStub = Stub(defaultReturnValue: offset)
#expect(mockPage.nextPageOffset == offset + 1)
}
}
}
Loading
Loading