diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift index d8513f8d1cf..1688754c672 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift @@ -1,6 +1,32 @@ import Foundation import DashSDKFFI +/// Result of ``SDK/documentCount(dataContractId:documentType:whereJSON:orderByJSON:groupByJSON:limit:)``. +/// +/// Mirrors the FFI's `{"counts": {"": }}` payload. Keys are the +/// hex-encoded group keys; for an ungrouped (aggregate) count there is a +/// single entry under the empty-string key, exposed via ``total``. +public struct DocumentCountResult: Sendable { + /// Hex-encoded group key → count. The empty-string key holds the + /// aggregate total for an ungrouped count. + public let counts: [String: UInt64] + + public init(counts: [String: UInt64]) { + self.counts = counts + } + + /// The aggregate total for an ungrouped count: `counts[""]`. `nil` when + /// the request was grouped (no empty-string entry). + public var total: UInt64? { + counts[""] + } + + /// `true` when the result carries per-group counts (any non-empty key). + public var isGrouped: Bool { + counts.keys.contains { !$0.isEmpty } + } +} + // MARK: - Platform Query Extensions for SDK @MainActor extension SDK { @@ -555,6 +581,113 @@ extension SDK { return json } + /// Count documents of a given type, optionally filtered by `where` and + /// grouped by `group_by`. + /// + /// Thin bridge over `dash_sdk_document_count`: it fetches the contract + /// handle (same precedent as `documentList`/`documentGet`), marshals the + /// document type + optional `where`/`order_by`/`group_by` JSON + the + /// `limit` sentinel in, calls the FFI, and marshals the + /// `{"counts": {"": }}` payload out. All aggregation is done + /// on the Rust side; nothing is decided here. + /// + /// - Parameters: + /// - dataContractId: Base58 id of the data contract holding the type. + /// - documentType: The document type name to count. + /// - whereJSON: Optional `[{field, operator, value}]` filter JSON. + /// Pass `nil` for an unfiltered count. + /// - orderByJSON: Optional `[{field, direction}]` JSON. Pass `nil` for + /// none (server defaults to ascending). + /// - groupByJSON: Optional `["", ...]` JSON. Pass `nil` for an + /// aggregate (ungrouped) count. + /// - limit: Sentinel-encoded `int64`. `-1` = use server default + /// (the default). `> 0` = explicit cap. `0` is rejected at the FFI + /// boundary, so the wrapper rejects it before calling. + /// - Returns: A ``DocumentCountResult`` mapping hex-encoded group key → + /// count. For an ungrouped count the total lives under the empty-string + /// key and is surfaced via ``DocumentCountResult/total``. + @MainActor + public func documentCount( + dataContractId: String, + documentType: String, + whereJSON: String? = nil, + orderByJSON: String? = nil, + groupByJSON: String? = nil, + limit: Int64 = -1 + ) async throws -> DocumentCountResult { + guard let handle = handle else { + throw SDKError.invalidState("SDK not initialized") + } + guard limit != 0 else { + // The FFI rejects limit == 0 (the v1 wire rejects Some(0)); fail + // fast with a clear message rather than relaying it. + throw SDKError.invalidParameter("limit must be -1 (server default) or a positive value, not 0") + } + + // Fetch the contract handle (precedent: documentList / documentGet). + let contractResult = dash_sdk_data_contract_fetch(handle, dataContractId) + if let error = contractResult.error { + let sdkError = SDKError.fromDashSDKError(error.pointee) + dash_sdk_error_free(error) + throw sdkError + } + guard let contractHandle = contractResult.data else { + throw SDKError.notFound("Data contract not found") + } + defer { + dash_sdk_data_contract_destroy(contractHandle.assumingMemoryBound(to: DataContractHandle.self)) + } + + // Marshal the optional JSON strings in. nil → null pointer = "none". + let result = documentType.withCString { typePtr in + withOptionalCString(whereJSON) { wherePtr in + withOptionalCString(orderByJSON) { orderPtr in + withOptionalCString(groupByJSON) { groupPtr in + dash_sdk_document_count( + handle, + contractHandle.assumingMemoryBound(to: DataContractHandle.self), + typePtr, + wherePtr, + orderPtr, + groupPtr, + limit + ) + } + } + } + } + + let json = try processJSONResult(result) + + // Payload shape: {"counts": {"": }}. + guard let countsObject = json["counts"] as? [String: Any] else { + throw SDKError.serializationError("Expected 'counts' object in document count response") + } + + var counts: [String: UInt64] = [:] + for (key, value) in countsObject { + if let num = value as? NSNumber { + counts[key] = num.uint64Value + } + } + + return DocumentCountResult(counts: counts) + } + + /// Call `body` with a `const char *` for an optional Swift string, + /// passing a null pointer when the string is `nil`. Mirrors the FFI's + /// "null/empty means none" contract for the where/order/group JSON args. + private func withOptionalCString( + _ string: String?, + _ body: (UnsafePointer?) -> R + ) -> R { + if let string = string { + return string.withCString { body($0) } + } else { + return body(nil) + } + } + // MARK: - DPNS Queries /// Get DPNS usernames for identity diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CountDocumentsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CountDocumentsView.swift new file mode 100644 index 00000000000..75f089c28ad --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CountDocumentsView.swift @@ -0,0 +1,255 @@ +import SwiftUI +import SwiftData +import SwiftDashSDK + +/// READ-only view that drives the document COUNT aggregation FFI +/// (`dash_sdk_document_count` via `SDK.documentCount`). Covers QA tests +/// DOC-10 (count total), DOC-11 (count filtered by `where`), and DOC-12 +/// (count grouped by `group_by`). +/// +/// This is a query view, not a state-transition builder — nothing is +/// signed or broadcast. It picks a loaded contract + document type using +/// the same accessible navigationLink pickers the Document builders use, +/// optionally takes `where` / `group_by` JSON, calls the wrapper, and +/// renders the total (and per-group counts when grouped) or the platform +/// error (e.g. "requires a countable index"). +struct CountDocumentsView: View { + @EnvironmentObject var appState: AppState + @Environment(\.modelContext) private var modelContext + + @Query private var contracts: [PersistentDataContract] + + @State private var selectedContract: PersistentDataContract? + @State private var selectedDocumentTypeName = "" + @State private var whereJSON = "" + @State private var groupByJSON = "" + + @State private var isRunning = false + /// Set once a run completes (success or failure) so the result + /// section appears. + @State private var didRun = false + @State private var result: DocumentCountResult? + @State private var errorMessage: String? + + var body: some View { + Form { + selectionSection + filterSection + runSection + if didRun { + resultSection + } + } + .navigationTitle("Count Documents") + .navigationBarTitleDisplayMode(.inline) + .onChange(of: selectedContract) { _, _ in + // A new contract may not have the previously-selected type — + // clear so the picker isn't stale, and drop any prior result. + selectedDocumentTypeName = "" + resetResult() + } + .onChange(of: selectedDocumentTypeName) { _, _ in + resetResult() + } + } + + // MARK: - Sections + + private var selectionSection: some View { + Section("Document") { + Picker("Contract", selection: $selectedContract) { + Text("Select a contract").tag(nil as PersistentDataContract?) + ForEach(activeContracts) { contract in + Text(contract.name) + .tag(contract as PersistentDataContract?) + .accessibilityIdentifier("countDocuments.contract.\(contract.idBase58)") + } + } + .accessibleFormPicker("countDocuments.contractPicker") + .disabled(isRunning) + + if let contract = selectedContract { + Picker("Document Type", selection: $selectedDocumentTypeName) { + Text("Select type").tag("") + ForEach(documentTypeNames(for: contract), id: \.self) { type in + Text(type) + .tag(type) + .accessibilityIdentifier("countDocuments.docType.\(type)") + } + } + .accessibleFormPicker("countDocuments.docTypePicker") + .disabled(isRunning) + } + } + } + + private var filterSection: some View { + Section { + TextField("[{\"field\":\"...\",\"operator\":\"==\",\"value\":...}]", text: $whereJSON) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .font(.system(.footnote, design: .monospaced)) + .accessibilityIdentifier("countDocuments.whereField") + .disabled(isRunning) + + TextField("[\"field1\",\"field2\"]", text: $groupByJSON) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .font(.system(.footnote, design: .monospaced)) + .accessibilityIdentifier("countDocuments.groupByField") + .disabled(isRunning) + } header: { + Text("Filters (optional)") + } footer: { + Text("`where` is a JSON array of [{field, operator, value}]. `group_by` is a JSON array of field names. Leave blank for an unfiltered total count. Counting requires a countable index on the document type.") + } + } + + private var runSection: some View { + Section { + Button(action: runCount) { + HStack { + if isRunning { + ProgressView() + .progressViewStyle(.circular) + } else { + Image(systemName: "number") + } + Text(isRunning ? "Counting…" : "Run Count") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + } + .disabled(!canRun) + .accessibilityIdentifier("countDocuments.runButton") + } + } + + @ViewBuilder + private var resultSection: some View { + if let errorMessage = errorMessage { + Section("Error") { + Text(errorMessage) + .foregroundColor(.red) + .font(.callout) + .textSelection(.enabled) + .accessibilityIdentifier("countDocuments.errorText") + } + } else if let result = result { + Section("Total") { + HStack { + Text("Count") + Spacer() + Text(result.total.map(String.init) ?? "—") + .fontWeight(.bold) + .foregroundColor(.primary) + .accessibilityIdentifier("countDocuments.totalCount") + } + } + + if result.isGrouped { + Section("Per-group counts") { + ForEach(groupedRows(result), id: \.key) { row in + HStack { + Text(row.key) + .font(.system(.footnote, design: .monospaced)) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Text(String(row.value)) + .fontWeight(.semibold) + } + .accessibilityIdentifier("countDocuments.groupRow.\(row.key)") + } + } + } + } + } + + // MARK: - Derived state + + /// Contracts limited to the active network — counting against another + /// network's contract would hit the wrong SDK network. + private var activeContracts: [PersistentDataContract] { + contracts.filter { $0.network == appState.currentNetwork } + } + + private var canRun: Bool { + selectedContract != nil + && !selectedDocumentTypeName.isEmpty + && !isRunning + } + + private func documentTypeNames(for contract: PersistentDataContract) -> [String] { + if let types = contract.documentTypes, !types.isEmpty { + return types.map { $0.name }.sorted() + } + return contract.documentTypesList.sorted() + } + + /// Per-group rows, sorted by hex key for a stable render. Excludes the + /// empty-string aggregate entry (shown in the Total section). + private func groupedRows(_ result: DocumentCountResult) -> [(key: String, value: UInt64)] { + result.counts + .filter { !$0.key.isEmpty } + .map { (key: $0.key, value: $0.value) } + .sorted { $0.key < $1.key } + } + + // MARK: - Actions + + private func resetResult() { + didRun = false + result = nil + errorMessage = nil + } + + private func runCount() { + guard let contract = selectedContract, + !selectedDocumentTypeName.isEmpty, + let sdk = appState.sdk else { + errorMessage = "SDK not initialized or no contract selected" + didRun = true + return + } + + let contractId = contract.idBase58 + let documentType = selectedDocumentTypeName + // Trim blanks → nil so empty fields mean "none" (null at the FFI). + let whereArg = trimmedOrNil(whereJSON) + let groupByArg = trimmedOrNil(groupByJSON) + + isRunning = true + errorMessage = nil + result = nil + + Task { + do { + let counted = try await sdk.documentCount( + dataContractId: contractId, + documentType: documentType, + whereJSON: whereArg, + orderByJSON: nil, + groupByJSON: groupByArg, + limit: -1 + ) + await MainActor.run { + self.result = counted + self.didRun = true + self.isRunning = false + } + } catch { + await MainActor.run { + self.errorMessage = error.localizedDescription + self.didRun = true + self.isRunning = false + } + } + } + } + + private func trimmedOrNil(_ s: String) -> String? { + let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionCategoryView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionCategoryView.swift index 77070100032..111ce24f1ac 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionCategoryView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionCategoryView.swift @@ -145,6 +145,25 @@ struct TransitionCategoryView: View { .padding(.vertical, 4) } } + + // Read-only COUNT aggregation query lives alongside the Document + // builders so it's discoverable next to the document operations, + // but routes to its own query view (it neither signs nor + // broadcasts). Drives QA tests DOC-10/11/12. + if category == .document { + NavigationLink(destination: CountDocumentsView()) { + VStack(alignment: .leading, spacing: 8) { + Text("Count Documents") + .font(.headline) + Text("Count documents (total, filtered by where, or grouped by group_by)") + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) + } + .padding(.vertical, 4) + } + .accessibilityIdentifier("transition.document.countDocuments") + } } .navigationTitle(category.rawValue) .navigationBarTitleDisplayMode(.inline) diff --git a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md index 2d6a95e4dbb..0b3258a680c 100644 --- a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md +++ b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md @@ -210,8 +210,13 @@ The app is a full multi-wallet client: `PlatformWalletManager` holds N wallets c | DOC-05 | Transfer document | Platform | Uncommon | 🧪 | *Settings builder* → `dash_sdk_document_transfer_to_identity`. | | DOC-06 | Update document price | Platform | Uncommon | 🧪 | *Settings builder* / `DocumentWithPriceView` → `dash_sdk_document_update_price_of_document`. | | DOC-07 | Purchase document | Platform | Uncommon | 🧪 | *Settings builder* → `dash_sdk_document_purchase`. | -| DOC-08 | Document count / sum / average aggregation | Platform | Uncommon | 🔌 | FFI `dash_sdk_document_count` / `_sum` / `_average`; no UI. | +| DOC-08 | Document aggregation (umbrella) | Platform | Uncommon | ➖ | Split into the rows below — `DOC-10` (count total), `DOC-11` (count filtered), `DOC-12` (count grouped), `DOC-13` (sum), `DOC-14` (average). Kept as a pointer only; select the specific row. | | DOC-09 | Create document (local demo) | Platform | — | ➖ | Retired. The old `DocumentsView` local-only mock was replaced by the real broadcast flow (`CreateDocumentView`); see `DOC-02`. | +| DOC-10 | Aggregation — count documents (total) | Platform | Uncommon | 🧪 | **Count Documents** read view → Swift wrapper over FFI `dash_sdk_document_count` (proof-verified). Total count is `counts[""]` in the `{counts:{hexKey:u64}}` result. Requires a contract whose doc type sets `documentsCountable: true` (e.g. the `countable` QA fixture). | +| DOC-11 | Aggregation — count documents, filtered (`where`) | Platform | Uncommon | 🧪 | Same Count view with a `where` clause → `dash_sdk_document_count(where_json=…)`. The filtered field must be a `countable` index. | +| DOC-12 | Aggregation — count documents, grouped (`group_by`) | Platform | Uncommon | 🧪 | Same Count view with a `group_by` field → `dash_sdk_document_count(group_by_json=…)`; returns one count per group (hex-encoded group key → `u64`). | +| DOC-13 | Aggregation — sum of a numeric property | Platform | Uncommon | 🚫 | FFI `dash_sdk_document_sum` returns `NotImplemented` — blocked upstream on grovedb PR 670 (range/sum aggregate). Will need a `summable` index once unblocked. | +| DOC-14 | Aggregation — average of a numeric property | Platform | Uncommon | 🚫 | FFI `dash_sdk_document_average` returns `NotImplemented` — blocked upstream on grovedb PR 670. Will need a `summable` index once unblocked. | ### 4.8 Tokens — `Domain=Token` @@ -347,7 +352,7 @@ Membership of each feature category across **all** sections (primary section mem - **DPNS** — `DPNS-01..07`, `MW-05` - **Voting** — `VOTE-01..07`, `DPNS-05`, `MW-05` - **Contract** — `DC-01..04` -- **Document** — `DOC-01..09`, `MW-04` +- **Document** — `DOC-01..14`, `MW-04` - **Token** — `TOK-01..16`, `MW-02`, `GRP-03` - **Shielded** — `SH-01..13`, `CORE-21`, `MW-06`, `MW-07`, `MW-11` - **DashPay** — `DP-01..06`, `MW-03` @@ -386,7 +391,7 @@ The complete Platform read surface, mapped to where each RPC is exercised in the ### Document | RPC | Tier | Status | Where | |---|---|---|---| -| getDocuments (incl. V1 COUNT/SUM/AVG, group_by, having) | Common | ✅ / 🔌 | `DocumentsView` / catalog; aggregation surface is FFI-only (`DOC-08`) | +| getDocuments (incl. V1 COUNT/SUM/AVG, group_by, having) | Common | ✅ / 🧪 / 🚫 | `DocumentsView` / catalog. COUNT (total/`where`/`group_by`) now has a **Count Documents** read view — `DOC-10/11/12`. SUM/AVG are upstream-blocked (grovedb PR 670) — `DOC-13/14`. `having` is not exposed by the FFI. | | getDocumentHistory | Thorough | ✅ | catalog | ### Token @@ -470,11 +475,12 @@ For completeness (the "everything gRPC + Core can do" requirement), these exist **🔌 SDK-only (FFI/wrapper exists, no UI):** - `ADDR-05` address balance-change history (recent / compacted / branch / trunk) -- `DOC-08` document count / sum / average aggregation - `SH-11` create identity from shielded pool (Type 20) - `SYS-06` raw GroveDB path elements **🚫 Not implemented anywhere:** +- `DOC-13` document SUM aggregation — FFI stub returns `NotImplemented` (blocked on grovedb PR 670) +- `DOC-14` document AVERAGE aggregation — FFI stub returns `NotImplemented` (blocked on grovedb PR 670) - `GRP-04` standalone group lifecycle management - `getConsensusParams` (served via Tenderdash RPC, not the SDK)