// // QueryViewController.swift // MongoView // // Created by Shadowfacts on 1/9/20. // Copyright © 2020 Shadowfacts. All rights reserved. // import Cocoa import MongoSwift import NIO class QueryViewController: NSViewController { @IBOutlet weak var verticalSplitView: NSSplitView! @IBOutlet var filterTextView: JavaScriptEditorView! @IBOutlet weak var outlineView: NSOutlineView! @IBOutlet weak var documentCountLabel: NSTextField! let mongoController: MongoController let collection: DatabaseCollection var defaultFilter: String { "{}" } var hasFilterChanged: Bool { return filterTextView.string != defaultFilter } var mostRecentQuery: String? = nil var rootNodes: [Node] = [] init(mongoController: MongoController, collection: DatabaseCollection) { self.mongoController = mongoController self.collection = collection super.init(nibName: "QueryViewController", bundle: .main) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() refresh() verticalSplitView.delegate = self verticalSplitView.setHoldingPriority(.defaultHigh, forSubviewAt: 0) filterTextView.isAutomaticQuoteSubstitutionEnabled = false filterTextView.string = defaultFilter outlineView.dataSource = self outlineView.delegate = self outlineView.target = self outlineView.doubleAction = #selector(outlineCellDoubleClicked) } override func viewWillAppear() { super.viewWillAppear() verticalSplitView.setPosition(80, ofDividerAt: 0) } func refresh(reload: Bool = true) { let filterText = filterTextView.string.trimmingCharacters(in: .whitespacesAndNewlines) let filter: BSONDocument if !filterText.isEmpty, let doc = ExtendedJSON.toDocument(filterText) { filter = doc } else { filter = [:] } mongoController.statusManager.set("Querying \(collection)...", for: .query, override: true) let collection = mongoController.collection(self.collection) collection.find(filter).flatMap { (cursor: MongoCursor) -> EventLoopFuture<[BSONDocument]> in return cursor.toArray() }.whenSuccess { (documents) in DispatchQueue.main.async { self.rootNodes = documents.map { Node(document: $0) } self.title = self.collection.description self.documentCountLabel.stringValue = "\(documents.count) document\(documents.count == 1 ? "" : "s")" if reload { self.outlineView.reloadData() } self.mongoController.statusManager.set("Queried \(self.collection)", for: .query, override: true) } } } func deleteRootNode(_ node: Node) { guard case let .document(doc) = node.value else { return } let alert = NSAlert() alert.alertStyle = .warning alert.messageText = "Confirm deletion" alert.informativeText = "Are you sure you want to delete the document" let id: BSONObjectID? if case let .objectID(docId) = doc["_id"] { id = docId alert.informativeText += " with id \(docId)" } else { id = nil } alert.addButton(withTitle: "Delete") alert.addButton(withTitle: "Cancel") alert.beginSheetModal(for: view.window!) { (response) in guard response == .alertFirstButtonReturn else { return } self.mongoController.collection(self.collection).deleteOne(doc).whenComplete { (result) in DispatchQueue.main.async { switch result { case let .success(result): guard let result = result, result.deletedCount == 1 else { let alert = NSAlert() alert.alertStyle = .critical alert.messageText = "Error deleting document" if let id = id { alert.informativeText = "The document with id \(id) could not be deleted." } else { alert.informativeText = "The document could not be deleted." } alert.beginSheetModal(for: self.view.window!, completionHandler: nil) return } self.refresh() self.mongoController.statusManager.set("Deleted document", for: .document) case let .failure(error): let alert = NSAlert(error: error) alert.beginSheetModal(for: self.view.window!, completionHandler: nil) } } } } } private func nodeForCopying() -> Node? { if outlineView.clickedRow >= 0 { return outlineView.item(atRow: outlineView.clickedRow) as? Node } else { return outlineView.item(atRow: outlineView.selectedRow) as? Node } } private func openEditWindow(_ document: BSONDocument) { let wc = EditDocumentWindowController(mongoController: mongoController, collection: collection, document: document) wc.documentEdited = { self.refresh() self.mongoController.statusManager.set("Updated document", for: .document) } wc.showWindow(nil) } @objc func outlineCellDoubleClicked() { if let node = outlineView.item(atRow: outlineView.clickedRow) as? Node { if node.hasChildren { if outlineView.isItemExpanded(node) { outlineView.collapseItem(node) } else { outlineView.expandItem(node) } } else if node.isValueInlineEditable { outlineView.editColumn(1, row: outlineView.clickedRow, with: nil, select: false) } } } @IBAction func deleteNode(_ sender: Any) { guard let node = outlineView.item(atRow: outlineView.clickedRow) as? Node else { return } if node.parent == nil { deleteRootNode(node) } } @IBAction func editDocument(_ sender: Any) { guard let node = outlineView.item(atRow: outlineView.clickedRow) as? Node, node.parent == nil, case let .document(document) = node.value else { return } openEditWindow(document) } @IBAction func copy(_ sender: Any) { guard let node = nodeForCopying() else { return } NSPasteboard.general.clearContents() NSPasteboard.general.setString(node.valueString, forType: .string) // todo: support copying more specific types? } @IBAction func copyAsJSON(_ sender: Any) { guard let node = nodeForCopying() else { return } let doc: BSONDocument = ["value": node.value] let ext = doc.toExtendedJSONString() // toExtendedJSON returns `{ "value": }`, drop the object wrapper let str = String(ext.dropFirst(12).dropLast(2)) NSPasteboard.general.clearContents() NSPasteboard.general.setString(str, forType: .string) } @objc func editedValue(_ textField: NSTextField) { guard let node = outlineView.item(atRow: outlineView.selectedRow) as? Node, case let .document(rootDoc) = node.root.value else { return } let proposedValue = textField.stringValue if let newValue = node.coerceBSONValue(proposedValue) { let updateDoc: BSONDocument = [ "$set": [ node.buildUpdateKey(): newValue ] ] mongoController.collection(collection).updateOne(filter: rootDoc, update: updateDoc).whenComplete { (result) in switch result { case .success(nil): fatalError() case .success(_): self.mongoController.statusManager.set("Updated document", for: .document) case let .failure(error): DispatchQueue.main.async { let alert = NSAlert(error: error) alert.beginSheetModal(for: self.view.window!, completionHandler: nil) } } } } else { textField.stringValue = node.valueString let alert = NSAlert() alert.alertStyle = .critical alert.messageText = "Invalid value format" alert.informativeText = "The value '\(proposedValue)' is not valid for fields of type \(node.value.type).\nIf you want to change the value type, edit the JSON document." alert.addButton(withTitle: "OK") alert.addButton(withTitle: "Edit Document") alert.beginSheetModal(for: self.view.window!) { (res) in alert.window.close() if res == .alertSecondButtonReturn { self.openEditWindow(rootDoc) } } } } } extension QueryViewController: NSMenuItemValidation { func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { if menuItem.action == #selector(deleteNode(_:)) || menuItem.action == #selector(editDocument(_:)) { if outlineView.clickedRow != -1, let node = outlineView.item(atRow: outlineView.clickedRow) as? Node, node.parent == nil { return true } else { return false } } else if menuItem.action == #selector(copy(_:)) || menuItem.action == #selector(copyAsJSON(_:)) { let node = nodeForCopying() return node != nil && node!.isValueCopyable } return true } } extension QueryViewController: NSSplitViewDelegate { func splitView(_ splitView: NSSplitView, constrainSplitPosition proposedPosition: CGFloat, ofSubviewAt dividerIndex: Int) -> CGFloat { return max(80, min(splitView.bounds.height / 2, proposedPosition)) } } extension QueryViewController: NSOutlineViewDataSource { func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { if item == nil { return rootNodes.count } else if let node = item as? Node { return node.numberOfChildren } else { return 0 } } func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { if let node = item as? Node { return node.hasChildren } else { return false } } func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { if item == nil { return rootNodes[index] } else if let node = item as? Node { return node.children[index] } else { fatalError("unreachable") } } } extension QueryViewController: NSOutlineViewDelegate { func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? { guard let tableColumn = tableColumn, let node = item as? Node else { fatalError() } if tableColumn.identifier == .fieldNameColumn { let cell = outlineView.makeView(withIdentifier: .fieldNameCell, owner: nil) as! NSTableCellView cell.textField!.stringValue = node.keyString cell.textField!.isEditable = false return cell } else if tableColumn.identifier == .fieldValueColumn { let cell = outlineView.makeView(withIdentifier: .fieldValueCell, owner: nil) as! NSTableCellView cell.textField!.stringValue = node.valueString cell.textField!.isEditable = node.isValueInlineEditable cell.textField!.target = self cell.textField!.action = #selector(editedValue(_:)) return cell } else if tableColumn.identifier == .valueTypeColumn { let cell = outlineView.makeView(withIdentifier: .valueTypeCell, owner: nil) as! NSTableCellView cell.textField!.stringValue = node.typeString cell.textField!.isEditable = false return cell } else { return nil } } } extension NSUserInterfaceItemIdentifier { static let fieldNameColumn = NSUserInterfaceItemIdentifier(rawValue: "FieldNameCol") static let fieldValueColumn = NSUserInterfaceItemIdentifier(rawValue: "FieldValueCol") static let fieldNameCell = NSUserInterfaceItemIdentifier(rawValue: "FieldNameCell") static let fieldValueCell = NSUserInterfaceItemIdentifier(rawValue: "FieldValueCell") static let valueTypeColumn = NSUserInterfaceItemIdentifier(rawValue: "ValueTypeCol") static let valueTypeCell = NSUserInterfaceItemIdentifier(rawValue: "ValueTypeCell") } fileprivate extension Node { var isValueCopyable: Bool { switch value.type { case .document, .array, .binary, .minKey, .maxKey: return false default: return true } } var isValueInlineEditable: Bool { switch value.type { case .double, .string, .objectID, .bool, .datetime, .int32, .int64, .decimal128: return true default: return false } } func coerceBSONValue(_ str: String) -> BSON? { guard isValueInlineEditable else { return false } switch value.type { case .double: if let d = Double(str) { return .double(d) } else { return nil } case .string: return .string(str) case .objectID: if let id = try? BSONObjectID(str) { return .objectID(id) } else { return nil } case .bool: let lower = str.lowercased() if lower == "true" { return .bool(true) } else if lower == "false" { return .bool(false) } else { return nil } case .datetime: if let date = Node.dateFormatter.date(from: str) { return .datetime(date) } else { return nil } case .int32: if let i = Int32(str) { return .int32(i) } else { return nil } case .int64: if let i = Int64(str) { return .int64(i) } else { return nil } case .decimal128: if let dec = try? BSONDecimal128(str) { return .decimal128(dec) } else { return nil } default: return nil } } func buildUpdateKey() -> String { let parentKey: String if let parent = parent { if case .objectID(_) = parent.key, parent.parent == nil { parentKey = "" } else { parentKey = parent.buildUpdateKey() + "." } } else { parentKey = "" } switch key { case let .index(index): return parentKey + index.description case let .name(name): return parentKey + name default: fatalError() } } }