From d4275e95ac1e3320999b72409c9472bde95eddde Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 20 Jun 2026 10:19:12 +0300 Subject: [PATCH] feat(swift-example-app): document sum/average aggregation view (DOC-13/14) Add a read-only "Sum / Average Documents" view that drives the document SUM and AVERAGE aggregation FFI shipped in #3935, moving QA tests DOC-13 (sum) and DOC-14 (average) from SDK-only to builder-only (drivable in the simulator). Mirrors the COUNT view added in #3926 for DOC-10/11/12. - Add thin-bridge wrappers SDK.documentSum / SDK.documentAverage over the dash_sdk_document_sum / dash_sdk_document_average FFI, plus the DocumentSumResult / DocumentAverageEntry / DocumentAverageResult result types. The FFI returns the raw (count, sum) pair; the sum/count division for display stays in the view (the wrappers only marshal). - Add SumAverageDocumentsView: op selector (Sum/Average), a required numeric sum-property field, optional where/group_by JSON, and result rendering (total + per-group rows, computed average for the avg op). - Wire it next to the Count link under Settings -> Platform State Transitions -> Document. - Flip DOC-13/14 to builder-only in TEST_PLAN.md and drop them from the SDK-only gaps list. Co-Authored-By: Claude Opus 4.8 --- .../FFI/PlatformQueryExtensions.swift | 298 +++++++++++++ .../Views/SumAverageDocumentsView.swift | 397 ++++++++++++++++++ .../Views/TransitionCategoryView.swift | 16 + .../swift-sdk/SwiftExampleApp/TEST_PLAN.md | 8 +- 4 files changed, 714 insertions(+), 5 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SumAverageDocumentsView.swift diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift index 78f5d2f589..e4bb3f2895 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift @@ -27,6 +27,83 @@ public struct DocumentCountResult: Sendable { } } +/// Result of ``SDK/documentSum(dataContractId:documentType:sumProperty:whereJSON:orderByJSON:groupByJSON:limit:)``. +/// +/// Mirrors the FFI's `{"sums": {"": }}` payload. Keys are the +/// hex-encoded group keys; for an ungrouped (aggregate) sum there is a single +/// entry under the empty-string key, exposed via ``total``. Values are signed +/// (`Int64`) to match grovedb's `SumValue = i64`. +public struct DocumentSumResult: Sendable { + /// Hex-encoded group key → sum. The empty-string key holds the aggregate + /// total for an ungrouped sum. + public let sums: [String: Int64] + + public init(sums: [String: Int64]) { + self.sums = sums + } + + /// The aggregate total for an ungrouped sum: `sums[""]`. `nil` when the + /// request was grouped (no empty-string entry). + public var total: Int64? { + sums[""] + } + + /// `true` when the result carries per-group sums (any non-empty key). + public var isGrouped: Bool { + sums.keys.contains { !$0.isEmpty } + } +} + +/// One `(count, sum)` pair returned by the average aggregation FFI for a single +/// key. Mirrors the FFI's `{"count": , "sum": }` object. The FFI +/// returns the raw pair un-divided so callers pick their own precision; compute +/// ``average`` (`sum / count`) at the point of display. +public struct DocumentAverageEntry: Sendable { + /// Number of documents folded into this entry. Unsigned (`UInt64`). + public let count: UInt64 + /// Sum of the numeric property over those documents. Signed (`Int64`) to + /// match grovedb's `SumValue = i64`. + public let sum: Int64 + + public init(count: UInt64, sum: Int64) { + self.count = count + self.sum = sum + } + + /// `sum / count` as a `Double`, or `nil` when `count == 0` (no documents + /// matched, so the average is undefined). + public var average: Double? { + count > 0 ? Double(sum) / Double(count) : nil + } +} + +/// Result of ``SDK/documentAverage(dataContractId:documentType:sumProperty:whereJSON:orderByJSON:groupByJSON:limit:)``. +/// +/// Mirrors the FFI's `{"averages": {"": {"count": , "sum": }}}` +/// payload. Keys are the hex-encoded group keys; for an ungrouped (aggregate) +/// average there is a single entry under the empty-string key, exposed via +/// ``total``. +public struct DocumentAverageResult: Sendable { + /// Hex-encoded group key → `(count, sum)` pair. The empty-string key holds + /// the aggregate total for an ungrouped average. + public let averages: [String: DocumentAverageEntry] + + public init(averages: [String: DocumentAverageEntry]) { + self.averages = averages + } + + /// The aggregate `(count, sum)` for an ungrouped average: `averages[""]`. + /// `nil` when the request was grouped (no empty-string entry). + public var total: DocumentAverageEntry? { + averages[""] + } + + /// `true` when the result carries per-group averages (any non-empty key). + public var isGrouped: Bool { + averages.keys.contains { !$0.isEmpty } + } +} + /// One element returned by ``SDK/systemPathElements(path:keys:)``. /// /// Mirrors a single object in the FFI's JSON array payload @@ -695,6 +772,227 @@ extension SDK { return DocumentCountResult(counts: counts) } + /// Sum a numeric property of a document type, optionally filtered by + /// `where` and grouped by `group_by`. + /// + /// Thin bridge over `dash_sdk_document_sum`: it fetches the contract handle + /// (same precedent as `documentCount`), marshals the document type + the + /// required `sumProperty` + optional `where`/`order_by`/`group_by` JSON + + /// the `limit` sentinel in, calls the FFI, and marshals the + /// `{"sums": {"": }}` 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 aggregate. + /// - sumProperty: Name of the integer property to sum. Required and + /// non-empty (the FFI rejects an empty string), so the wrapper rejects + /// it before calling. + /// - whereJSON: Optional `[{field, operator, value}]` filter JSON. Pass + /// `nil` for an unfiltered sum. + /// - orderByJSON: Optional `[{field, direction}]` JSON. Pass `nil` for + /// none. + /// - groupByJSON: Optional `["", ...]` JSON. Pass `nil` for an + /// aggregate (ungrouped) sum. + /// - limit: Sentinel-encoded `int64`. `-1` = server default (the + /// default). `> 0` = explicit cap. `0` is rejected at the FFI boundary, + /// so the wrapper rejects it before calling. + /// - Returns: A ``DocumentSumResult`` mapping hex-encoded group key → sum. + /// For an ungrouped sum the total lives under the empty-string key and is + /// surfaced via ``DocumentSumResult/total``. + @MainActor + public func documentSum( + dataContractId: String, + documentType: String, + sumProperty: String, + whereJSON: String? = nil, + orderByJSON: String? = nil, + groupByJSON: String? = nil, + limit: Int64 = -1 + ) async throws -> DocumentSumResult { + guard let handle = handle else { + throw SDKError.invalidState("SDK not initialized") + } + guard !sumProperty.isEmpty else { + // The FFI rejects an empty sum property; fail fast with a clear + // message rather than relaying it. + throw SDKError.invalidParameter("sumProperty must name the numeric property to sum; it cannot be empty") + } + 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: documentCount). + 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 strings in. sumProperty is required (non-null); + // nil where/order/group → null pointer = "none". + let result = documentType.withCString { typePtr in + sumProperty.withCString { sumPropPtr in + withOptionalCString(whereJSON) { wherePtr in + withOptionalCString(orderByJSON) { orderPtr in + withOptionalCString(groupByJSON) { groupPtr in + dash_sdk_document_sum( + handle, + contractHandle.assumingMemoryBound(to: DataContractHandle.self), + typePtr, + sumPropPtr, + wherePtr, + orderPtr, + groupPtr, + limit + ) + } + } + } + } + } + + let json = try processJSONResult(result) + + // Payload shape: {"sums": {"": }}. + guard let sumsObject = json["sums"] as? [String: Any] else { + throw SDKError.serializationError("Expected 'sums' object in document sum response") + } + + var sums: [String: Int64] = [:] + for (key, value) in sumsObject { + if let num = value as? NSNumber { + sums[key] = num.int64Value + } + } + + return DocumentSumResult(sums: sums) + } + + /// Average a numeric property of a document type, optionally filtered by + /// `where` and grouped by `group_by`. + /// + /// Thin bridge over `dash_sdk_document_average`: it fetches the contract + /// handle, marshals the document type + the required `sumProperty` + + /// optional `where`/`order_by`/`group_by` JSON + the `limit` sentinel in, + /// calls the FFI, and marshals the + /// `{"averages": {"": {"count": , "sum": }}}` payload + /// out. The FFI returns the raw `(count, sum)` pair un-divided so callers + /// pick their own precision; the wrapper preserves that pair verbatim and + /// leaves the division to the caller. 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 aggregate. + /// - sumProperty: Name of the integer property to average. Required and + /// non-empty (the FFI rejects an empty string), so the wrapper rejects + /// it before calling. + /// - whereJSON: Optional `[{field, operator, value}]` filter JSON. Pass + /// `nil` for an unfiltered average. + /// - orderByJSON: Optional `[{field, direction}]` JSON. Pass `nil` for + /// none. + /// - groupByJSON: Optional `["", ...]` JSON. Pass `nil` for an + /// aggregate (ungrouped) average. + /// - limit: Sentinel-encoded `int64`. `-1` = server default (the + /// default). `> 0` = explicit cap. `0` is rejected at the FFI boundary, + /// so the wrapper rejects it before calling. + /// - Returns: A ``DocumentAverageResult`` mapping hex-encoded group key → + /// `(count, sum)`. For an ungrouped average the total lives under the + /// empty-string key and is surfaced via ``DocumentAverageResult/total``. + @MainActor + public func documentAverage( + dataContractId: String, + documentType: String, + sumProperty: String, + whereJSON: String? = nil, + orderByJSON: String? = nil, + groupByJSON: String? = nil, + limit: Int64 = -1 + ) async throws -> DocumentAverageResult { + guard let handle = handle else { + throw SDKError.invalidState("SDK not initialized") + } + guard !sumProperty.isEmpty else { + // The FFI rejects an empty sum property; fail fast with a clear + // message rather than relaying it. + throw SDKError.invalidParameter("sumProperty must name the numeric property to average; it cannot be empty") + } + 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: documentCount). + 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 strings in. sumProperty is required (non-null); + // nil where/order/group → null pointer = "none". + let result = documentType.withCString { typePtr in + sumProperty.withCString { sumPropPtr in + withOptionalCString(whereJSON) { wherePtr in + withOptionalCString(orderByJSON) { orderPtr in + withOptionalCString(groupByJSON) { groupPtr in + dash_sdk_document_average( + handle, + contractHandle.assumingMemoryBound(to: DataContractHandle.self), + typePtr, + sumPropPtr, + wherePtr, + orderPtr, + groupPtr, + limit + ) + } + } + } + } + } + + let json = try processJSONResult(result) + + // Payload shape: {"averages": {"": {"count": , "sum": }}}. + guard let averagesObject = json["averages"] as? [String: Any] else { + throw SDKError.serializationError("Expected 'averages' object in document average response") + } + + var averages: [String: DocumentAverageEntry] = [:] + for (key, value) in averagesObject { + guard let entry = value as? [String: Any], + let countNum = entry["count"] as? NSNumber, + let sumNum = entry["sum"] as? NSNumber else { + continue + } + averages[key] = DocumentAverageEntry( + count: countNum.uint64Value, + sum: sumNum.int64Value + ) + } + + return DocumentAverageResult(averages: averages) + } + /// 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. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SumAverageDocumentsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SumAverageDocumentsView.swift new file mode 100644 index 0000000000..46df0e4507 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SumAverageDocumentsView.swift @@ -0,0 +1,397 @@ +import SwiftUI +import SwiftData +import SwiftDashSDK + +/// READ-only view that drives the document SUM and AVERAGE aggregation FFI +/// (`dash_sdk_document_sum` / `dash_sdk_document_average` via `SDK.documentSum` +/// / `SDK.documentAverage`). Covers QA tests DOC-13 (sum of a numeric +/// property) and DOC-14 (average of a numeric property). +/// +/// 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, takes a +/// required numeric `sum property`, optional `where` / `group_by` JSON, calls +/// the wrapper for the chosen operation, and renders the total (and per-group +/// rows when grouped) or the platform error (e.g. "requires a summable index"). +/// +/// The average FFI returns the raw `(count, sum)` pair un-divided; computing +/// the displayed average (`sum / count`) is presentation and happens here, not +/// in the wrapper. +struct SumAverageDocumentsView: View { + /// Which aggregation the view runs. + private enum Operation: String, CaseIterable, Identifiable { + case sum = "Sum" + case average = "Average" + + var id: String { rawValue } + } + + @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 operation: Operation = .sum + @State private var sumProperty = "" + @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 sumResult: DocumentSumResult? + @State private var averageResult: DocumentAverageResult? + @State private var errorMessage: String? + + var body: some View { + Form { + selectionSection + operationSection + filterSection + runSection + if didRun { + resultSection + } + } + .navigationTitle("Sum / Average 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() + } + .onChange(of: operation) { _, _ 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("sumAverageDocuments.contract.\(contract.idBase58)") + } + } + .accessibleFormPicker("sumAverageDocuments.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("sumAverageDocuments.docType.\(type)") + } + } + .accessibleFormPicker("sumAverageDocuments.docTypePicker") + .disabled(isRunning) + } + } + } + + private var operationSection: some View { + Section { + Picker("Operation", selection: $operation) { + ForEach(Operation.allCases) { op in + Text(op.rawValue).tag(op) + } + } + .pickerStyle(.segmented) + .accessibilityIdentifier("sumAverageDocuments.opPicker") + .disabled(isRunning) + + TextField("Numeric property to aggregate (e.g. amount)", text: $sumProperty) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .font(.system(.footnote, design: .monospaced)) + .accessibilityIdentifier("sumAverageDocuments.sumPropertyField") + .disabled(isRunning) + } header: { + Text("Aggregation") + } footer: { + Text("Pick \(Operation.sum.rawValue) or \(Operation.average.rawValue), then name the numeric property to aggregate. This property is required.") + } + } + + private var filterSection: some View { + Section { + TextField("[{\"field\":\"...\",\"operator\":\"==\",\"value\":...}]", text: $whereJSON) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .font(.system(.footnote, design: .monospaced)) + .accessibilityIdentifier("sumAverageDocuments.whereField") + .disabled(isRunning) + + TextField("[\"field1\",\"field2\"]", text: $groupByJSON) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .font(.system(.footnote, design: .monospaced)) + .accessibilityIdentifier("sumAverageDocuments.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 aggregate total. Aggregation requires a `summable` index on the numeric property of the document type.") + } + } + + private var runSection: some View { + Section { + Button(action: runAggregation) { + HStack { + if isRunning { + ProgressView() + .progressViewStyle(.circular) + } else { + Image(systemName: "sum") + } + Text(isRunning ? "Running…" : "Run \(operation.rawValue)") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + } + .disabled(!canRun) + .accessibilityIdentifier("sumAverageDocuments.runButton") + } + } + + @ViewBuilder + private var resultSection: some View { + if let errorMessage = errorMessage { + Section("Error") { + Text(errorMessage) + .foregroundColor(.red) + .font(.callout) + .textSelection(.enabled) + .accessibilityIdentifier("sumAverageDocuments.errorText") + } + } else if operation == .sum, let result = sumResult { + sumResultSections(result) + } else if operation == .average, let result = averageResult { + averageResultSections(result) + } + } + + @ViewBuilder + private func sumResultSections(_ result: DocumentSumResult) -> some View { + Section("Total") { + HStack { + Text("Sum") + Spacer() + Text(result.total.map(String.init) ?? "—") + .fontWeight(.bold) + .foregroundColor(.primary) + .accessibilityIdentifier("sumAverageDocuments.total") + } + } + + if result.isGrouped { + Section("Per-group sums") { + ForEach(groupedSumRows(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("sumAverageDocuments.groupRow.\(row.key)") + } + } + } + } + + @ViewBuilder + private func averageResultSections(_ result: DocumentAverageResult) -> some View { + Section("Total") { + HStack { + Text("Average") + Spacer() + Text(formatAverage(result.total)) + .fontWeight(.bold) + .foregroundColor(.primary) + .accessibilityIdentifier("sumAverageDocuments.average") + } + if let entry = result.total { + HStack { + Text("Count") + Spacer() + Text(String(entry.count)) + .foregroundColor(.secondary) + } + HStack { + Text("Sum") + Spacer() + Text(String(entry.sum)) + .foregroundColor(.secondary) + } + } + } + + if result.isGrouped { + Section("Per-group averages") { + ForEach(groupedAverageRows(result), id: \.key) { row in + HStack { + Text(row.key) + .font(.system(.footnote, design: .monospaced)) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + VStack(alignment: .trailing, spacing: 2) { + Text(formatAverage(row.entry)) + .fontWeight(.semibold) + Text("n=\(row.entry.count), Σ=\(row.entry.sum)") + .font(.caption2) + .foregroundColor(.secondary) + } + } + .accessibilityIdentifier("sumAverageDocuments.groupRow.\(row.key)") + } + } + } + } + + // MARK: - Derived state + + /// Contracts limited to the active network — aggregating 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 + && !sumProperty.trimmingCharacters(in: .whitespacesAndNewlines).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 sum rows, sorted by hex key for a stable render. Excludes the + /// empty-string aggregate entry (shown in the Total section). + private func groupedSumRows(_ result: DocumentSumResult) -> [(key: String, value: Int64)] { + result.sums + .filter { !$0.key.isEmpty } + .map { (key: $0.key, value: $0.value) } + .sorted { $0.key < $1.key } + } + + /// Per-group average rows, sorted by hex key for a stable render. Excludes + /// the empty-string aggregate entry (shown in the Total section). + private func groupedAverageRows(_ result: DocumentAverageResult) -> [(key: String, entry: DocumentAverageEntry)] { + result.averages + .filter { !$0.key.isEmpty } + .map { (key: $0.key, entry: $0.value) } + .sorted { $0.key < $1.key } + } + + /// Render an average entry's computed `sum / count`. The FFI returns the + /// raw `(count, sum)` pair; the division (presentation) happens here. A + /// `nil` entry or a zero `count` (no matched documents) renders as `—`. + private func formatAverage(_ entry: DocumentAverageEntry?) -> String { + guard let avg = entry?.average else { return "—" } + return String(format: "%.4f", avg) + } + + // MARK: - Actions + + private func resetResult() { + didRun = false + sumResult = nil + averageResult = nil + errorMessage = nil + } + + private func runAggregation() { + let trimmedProperty = sumProperty.trimmingCharacters(in: .whitespacesAndNewlines) + guard let contract = selectedContract, + !selectedDocumentTypeName.isEmpty, + !trimmedProperty.isEmpty, + let sdk = appState.sdk else { + errorMessage = "SDK not initialized, no contract selected, or no property given" + didRun = true + return + } + + let contractId = contract.idBase58 + let documentType = selectedDocumentTypeName + let op = operation + // Trim blanks → nil so empty fields mean "none" (null at the FFI). + let whereArg = trimmedOrNil(whereJSON) + let groupByArg = trimmedOrNil(groupByJSON) + + isRunning = true + errorMessage = nil + sumResult = nil + averageResult = nil + + Task { + do { + switch op { + case .sum: + let summed = try await sdk.documentSum( + dataContractId: contractId, + documentType: documentType, + sumProperty: trimmedProperty, + whereJSON: whereArg, + orderByJSON: nil, + groupByJSON: groupByArg, + limit: -1 + ) + await MainActor.run { + self.sumResult = summed + self.didRun = true + self.isRunning = false + } + case .average: + let averaged = try await sdk.documentAverage( + dataContractId: contractId, + documentType: documentType, + sumProperty: trimmedProperty, + whereJSON: whereArg, + orderByJSON: nil, + groupByJSON: groupByArg, + limit: -1 + ) + await MainActor.run { + self.averageResult = averaged + 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 111ce24f1a..f649683fcc 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionCategoryView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionCategoryView.swift @@ -163,6 +163,22 @@ struct TransitionCategoryView: View { .padding(.vertical, 4) } .accessibilityIdentifier("transition.document.countDocuments") + + // Read-only SUM/AVERAGE aggregation query, sibling to the Count + // view above. Routes to its own query view (it neither signs + // nor broadcasts). Drives QA tests DOC-13/14. + NavigationLink(destination: SumAverageDocumentsView()) { + VStack(alignment: .leading, spacing: 8) { + Text("Sum / Average Documents") + .font(.headline) + Text("Sum or average a numeric document property (total, filtered, or grouped)") + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) + } + .padding(.vertical, 4) + } + .accessibilityIdentifier("transition.document.sumAverageDocuments") } } .navigationTitle(category.rawValue) diff --git a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md index 7a816e041d..9faac90c6e 100644 --- a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md +++ b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md @@ -215,8 +215,8 @@ The app is a full multi-wallet client: `PlatformWalletManager` holds N wallets c | 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` **implemented** (the grovedb PR 670 aggregate-sum capability is present; wraps rs-sdk `DocumentSplitSums::fetch`, proof-verified) → `{sums:{hexKey:i64}}`. No app UI yet; needs a contract doc type with a `summable` index on a numeric property. | -| DOC-14 | Aggregation — average of a numeric property | Platform | Uncommon | 🔌 | FFI `dash_sdk_document_average` **implemented** (wraps rs-sdk `DocumentSplitAverages::fetch`) → `{averages:{hexKey:{count,sum}}}` (caller divides). No app UI yet; needs a `countable`+`summable` index. | +| DOC-13 | Aggregation — sum of a numeric property | Platform | Uncommon | 🧪 | **Sum / Average Documents** read view (op selector → **Sum**) → Swift wrapper over FFI `dash_sdk_document_sum` (proof-verified). Total sum is `sums[""]` in the `{sums:{hexKey:i64}}` result; a `where`/`group_by` filter and the required numeric `sum property` are entered in the same view. Needs a contract doc type with a `summable` index on the numeric property. | +| DOC-14 | Aggregation — average of a numeric property | Platform | Uncommon | 🧪 | Same **Sum / Average Documents** read view (op selector → **Average**) → Swift wrapper over FFI `dash_sdk_document_average` (proof-verified) → `{averages:{hexKey:{count,sum}}}`; the view divides `sum/count` for display. Needs a doc type with a `summable` index on the numeric property. | ### 4.8 Tokens — `Domain=Token` @@ -391,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. COUNT (total/`where`/`group_by`) now has a **Count Documents** read view — `DOC-10/11/12`. SUM/AVG are FFI-available (`DOC-13/14`) — no app UI yet. `having` is not exposed by the FFI. | +| getDocuments (incl. V1 COUNT/SUM/AVG, group_by, having) | Common | ✅ / 🧪 | `DocumentsView` / catalog. COUNT (total/`where`/`group_by`) has a **Count Documents** read view — `DOC-10/11/12`. SUM/AVG now have a **Sum / Average Documents** read view — `DOC-13/14`. `having` is not exposed by the FFI. | | getDocumentHistory | Thorough | ✅ | catalog | ### Token @@ -476,8 +476,6 @@ 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) - `SH-11` create identity from shielded pool (Type 20) -- `DOC-13` document SUM aggregation (FFI `dash_sdk_document_sum`) -- `DOC-14` document AVERAGE aggregation (FFI `dash_sdk_document_average`) **🚫 Not implemented anywhere:** - `GRP-04` standalone group lifecycle management