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
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import Foundation

public struct SavedQueryKeywordResolution<Query: SavedQueryRepresentable> {
public let upserts: [Query]
// Local rows whose keyword is cleared before the upserts land: each is replaced or loses its keyword.
public let vacatedLocalIds: [UUID]
public let releasedLocalIds: [UUID]
public let releasedIncomingIds: [UUID]

public var releasedIds: [UUID] {
releasedLocalIds + releasedIncomingIds
}
}

extension SavedQueryKeywordResolution: Equatable where Query: Equatable {}

extension SavedQueryKeywordResolution: Sendable where Query: Sendable {}

// Every device runs this one rule on the same records, so they all release the same losers and converge.
public enum SavedQueryKeywordResolver {
public static func resolve<Query: SavedQueryRepresentable>(
incoming: [Query],
local: [Query]
) -> SavedQueryKeywordResolution<Query> {
let upserts = latestVersions(of: incoming)
let incomingIds = Set(upserts.map(\.id))
let bystanders = local.filter { !incomingIds.contains($0.id) }
let losingIds = keywordLosers(among: bystanders + upserts)

return SavedQueryKeywordResolution(
upserts: upserts.map { losingIds.contains($0.id) ? releasingKeyword($0) : $0 },
vacatedLocalIds: local.map(\.id).filter { incomingIds.contains($0) || losingIds.contains($0) },
releasedLocalIds: bystanders.map(\.id).filter { losingIds.contains($0) },
releasedIncomingIds: upserts.map(\.id).filter { losingIds.contains($0) }
)
}

public static func holdsKeywordFirst<Query: SavedQueryRepresentable>(_ lhs: Query, _ rhs: Query) -> Bool {
guard lhs.createdAt == rhs.createdAt else { return lhs.createdAt < rhs.createdAt }
return lhs.id.uuidString < rhs.id.uuidString
}

private struct KeywordSlot: Hashable {
let keyword: String
let connectionId: UUID
}

// A global keyword is never contested: the local save check already keeps it unique everywhere.
private static func slot<Query: SavedQueryRepresentable>(of query: Query) -> KeywordSlot? {
guard let keyword = query.keyword, let connectionId = query.connectionId else { return nil }
return KeywordSlot(keyword: keyword, connectionId: connectionId)
}

private static func keywordLosers<Query: SavedQueryRepresentable>(among contenders: [Query]) -> Set<UUID> {
var claimants: [KeywordSlot: [Query]] = [:]
for query in contenders {
guard let slot = slot(of: query) else { continue }
claimants[slot, default: []].append(query)
}
return Set(claimants.values.flatMap { $0.sorted(by: holdsKeywordFirst).dropFirst().map(\.id) })

Check warning on line 60 in apple/Packages/TableProCore/Sources/TableProConnectionLibrary/SavedQueryKeywordResolver.swift

View workflow job for this annotation

GitHub Actions / macos / macOS Tests

capture of non-Sendable type 'Query.Type' in an isolated closure [#SendableMetatypes]

Check warning on line 60 in apple/Packages/TableProCore/Sources/TableProConnectionLibrary/SavedQueryKeywordResolver.swift

View workflow job for this annotation

GitHub Actions / ios / iOS and Package Tests

capture of non-Sendable type 'Query.Type' in an isolated closure [#SendableMetatypes]
}

private static func latestVersions<Query: SavedQueryRepresentable>(of queries: [Query]) -> [Query] {
var latestById: [UUID: Query] = [:]
var arrivalOrder: [UUID] = []
for query in queries where latestById.updateValue(query, forKey: query.id) == nil {
arrivalOrder.append(query.id)
}
return arrivalOrder.compactMap { latestById[$0] }
}

private static func releasingKeyword<Query: SavedQueryRepresentable>(_ query: Query) -> Query {
var released = query
released.keyword = nil
return released
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import Foundation

public protocol SavedQueryRepresentable {
var id: UUID { get }
var name: String { get }
var query: String { get }
var keyword: String? { get set }
var folderId: UUID? { get }
var connectionId: UUID? { get }
var sortOrder: Int { get }
var createdAt: Date { get }
}

public protocol SavedQueryFolderRepresentable: LibraryGroupRepresentable {
var connectionId: UUID? { get }
}

public enum SavedQueryOutlineID: Hashable, Sendable {
case folder(UUID)
case query(UUID)
}

public enum SavedQueryOutlineNode<Folder: SavedQueryFolderRepresentable, Query: SavedQueryRepresentable>: Identifiable {
case folder(Folder, children: [SavedQueryOutlineNode])
case query(Query)

public var id: SavedQueryOutlineID {
switch self {
case .folder(let folder, _):
return .folder(folder.id)
case .query(let query):
return .query(query.id)
}
}

public var children: [SavedQueryOutlineNode]? {
switch self {
case .folder(_, let children):
return children
case .query:
return nil
}
}
}

extension SavedQueryOutlineNode: Equatable where Folder: Equatable, Query: Equatable {}

extension SavedQueryOutlineNode: Hashable where Folder: Hashable, Query: Hashable {}

extension SavedQueryOutlineNode: Sendable where Folder: Sendable, Query: Sendable {}

public struct SavedQueryFolderRow<Folder: SavedQueryFolderRepresentable>: Identifiable {
public let folder: Folder
public let depth: Int

public var id: UUID { folder.id }
}

extension SavedQueryFolderRow: Equatable where Folder: Equatable {}

extension SavedQueryFolderRow: Sendable where Folder: Sendable {}

public enum SavedQueryOutline {
public static func isVisible(scope: UUID?, to connectionId: UUID) -> Bool {
scope == nil || scope == connectionId
}

public static func visible<Query: SavedQueryRepresentable>(_ queries: [Query], to connectionId: UUID) -> [Query] {
queries.filter { isVisible(scope: $0.connectionId, to: connectionId) }
}

public static func visible<Folder: SavedQueryFolderRepresentable>(_ folders: [Folder], to connectionId: UUID) -> [Folder] {
folders.filter { isVisible(scope: $0.connectionId, to: connectionId) }
}

// Mac folders nest without limit, so the graph's depth checks are never asked here.
public static func build<Folder: SavedQueryFolderRepresentable, Query: SavedQueryRepresentable>(
folders: [Folder],
queries: [Query]
) -> [SavedQueryOutlineNode<Folder, Query>] {
let graph = LibraryGroupGraph(groups: folders)
let foldersById = firstById(folders)
let queriesByFolder = Dictionary(grouping: queries) { query -> UUID? in
query.folderId.flatMap { graph.contains($0) ? $0 : nil }
}

var visited: Set<UUID> = []
func nodes(under parentId: UUID?) -> [SavedQueryOutlineNode<Folder, Query>] {
var result: [SavedQueryOutlineNode<Folder, Query>] = []
for id in graph.sortedChildIds(of: parentId, mode: .manual) where visited.insert(id).inserted {
guard let folder = foldersById[id] else { continue }
result.append(.folder(folder, children: nodes(under: id)))
}
for query in (queriesByFolder[parentId] ?? []).sorted(by: queryPrecedes) {

Check warning on line 94 in apple/Packages/TableProCore/Sources/TableProConnectionLibrary/SavedQueryOutline.swift

View workflow job for this annotation

GitHub Actions / macos / macOS Tests

capture of non-Sendable type 'Query.Type' in an isolated closure [#SendableMetatypes]

Check warning on line 94 in apple/Packages/TableProCore/Sources/TableProConnectionLibrary/SavedQueryOutline.swift

View workflow job for this annotation

GitHub Actions / ios / iOS and Package Tests

capture of non-Sendable type 'Query.Type' in an isolated closure [#SendableMetatypes]
result.append(.query(query))
}
return result
}
return nodes(under: nil)
}

public static func folderRows<Folder: SavedQueryFolderRepresentable>(_ folders: [Folder]) -> [SavedQueryFolderRow<Folder>] {
let foldersById = firstById(folders)
return LibraryGroupGraph(groups: folders).flattened(mode: .manual).compactMap { entry in
foldersById[entry.id].map { SavedQueryFolderRow(folder: $0, depth: entry.depth) }
}
}

// A folder stays while anything under it matches, so a hit never hides under an empty-looking ancestor.
public static func filter<Folder: SavedQueryFolderRepresentable, Query: SavedQueryRepresentable>(
_ nodes: [SavedQueryOutlineNode<Folder, Query>],
matching text: String
) -> [SavedQueryOutlineNode<Folder, Query>] {
let term = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !term.isEmpty else { return nodes }
return filtered(nodes, term: term)
}

private static func filtered<Folder: SavedQueryFolderRepresentable, Query: SavedQueryRepresentable>(
_ nodes: [SavedQueryOutlineNode<Folder, Query>],
term: String
) -> [SavedQueryOutlineNode<Folder, Query>] {
nodes.compactMap { node -> SavedQueryOutlineNode<Folder, Query>? in
switch node {
case .query(let query):
return matches(query, term: term) ? node : nil
case .folder(let folder, let children):
let kept = filtered(children, term: term)
guard !kept.isEmpty || folder.name.localizedCaseInsensitiveContains(term) else { return nil }
return .folder(folder, children: kept)
}
}
}

private static func matches<Query: SavedQueryRepresentable>(_ query: Query, term: String) -> Bool {
query.name.localizedCaseInsensitiveContains(term)
|| query.keyword?.localizedCaseInsensitiveContains(term) == true
|| query.query.localizedCaseInsensitiveContains(term)
}

private static func queryPrecedes<Query: SavedQueryRepresentable>(_ lhs: Query, _ rhs: Query) -> Bool {
if lhs.sortOrder != rhs.sortOrder { return lhs.sortOrder < rhs.sortOrder }
let order = lhs.name.localizedStandardCompare(rhs.name)
if order != .orderedSame { return order == .orderedAscending }
return lhs.id.uuidString < rhs.id.uuidString
}

private static func firstById<Folder: SavedQueryFolderRepresentable>(_ folders: [Folder]) -> [UUID: Folder] {
var byId: [UUID: Folder] = [:]
for folder in folders where byId[folder.id] == nil {
byId[folder.id] = folder
}
return byId
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Foundation

public enum SavedQueryImportPlacement {
public static func holdableFolderPath(_ path: [PathComponent], for scope: UUID?) -> [PathComponent] {
var kept: [PathComponent] = []
for component in path {
guard !component.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
SavedQueryScopeRule.folder(kept.last?.scope, canHold: component.scope)
else { break }
kept.append(component)
}
while let leaf = kept.last, !SavedQueryScopeRule.folder(leaf.scope, canHold: scope) {
kept.removeLast()
}
return kept
}

public static func importedName(_ query: PlannedQuery) -> String {
let trimmed = query.name.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? SavedQueryName.derived(from: query.sql) : query.name
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ public enum SavedQueryKeyword {
public static func shadowsSQLKeyword(_ keyword: String) -> Bool {
reservedSQLKeywords.contains(keyword.lowercased())
}

// A global keyword expands in every connection, so it collides with a holder anywhere.
public static func isAvailable(_ keyword: String, scope: UUID?, holderScopes: some Sequence<UUID?>) -> Bool {
guard normalized(keyword) != nil else { return true }
return !holderScopes.contains { scope == nil || $0 == nil || $0 == scope }
}
}

public enum SavedQueryName {
Expand Down Expand Up @@ -156,11 +162,8 @@ public struct SavedQueryLedger: Sendable {
}
}

/// Mirrors `SQLFavoriteStorage.isKeywordAvailable`: a global keyword expands in every connection.
private func isKeywordAvailable(_ keyword: String, scope: UUID?) -> Bool {
guard let holders = keywordScopes[keyword], !holders.isEmpty else { return true }
guard let scope else { return false }
return !holders.contains(nil) && !holders.contains(scope)
SavedQueryKeyword.isAvailable(keyword, scope: scope, holderScopes: keywordScopes[keyword] ?? [])
}

private static func normalizedName(_ name: String) -> String {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import Foundation

public struct SavedQuery: Identifiable, Codable, Hashable, Sendable {
public let id: UUID
public var name: String
public var query: String
public var keyword: String?
public var folderId: UUID?
public var connectionId: UUID?
public var sortOrder: Int
public let createdAt: Date
public var updatedAt: Date

public init(
id: UUID = UUID(),
name: String,
query: String,
keyword: String? = nil,
folderId: UUID? = nil,
connectionId: UUID? = nil,
sortOrder: Int = 0,
createdAt: Date? = nil,
updatedAt: Date? = nil
) {
let now = Date()
self.id = id
self.name = name
self.query = query
self.keyword = keyword
self.folderId = folderId
self.connectionId = connectionId
self.sortOrder = sortOrder
self.createdAt = createdAt ?? now
self.updatedAt = updatedAt ?? now
}

private enum CodingKeys: String, CodingKey {
case id, name, query, keyword, folderId, connectionId, sortOrder, createdAt, updatedAt
}

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
query = try container.decode(String.self, forKey: .query)
keyword = try container.decodeIfPresent(String.self, forKey: .keyword)
folderId = try container.decodeIfPresent(UUID.self, forKey: .folderId)
connectionId = try container.decodeIfPresent(UUID.self, forKey: .connectionId)
sortOrder = try container.decodeIfPresent(Int.self, forKey: .sortOrder) ?? 0
// The keyword contest is decided by createdAt, so a row without one is refused rather than dated now.
createdAt = try container.decode(Date.self, forKey: .createdAt)
updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? createdAt
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import Foundation

public struct SavedQueryFolder: Identifiable, Codable, Hashable, Sendable {
public let id: UUID
public var name: String
public var parentId: UUID?
public var connectionId: UUID?
public var sortOrder: Int
public let createdAt: Date
public var updatedAt: Date

public init(
id: UUID = UUID(),
name: String,
parentId: UUID? = nil,
connectionId: UUID? = nil,
sortOrder: Int = 0,
createdAt: Date? = nil,
updatedAt: Date? = nil
) {
let now = Date()
self.id = id
self.name = name
self.parentId = parentId
self.connectionId = connectionId
self.sortOrder = sortOrder
self.createdAt = createdAt ?? now
self.updatedAt = updatedAt ?? now
}

private enum CodingKeys: String, CodingKey {
case id, name, parentId, connectionId, sortOrder, createdAt, updatedAt
}

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
parentId = try container.decodeIfPresent(UUID.self, forKey: .parentId)
connectionId = try container.decodeIfPresent(UUID.self, forKey: .connectionId)
sortOrder = try container.decodeIfPresent(Int.self, forKey: .sortOrder) ?? 0
createdAt = try container.decode(Date.self, forKey: .createdAt)
updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? createdAt
}
}
Loading
Loading