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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- JSON tab in the inspector, showing the selected row as JSON, with Show Row as JSON on a row's right-click menu.
- Foreign key expansion in the JSON tab, fetching the referenced row on click, five levels deep.
- Filter field in the JSON tab, taking text or a regular expression in slashes.
- Always Expand Foreign Keys in the JSON tab, off until turned on.
- JavaScript shell for MongoDB queries, with mongosh's `db` API, cursors, variables, functions and `print`.
- Per-connection MongoDB shell state, so a variable or function survives from one statement to the next.
- Cursor method autocomplete after `find()` and `aggregate()`.
Expand Down Expand Up @@ -37,6 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Row inspector lag on hover, on a tab switch and on every keystroke, from re-parsing each field's value.
- The data grid's row commands on a column's right-click menu in Structure, when the column was already selected.
- Wrong keyboard shortcuts shown beside Copy Name and Duplicate in the Structure right-click menu.
- Plugin download reporting no progress at all when a connection or a file needs a driver installed.
Expand Down
105 changes: 105 additions & 0 deletions TablePro/Core/Services/Query/ForeignKeyRowFetcher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
//
// ForeignKeyRowFetcher.swift
// TablePro
//
// The single-row lookup behind Preview Referenced Row and the JSON inspector's
// foreign key expansion.
//

import Foundation
import os
import TableProPluginKit

@MainActor
enum ForeignKeyRowFetcher {
struct FetchedRow: Sendable {
let columns: [String]
let columnTypes: [ColumnType]
let values: [PluginCellValue]
let foreignKeys: [String: JSONForeignKeyRef]
}

enum FetchFailure: Error {
case noConnection
}

private static let logger = Logger(subsystem: "com.TablePro", category: "ForeignKeyRowFetcher")

/// The referenced row, or nil when the key matches nothing. Two callers share this so the
/// popover and the inspector cannot end up reading a foreign key two different ways.
///
/// `includeForeignKeys` costs a metadata read for the referenced table's own constraints, which
/// only the inspector needs: it is what makes a nested key clickable in turn.
static func fetch(
connectionId: UUID,
databaseType: DatabaseType,
reference: JSONForeignKeyRef,
value: String,
includeForeignKeys: Bool = false
) async throws -> FetchedRow? {
guard let driver = DatabaseManager.shared.driver(for: connectionId) else {
throw FetchFailure.noConnection
}

let quotedTable: String
if let schema = reference.referencedSchema, !schema.isEmpty {
quotedTable = "\(driver.quoteIdentifier(schema)).\(driver.quoteIdentifier(reference.referencedTable))"
} else {
quotedTable = driver.quoteIdentifier(reference.referencedTable)
}

let query = ForeignKeyPreviewQuery.singleRow(
quotedTable: quotedTable,
quotedColumn: driver.quoteIdentifier(reference.referencedColumn),
escapedValue: driver.escapeStringLiteral(value),
dialect: PluginManager.shared.sqlDialect(for: databaseType)
)

let result = try await driver.execute(query: query)
guard let firstRow = result.rows.first else { return nil }

let foreignKeys = includeForeignKeys
? await referencedTableForeignKeys(connectionId: connectionId, reference: reference)
: [:]

return FetchedRow(
columns: result.columns,
columnTypes: result.columnTypes,
values: firstRow,
foreignKeys: foreignKeys
)
}

/// Answers from the schema prefetch when it covers the table, so following a chain of keys in
/// the same schema costs no extra round trips.
private static func referencedTableForeignKeys(
connectionId: UUID,
reference: JSONForeignKeyRef
) async -> [String: JSONForeignKeyRef] {
guard let scope = DatabaseManager.shared.browseScope(for: connectionId) else { return [:] }
let targetScope = reference.referencedSchema.map {
DatabaseScope(connectionId: connectionId, database: scope.database, schema: $0)
} ?? scope

if let cached = SchemaForeignKeyStore.shared.foreignKeysByColumn(
for: targetScope,
table: reference.referencedTable
) {
return cached.mapValues(JSONForeignKeyRef.init)
}

do {
let table = reference.referencedTable
let fetched = try await DatabaseManager.shared.withMetadataDriver(scope: targetScope) { driver in
try await driver.fetchForeignKeys(table: table)
}
return Dictionary(
fetched.map { ($0.column, JSONForeignKeyRef($0)) },
uniquingKeysWith: { first, _ in first }
)
} catch {
logger.error("Nested foreign key metadata fetch failed: \(error.localizedDescription)")
return [:]
}
}
}
5 changes: 4 additions & 1 deletion TablePro/Models/UI/InspectorContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ struct InspectorContext {
let isRowDeleted: Bool
let currentQuery: String?
let queryResults: String?
/// The same row the details tab shows, carried as raw cell values for the JSON tab.
let jsonRow: JSONRowSnapshot?

static let empty = InspectorContext(
tableName: nil,
Expand All @@ -25,6 +27,7 @@ struct InspectorContext {
isEditable: false,
isRowDeleted: false,
currentQuery: nil,
queryResults: nil
queryResults: nil,
jsonRow: nil
)
}
61 changes: 61 additions & 0 deletions TablePro/Models/UI/JSON/JSONDisplayRow.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
//
// JSONDisplayRow.swift
// TablePro
//
// One printed line of the JSON inspector.
//

import Foundation

enum JSONForeignKeyFailure: Equatable, Sendable {
case notFound
case cycle
case depthLimit
case failed(String)
}

struct JSONForeignKeyStates: Sendable {
var fetched: [JSONNodePath: JSONRowNode] = [:]
var loading: Set<JSONNodePath> = []
var failures: [JSONNodePath: JSONForeignKeyFailure] = [:]
}

struct JSONDisplayRow: Identifiable, Equatable, Sendable {
enum Token: Equatable, Sendable {
case scalar(JSONScalar)
case openObject
case closeObject
case openArray
case closeArray
case collapsedObject(count: Int)
case collapsedArray(count: Int)
}

enum Status: Equatable, Sendable {
case none
case loading
case failure(JSONForeignKeyFailure)
}

let id: String
let path: JSONNodePath
let depth: Int
let key: JSONNodeKey
let token: Token
let needsComma: Bool
/// The node's own value, carried beside the token rather than read out of it. An expanded
/// foreign key draws as `{`, and taking the value from the token alone took Copy Value and
/// Open off the line the moment it was opened.
let scalar: JSONScalar?
let foreignKey: JSONForeignKeyRef?
let isExpandable: Bool
let isExpanded: Bool
let status: Status

var showsKey: Bool {
switch token {
case .closeObject, .closeArray: false
default: key.text != nil
}
}
}
58 changes: 58 additions & 0 deletions TablePro/Models/UI/JSON/JSONForeignKeyExpansionPolicy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//
// JSONForeignKeyExpansionPolicy.swift
// TablePro
//
// How far the JSON inspector follows a chain of foreign keys.
//

import Foundation

struct JSONForeignKeyVisit: Hashable, Sendable {
let table: String
let schema: String?
let column: String
let value: String?

init(table: String, schema: String?, column: String, value: String?) {
self.table = table
self.schema = schema
self.column = column
self.value = value
}

init(ref: JSONForeignKeyRef, value: String?) {
self.init(
table: ref.referencedTable,
schema: ref.referencedSchema,
column: ref.referencedColumn,
value: value
)
}
}

enum JSONForeignKeyExpansionDecision: Equatable, Sendable {
case allowed
case cycle
case depthLimit
}

/// A row that references itself, directly or through another table, is ordinary schema design, so
/// the chain has to be checked rather than trusted: `employee.manager_id → employee` expands
/// forever otherwise. The depth cap covers the chains that do terminate but only after more
/// round trips than a reader wants.
enum JSONForeignKeyExpansionPolicy {
static let maxChainDepth = 5

/// How many levels "Always Expand Foreign Keys" fetches without being asked. One: the setting
/// exists to save the first click, not to walk the schema on every selection change.
static let autoExpandDepth = 1

static func decide(
chain: [JSONForeignKeyVisit],
next: JSONForeignKeyVisit
) -> JSONForeignKeyExpansionDecision {
if chain.contains(next) { return .cycle }
guard chain.count < maxChainDepth else { return .depthLimit }
return .allowed
}
}
124 changes: 124 additions & 0 deletions TablePro/Models/UI/JSON/JSONRowFilter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
//
// JSONRowFilter.swift
// TablePro
//
// Text and /regex/ filtering over a row's JSON tree.
//

import Foundation

struct JSONRowMatcher: Sendable {
private enum Kind {
case substring(String)
case regex(NSRegularExpression)
}

private let kind: Kind

/// `/…/` is read as a regular expression, anything else as a case-insensitive substring. A
/// trailing slash is required, so a reader typing a path such as `a/b` still gets a substring.
static func make(query: String) -> JSONRowFilterQuery {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return .empty }
if trimmed.count >= 2, trimmed.hasPrefix("/"), trimmed.hasSuffix("/") {
let pattern = String(trimmed.dropFirst().dropLast())
guard !pattern.isEmpty else { return .empty }
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
return .invalidRegex
}
return .matcher(JSONRowMatcher(kind: .regex(regex)))
}
return .matcher(JSONRowMatcher(kind: .substring(trimmed)))
}

func matches(_ text: String) -> Bool {
guard !text.isEmpty else { return false }
switch kind {
case .substring(let needle):
return text.range(of: needle, options: [.caseInsensitive, .diacriticInsensitive]) != nil
case .regex(let regex):
let range = NSRange(location: 0, length: (text as NSString).length)
return regex.firstMatch(in: text, options: [], range: range) != nil
}
}
}

enum JSONRowFilterQuery: Sendable {
case empty
case invalidRegex
case matcher(JSONRowMatcher)
}

enum JSONRowFilter {
/// Paths to keep for a filter: every node that matches, plus every ancestor of one, so a match
/// nested three levels down still arrives with the keys that lead to it.
static func visiblePaths(
root: JSONRowNode,
fetchedForeignKeys: [JSONNodePath: JSONRowNode],
matcher: JSONRowMatcher
) -> Set<JSONNodePath> {
var visible: Set<JSONNodePath> = []
_ = collect(
node: root,
fetched: fetchedForeignKeys,
matcher: matcher,
keepsEverything: false,
into: &visible
)
return visible
}

/// One walk, one visit per node.
///
/// A key that matches keeps what it holds, decided before descending and carried down as
/// `keepsEverything`, so a chain of matching ancestors costs one pass rather than one pass per
/// ancestor over the same leaves. Keeping the container alone left it drawn as `{…}` with a
/// disclosure control that could not open it, because a filtered tree takes its expansion from
/// what survived the filter rather than from the reader's expanded set.
///
/// Only the *key* keeps a subtree. A value that matches keeps its own line and the keys that
/// lead to it: an expanded foreign key carries both its own scalar and the fetched row's
/// fields, so treating a value match the same way answered a search for `1` with every column
/// of the referenced row.
private static func collect(
node: JSONRowNode,
fetched: [JSONNodePath: JSONRowNode],
matcher: JSONRowMatcher,
keepsEverything: Bool,
into visible: inout Set<JSONNodePath>
) -> Bool {
let keyMatches = node.key.text.map(matcher.matches) ?? false
let keepsDescendants = keepsEverything || keyMatches
var subtreeMatched = false

for child in children(of: node, fetched: fetched) {
if collect(
node: child,
fetched: fetched,
matcher: matcher,
keepsEverything: keepsDescendants,
into: &visible
) {
subtreeMatched = true
}
}

guard keepsDescendants || subtreeMatched || scalarMatches(node, matcher: matcher) else {
return false
}
visible.insert(node.path)
return true
}

static func children(of node: JSONRowNode, fetched: [JSONNodePath: JSONRowNode]) -> [JSONRowNode] {
if node.foreignKey != nil, let expansion = fetched[node.path] {
return expansion.children
}
return node.children
}

private static func scalarMatches(_ node: JSONRowNode, matcher: JSONRowMatcher) -> Bool {
guard let scalar = node.scalar else { return false }
return matcher.matches(scalar.searchableText)
}
}
Loading
Loading