diff --git a/.github/plugin-registry.json b/.github/plugin-registry.json index f6bf29a547..49dbf6ef8c 100644 --- a/.github/plugin-registry.json +++ b/.github/plugin-registry.json @@ -55,6 +55,9 @@ "MongoDBDriver": { "name": "MongoDB Driver" }, + "Neo4jDriverPlugin": { + "name": "Neo4j Driver" + }, "OracleDriver": { "name": "Oracle Driver" }, diff --git a/apple/Packages/TableProCore/Package.swift b/apple/Packages/TableProCore/Package.swift index 94fe92c270..61247fd6ed 100644 --- a/apple/Packages/TableProCore/Package.swift +++ b/apple/Packages/TableProCore/Package.swift @@ -37,6 +37,7 @@ let package = Package( .library(name: "TableProGoogleCloud", targets: ["TableProGoogleCloud"]), .library(name: "TableProSpannerCore", targets: ["TableProSpannerCore"]), .library(name: "TableProWeaviateCore", targets: ["TableProWeaviateCore"]), + .library(name: "TableProNeo4jCore", targets: ["TableProNeo4jCore"]), .library(name: "TableProFirebaseCore", targets: ["TableProFirebaseCore"]), .library(name: "TableProBigtableCore", targets: ["TableProBigtableCore"]), .library(name: "TableProNumberFormatting", targets: ["TableProNumberFormatting"]), @@ -192,6 +193,11 @@ let package = Package( dependencies: [], path: "Sources/TableProWeaviateCore" ), + .target( + name: "TableProNeo4jCore", + dependencies: [], + path: "Sources/TableProNeo4jCore" + ), .target( name: "TableProBigtableCore", dependencies: ["TableProGoogleCloud"], @@ -280,6 +286,11 @@ let package = Package( dependencies: ["TableProBigtableCore", "TableProGoogleCloud"], path: "Tests/TableProBigtableCoreTests" ), + .testTarget( + name: "TableProNeo4jCoreTests", + dependencies: ["TableProNeo4jCore"], + path: "Tests/TableProNeo4jCoreTests" + ), .testTarget( name: "TableProFirebaseCoreTests", dependencies: ["TableProFirebaseCore"], diff --git a/apple/Packages/TableProCore/Sources/TableProConnectionLibrary/SQLDDLFallbackPolicy.swift b/apple/Packages/TableProCore/Sources/TableProConnectionLibrary/SQLDDLFallbackPolicy.swift index d6962fdd63..09f552b188 100644 --- a/apple/Packages/TableProCore/Sources/TableProConnectionLibrary/SQLDDLFallbackPolicy.swift +++ b/apple/Packages/TableProCore/Sources/TableProConnectionLibrary/SQLDDLFallbackPolicy.swift @@ -46,6 +46,7 @@ public enum SQLDDLFallbackPolicy { "Firestore", "Firebase Realtime Database", "Bigtable", + "Neo4j", ] /// True when `DROP`/`TRUNCATE` built by the app is something this engine could run. diff --git a/apple/Packages/TableProCore/Sources/TableProNeo4jCore/BoltMessage.swift b/apple/Packages/TableProCore/Sources/TableProNeo4jCore/BoltMessage.swift new file mode 100644 index 0000000000..5b13e9e2e2 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProNeo4jCore/BoltMessage.swift @@ -0,0 +1,175 @@ +import Foundation + +public struct BoltVersion: Comparable, Hashable, Sendable, CustomStringConvertible { + public let major: UInt8 + public let minor: UInt8 + + public init(major: UInt8, minor: UInt8) { + self.major = major + self.minor = minor + } + + public var description: String { "\(major).\(minor)" } + + public static func < (lhs: BoltVersion, rhs: BoltVersion) -> Bool { + (lhs.major, lhs.minor) < (rhs.major, rhs.minor) + } + + public var separatesLogon: Bool { self >= BoltVersion(major: 5, minor: 1) } + public var sendsBoltAgent: Bool { self >= BoltVersion(major: 5, minor: 3) } + public var hasElementIds: Bool { major >= 5 } +} + +public enum BoltHandshake { + public static let magic: [UInt8] = [0x60, 0x60, 0xB0, 0x17] + + // Each proposal is [0, range, minor, major]: major.minor down to major.(minor - range). + public static let proposals: [UInt8] = [ + 0x00, 0x04, 0x04, 0x05, + 0x00, 0x02, 0x04, 0x04, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00 + ] + + public static var request: Data { Data(magic + proposals) } + + public static func version(fromResponse bytes: [UInt8]) -> BoltVersion? { + guard bytes.count == 4, bytes != [0, 0, 0, 0] else { return nil } + return BoltVersion(major: bytes[3], minor: bytes[2]) + } +} + +public enum BoltRequest: Equatable, Sendable { + case hello(extra: [String: PackStreamValue]) + case logon(auth: [String: PackStreamValue]) + case run(query: String, parameters: [String: PackStreamValue], extra: [String: PackStreamValue]) + case pull(count: Int64) + case discard + case reset + case goodbye + + public var structure: PackStreamValue { + switch self { + case .hello(let extra): + return .structure(tag: 0x01, fields: [.map(extra)]) + case .logon(let auth): + return .structure(tag: 0x6A, fields: [.map(auth)]) + case .run(let query, let parameters, let extra): + return .structure(tag: 0x10, fields: [.string(query), .map(parameters), .map(extra)]) + case .pull(let count): + return .structure(tag: 0x3F, fields: [.map(["n": .int(count)])]) + case .discard: + return .structure(tag: 0x2F, fields: [.map(["n": .int(-1)])]) + case .reset: + return .structure(tag: 0x0F, fields: []) + case .goodbye: + return .structure(tag: 0x02, fields: []) + } + } + + public var expectsResponse: Bool { self != .goodbye } + + public var framed: Data { BoltChunker.frame(PackStreamEncoder.encode(structure)) } +} + +public enum BoltResponse: Equatable, Sendable { + case success([String: PackStreamValue]) + case record([PackStreamValue]) + case ignored + case failure(code: String, message: String) + + public static func decode(_ message: Data) throws -> BoltResponse { + guard case .structure(let tag, let fields) = try PackStreamDecoder.decode(message) else { + throw BoltProtocolError.unexpectedMessage("not a structure") + } + switch tag { + case 0x70: + return .success(fields.first?.mapValue ?? [:]) + case 0x71: + return .record(fields.first?.listValue ?? []) + case 0x7E: + return .ignored + case 0x7F: + let metadata = fields.first?.mapValue ?? [:] + let code = metadata["code"]?.stringValue ?? metadata["neo4j_code"]?.stringValue ?? "" + return .failure(code: code, message: metadata["message"]?.stringValue ?? "") + default: + throw BoltProtocolError.unexpectedMessage(String(format: "tag 0x%02X", tag)) + } + } +} + +public enum BoltProtocolError: Error, Equatable, Sendable, LocalizedError { + case unexpectedMessage(String) + case unsupportedServer + case chunkTooLarge(Int) + + public var errorDescription: String? { + switch self { + case .unexpectedMessage(let detail): + return String(format: String(localized: "The server sent a message this client does not understand (%@)."), detail) + case .unsupportedServer: + return String(localized: "The server does not speak Bolt 4.2 to 5.4. Neo4j 4.2 or later is required.") + case .chunkTooLarge(let size): + return String(format: String(localized: "The server sent a %d-byte message, which is over the limit."), size) + } + } +} + +public enum BoltChunker { + public static let maximumChunkSize = Int(UInt16.max) + + public static func frame(_ message: Data) -> Data { + var framed = Data() + framed.reserveCapacity(message.count + 4) + var offset = message.startIndex + while offset < message.endIndex { + let end = min(offset + maximumChunkSize, message.endIndex) + let size = UInt16(end - offset) + framed.append(UInt8(size >> 8)) + framed.append(UInt8(size & 0xFF)) + framed.append(message[offset ..< end]) + offset = end + } + framed.append(contentsOf: [0x00, 0x00]) + return framed + } +} + +// A zero-size chunk with nothing before it is a NOOP keep-alive, not an empty message. +public struct BoltDechunker: Sendable { + public static let maximumMessageSize = 512 * 1_024 * 1_024 + + private var buffer: [UInt8] = [] + private var cursor = 0 + private var message = Data() + + public init() {} + + public mutating func append(_ bytes: Bytes) where Bytes.Element == UInt8 { + if cursor > 0, cursor >= buffer.count / 2 { + buffer.removeFirst(cursor) + cursor = 0 + } + buffer.append(contentsOf: bytes) + } + + public mutating func nextMessage() throws -> Data? { + while buffer.count - cursor >= 2 { + let size = Int(buffer[cursor]) << 8 | Int(buffer[cursor + 1]) + if size == 0 { + cursor += 2 + guard !message.isEmpty else { continue } + defer { message = Data() } + return message + } + guard buffer.count - cursor >= 2 + size else { return nil } + message.append(contentsOf: buffer[(cursor + 2) ..< (cursor + 2 + size)]) + cursor += 2 + size + guard message.count <= Self.maximumMessageSize else { + throw BoltProtocolError.chunkTooLarge(message.count) + } + } + return nil + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProNeo4jCore/Neo4jCypher.swift b/apple/Packages/TableProCore/Sources/TableProNeo4jCore/Neo4jCypher.swift new file mode 100644 index 0000000000..22d79a0760 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProNeo4jCore/Neo4jCypher.swift @@ -0,0 +1,345 @@ +import Foundation + +public enum Neo4jEntityKind: String, Sendable, CaseIterable { + case node + case relationship + + public var schemaName: String { + switch self { + case .node: "Labels" + case .relationship: "Relationship Types" + } + } + + public var entityType: String { + switch self { + case .node: "NODE" + case .relationship: "RELATIONSHIP" + } + } + + public init?(schemaName: String?) { + guard let match = Self.allCases.first(where: { $0.schemaName == schemaName }) else { return nil } + self = match + } +} + +public struct Neo4jFilter: Sendable, Equatable { + public let column: String + public let op: String + public let value: String + public let secondValue: String? + public let isCaseSensitive: Bool + + public init(column: String, op: String, value: String, secondValue: String? = nil, isCaseSensitive: Bool = true) { + self.column = column + self.op = op + self.value = value + self.secondValue = secondValue + self.isCaseSensitive = isCaseSensitive + } +} + +public enum Neo4jCypherError: Error, Equatable, Sendable, LocalizedError { + case unsupportedOperator(String) + case unquotableName(String) + case incompleteRange + + public var errorDescription: String? { + switch self { + case .unsupportedOperator(let op): + return String(format: String(localized: "The %@ filter is not available for Neo4j."), op) + case .unquotableName(let name): + return String(format: String(localized: "Neo4j cannot query the name %@ because it contains \\u."), name) + case .incompleteRange: + return String(localized: "Enter both bounds to filter with BETWEEN.") + } + } +} + +public enum Neo4jCypher { + public static let idColumn = "" + public static let labelsColumn = "" + public static let startColumn = "" + public static let endColumn = "" + public static let rawFilterColumn = "__RAW__" + + // A comment keeps a browse query valid Cypher when pasted into the editor. + public static let browseMarker = "// TablePro browse: " + + // Neo4j decodes `\uXXXX` before parsing, inside backticks too, so no spelling of `\u` stays inside its quotes. + // `\uZZZZ` is an escape the server rejects; the query builders refuse such a name before it gets that far. + public static func quote(_ identifier: String) -> String { + let escaped = identifier + .replacingOccurrences(of: "\\u", with: "\\uZZZZ") + .replacingOccurrences(of: "`", with: "``") + return "`" + escaped + "`" + } + + public static func isQuotable(_ name: String) -> Bool { + !name.contains("\\u") + } + + static func requireQuotable(_ names: [String]) throws { + if let name = names.first(where: { !isQuotable($0) }) { throw Neo4jCypherError.unquotableName(name) } + } + + public static func literal(_ text: String) -> String { + let escaped = text + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "\\'") + return "'" + escaped + "'" + } + + public static func browseKind(of query: String) -> Neo4jEntityKind? { + let trimmed = query.drop { $0.isWhitespace } + guard trimmed.hasPrefix(browseMarker) else { return nil } + let rest = trimmed.dropFirst(browseMarker.count).prefix { !$0.isNewline } + return Neo4jEntityKind(rawValue: rest.trimmingCharacters(in: .whitespaces)) + } + + public static func browsedName(in query: String) -> String? { + guard browseKind(of: query) != nil, let start = query.range(of: ":`") else { return nil } + var name = "" + var index = start.upperBound + while index < query.endIndex { + let character = query[index] + let next = query.index(after: index) + guard character == "`" else { + name.append(character) + index = next + continue + } + guard next < query.endIndex, query[next] == "`" else { return name } + name.append("`") + index = query.index(after: next) + } + return nil + } + + public static func fixedColumns(for kind: Neo4jEntityKind) -> [String] { + switch kind { + case .node: [idColumn, labelsColumn] + case .relationship: [idColumn, startColumn, endColumn] + } + } + + public static func pattern(for kind: Neo4jEntityKind, name: String) -> String { + switch kind { + case .node: "MATCH (n:\(quote(name)))" + case .relationship: "MATCH ()-[n:\(quote(name))]->()" + } + } + + public static func browseQuery( + kind: Neo4jEntityKind, + name: String, + filters: [Neo4jFilter] = [], + matchesAll: Bool = true, + sort: [(column: String, ascending: Bool)] = [], + limit: Int, + offset: Int, + usesElementId: Bool = true + ) throws -> String { + try requireQuotable([name] + sort.map(\.column)) + var lines = [browseMarker + kind.rawValue, pattern(for: kind, name: name)] + if let filter = try whereClause(filters, matchesAll: matchesAll, usesElementId: usesElementId) { + lines.append(filter) + } + lines.append("RETURN n") + if !sort.isEmpty { + let keys = sort.map { expression(for: $0.column, usesElementId: usesElementId) + ($0.ascending ? " ASC" : " DESC") } + lines.append("ORDER BY " + keys.joined(separator: ", ")) + } + lines.append("SKIP \(max(0, offset)) LIMIT \(max(0, limit))") + return lines.joined(separator: "\n") + } + + public static func exportQuery(kind: Neo4jEntityKind, name: String) throws -> String { + try requireQuotable([name]) + return [browseMarker + kind.rawValue, pattern(for: kind, name: name), "RETURN n"].joined(separator: "\n") + } + + public static func countQuery( + kind: Neo4jEntityKind, + name: String, + filters: [Neo4jFilter] = [], + matchesAll: Bool = true, + usesElementId: Bool = true + ) throws -> String { + try requireQuotable([name]) + var lines = [pattern(for: kind, name: name)] + if let filter = try whereClause(filters, matchesAll: matchesAll, usesElementId: usesElementId) { + lines.append(filter) + } + lines.append("RETURN count(n) AS count") + return lines.joined(separator: "\n") + } + + // Bounded so the Structure tab stays fast on a large graph, unlike `db.schema.nodeTypeProperties()`. + public static func propertySampleQuery(kind: Neo4jEntityKind, name: String, sampleSize: Int = 1_000) -> String { + pattern(for: kind, name: name) + + " WITH n LIMIT \(sampleSize) UNWIND keys(n) AS key" + + " RETURN key, head(collect(n[key])) AS sample ORDER BY key" + } + + public static func propertyKeysQuery(kind: Neo4jEntityKind, name: String) throws -> String { + try requireQuotable([name]) + return pattern(for: kind, name: name) + " UNWIND keys(n) AS key RETURN DISTINCT key ORDER BY key" + } + + public static func expression(for column: String, usesElementId: Bool) -> String { + let idFunction = usesElementId ? "elementId" : "id" + switch column { + case idColumn: return "\(idFunction)(n)" + case labelsColumn: return "labels(n)" + case startColumn: return "\(idFunction)(startNode(n))" + case endColumn: return "\(idFunction)(endNode(n))" + default: return "n." + quote(column) + } + } + + private static func whereClause(_ filters: [Neo4jFilter], matchesAll: Bool, usesElementId: Bool) throws -> String? { + let clauses = try filters.map { try condition(for: $0, usesElementId: usesElementId) } + guard !clauses.isEmpty else { return nil } + let joined = clauses.count == 1 + ? clauses[0] + : clauses.map { "(\($0))" }.joined(separator: matchesAll ? " AND " : " OR ") + return "WHERE " + joined + } + + private static let negations = ["!=": "=", "<>": "=", "NOT CONTAINS": "CONTAINS", "NOT IN": "IN"] + + static func condition(for filter: Neo4jFilter, usesElementId: Bool) throws -> String { + if filter.column == rawFilterColumn { return filter.value } + try requireQuotable([filter.column]) + let op = filter.op.uppercased() + if let presence = presence(op, column: filter.column, usesElementId: usesElementId) { return presence } + + let subject = Subject(filter: filter, usesElementId: usesElementId) + var predicate = try positive(negations[op] ?? op, filter: filter, subject: subject) + // `labels(n)` is a list, so a label filter matches when any one label does. + if filter.column == labelsColumn { predicate = "any(label IN labels(n) WHERE \(predicate))" } + return negations[op] == nil ? predicate : "NOT (\(predicate))" + } + + private struct Subject { + let expression: String + // An element id or a label is always a string, so it never compares as a number. + let isText: Bool + let foldsCase: Bool + + init(filter: Neo4jFilter, usesElementId: Bool) { + let isLabel = filter.column == labelsColumn + let isElementId = usesElementId && [idColumn, startColumn, endColumn].contains(filter.column) + expression = isLabel ? "label" : Neo4jCypher.expression(for: filter.column, usesElementId: usesElementId) + isText = isLabel || isElementId + foldsCase = !filter.isCaseSensitive && !isElementId + } + } + + private static func presence(_ op: String, column: String, usesElementId: Bool) -> String? { + let target = expression(for: column, usesElementId: usesElementId) + switch op { + case "IS NULL": return "\(target) IS NULL" + case "IS NOT NULL": return "\(target) IS NOT NULL" + case "IS EMPTY" where column == labelsColumn: return "size(labels(n)) = 0" + case "IS NOT EMPTY" where column == labelsColumn: return "size(labels(n)) > 0" + case "IS EMPTY": return "(\(target) IS NULL OR \(target) = '')" + case "IS NOT EMPTY": return "(\(target) IS NOT NULL AND \(target) <> '')" + default: return nil + } + } + + private static func positive(_ op: String, filter: Neo4jFilter, subject: Subject) throws -> String { + let target = subject.expression + switch op { + case "=": + return matchesAny(subject, [filter.value]) + case "IN": + return matchesAny(subject, filter.value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }) + case ">", ">=", "<", "<=": + return "\(target) \(op) \(bound(filter.value, subject))" + case "BETWEEN": + let range = try bounds(of: filter) + return "\(target) >= \(bound(range.lower, subject)) AND \(target) <= \(bound(range.upper, subject))" + case "CONTAINS", "STARTS WITH", "ENDS WITH": + let needle = literal(subject.foldsCase ? filter.value.lowercased() : filter.value) + return "\(text(subject, folded: subject.foldsCase)) \(op) \(needle)" + case "REGEX": + let pattern = subject.foldsCase ? "(?i)" + filter.value : filter.value + return "\(text(subject, folded: false)) =~ \(literal(pattern))" + default: + throw Neo4jCypherError.unsupportedOperator(filter.op) + } + } + + // A property can hold any type, so text operators read it as text first: a number or a list is then a + // non-match rather than a type error that fails the whole page. + private static func text(_ subject: Subject, folded: Bool) -> String { + let text = subject.isText ? subject.expression : "toStringOrNull(\(subject.expression))" + return folded ? "toLower(\(text))" : text + } + + private static func bound(_ value: String, _ subject: Subject) -> String { + subject.isText ? literal(value) : typedLiteral(value) + } + + private static func matchesAny(_ subject: Subject, _ values: [String]) -> String { + let target = subject.expression + if subject.isText { + let items = values.map { literal(subject.foldsCase ? $0.lowercased() : $0) } + let lhs = subject.foldsCase ? "toLower(\(target))" : target + return items.count == 1 ? "\(lhs) = \(items[0])" : "\(lhs) IN [\(items.joined(separator: ", "))]" + } + if subject.foldsCase { + // Case folding applies to text only, so `30.0` still matches `= 30`. + let folded = "\(text(subject, folded: true)) IN [\(values.map { literal($0.lowercased()) }.joined(separator: ", "))]" + let typed = values.map(typedLiteral).filter { !$0.hasPrefix("'") } + guard !typed.isEmpty else { return folded } + return "(\(target) IN [\(typed.joined(separator: ", "))] OR \(folded))" + } + // A property has no declared type, so `123` also matches the text '123'. + let forms = values.flatMap(spellings) + if forms.count == 1 { return "\(target) = \(forms[0])" } + if values.count == 1 { return "(" + forms.map { "\(target) = \($0)" }.joined(separator: " OR ") + ")" } + return "\(target) IN [\(forms.joined(separator: ", "))]" + } + + private static func spellings(_ value: String) -> [String] { + let typed = typedLiteral(value) + return typed == literal(value) ? [typed] : [typed, literal(value)] + } + + // The host sends BETWEEN's value as "lower,upper" and the upper bound again on its own. + private static func bounds(of filter: Neo4jFilter) throws -> (lower: String, upper: String) { + let lower: String + let upper: String + if let second = filter.secondValue { + let suffix = "," + second + lower = filter.value.hasSuffix(suffix) ? String(filter.value.dropLast(suffix.count)) : filter.value + upper = second + } else if let comma = filter.value.firstIndex(of: ",") { + lower = String(filter.value[.. String { + if let integer = Int64(value), String(integer) == value { return value } + if value.range(of: #"^-?(0|[1-9][0-9]*)\.[0-9]+([eE][+-]?[0-9]+)?$"#, options: .regularExpression) != nil, + let number = Double(value), number.isFinite { + return value + } + switch value.lowercased() { + case "true", "false": return value.lowercased() + default: return literal(value) + } + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProNeo4jCore/Neo4jEntity.swift b/apple/Packages/TableProCore/Sources/TableProNeo4jCore/Neo4jEntity.swift new file mode 100644 index 0000000000..8d2a53ad0c --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProNeo4jCore/Neo4jEntity.swift @@ -0,0 +1,64 @@ +import Foundation + +public enum Neo4jStructureTag { + public static let node: UInt8 = 0x4E + public static let relationship: UInt8 = 0x52 + public static let unboundRelationship: UInt8 = 0x72 + public static let path: UInt8 = 0x50 + public static let date: UInt8 = 0x44 + public static let time: UInt8 = 0x54 + public static let localTime: UInt8 = 0x74 + public static let dateTime: UInt8 = 0x49 + public static let dateTimeZoneId: UInt8 = 0x69 + public static let legacyDateTime: UInt8 = 0x46 + public static let legacyDateTimeZoneId: UInt8 = 0x66 + public static let localDateTime: UInt8 = 0x64 + public static let duration: UInt8 = 0x45 + public static let point2D: UInt8 = 0x58 + public static let point3D: UInt8 = 0x59 +} + +// Bolt 4 has no element id, so the numeric id stands in, written as text. +public struct Neo4jEntity: Equatable, Sendable { + public enum Kind: Equatable, Sendable { + case node(labels: [String]) + case relationship(type: String, startId: String, endId: String) + } + + public let elementId: String + public let kind: Kind + public let properties: [String: PackStreamValue] + + public init(elementId: String, kind: Kind, properties: [String: PackStreamValue]) { + self.elementId = elementId + self.kind = kind + self.properties = properties + } + + public init?(_ value: PackStreamValue) { + guard case .structure(let tag, let fields) = value else { return nil } + switch tag { + case Neo4jStructureTag.node where fields.count >= 3: + elementId = Self.identifier(fields, elementIdIndex: 3, idIndex: 0) + kind = .node(labels: fields[1].listValue?.compactMap(\.stringValue) ?? []) + properties = fields[2].mapValue ?? [:] + case Neo4jStructureTag.relationship where fields.count >= 5: + elementId = Self.identifier(fields, elementIdIndex: 5, idIndex: 0) + kind = .relationship( + type: fields[3].stringValue ?? "", + startId: Self.identifier(fields, elementIdIndex: 6, idIndex: 1), + endId: Self.identifier(fields, elementIdIndex: 7, idIndex: 2) + ) + properties = fields[4].mapValue ?? [:] + default: + return nil + } + } + + private static func identifier(_ fields: [PackStreamValue], elementIdIndex: Int, idIndex: Int) -> String { + if elementIdIndex < fields.count, let elementId = fields[elementIdIndex].stringValue { + return elementId + } + return fields[idIndex].intValue.map(String.init) ?? "" + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProNeo4jCore/Neo4jResultTable.swift b/apple/Packages/TableProCore/Sources/TableProNeo4jCore/Neo4jResultTable.swift new file mode 100644 index 0000000000..cefdfeca6f --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProNeo4jCore/Neo4jResultTable.swift @@ -0,0 +1,62 @@ +import Foundation + +public struct Neo4jResultTable: Equatable, Sendable { + public let columns: [String] + public let typeNames: [String] + public let rows: [[String?]] + + public init(columns: [String], typeNames: [String], rows: [[String?]]) { + self.columns = columns + self.typeNames = typeNames + self.rows = rows + } + + public static func records(keys: [String], records: [[PackStreamValue]]) -> Neo4jResultTable { + let typeNames = keys.indices.map { index -> String in + for record in records where index < record.count && record[index] != .null { + return Neo4jValueText.typeName(record[index]) + } + return "ANY" + } + let rows = records.map { record in + keys.indices.map { index in index < record.count ? Neo4jValueText.text(record[index]) : nil } + } + return Neo4jResultTable(columns: keys, typeNames: typeNames, rows: rows) + } + + // Known keys keep their order; keys first seen on this page follow, sorted. A stream's columns are fixed by its + // header, so it passes `addsUnseenProperties: false`. + public static func entities( + kind: Neo4jEntityKind, + records: [[PackStreamValue]], + knownProperties: [String], + addsUnseenProperties: Bool = true + ) -> Neo4jResultTable { + let entities = records.compactMap { $0.first.flatMap(Neo4jEntity.init) } + let known = Set(knownProperties) + let extra = addsUnseenProperties ? Set(entities.flatMap(\.properties.keys)).subtracting(known).sorted() : [] + let properties = knownProperties + extra + let fixed = Neo4jCypher.fixedColumns(for: kind) + + var typeNames = fixed.map { _ in "STRING" } + if kind == .node { typeNames[1] = "LIST" } + for key in properties { + let sample = entities.lazy.compactMap { $0.properties[key] }.first { $0 != .null } + typeNames.append(sample.map(Neo4jValueText.typeName) ?? "ANY") + } + + let rows = entities.map { entity -> [String?] in + var row: [String?] = [entity.elementId] + switch entity.kind { + case .node(let labels): + row.append(Neo4jValueText.text(.list(labels.map(PackStreamValue.string)))) + case .relationship(_, let startId, let endId): + row.append(startId) + row.append(endId) + } + row += properties.map { entity.properties[$0].flatMap(Neo4jValueText.text) } + return row + } + return Neo4jResultTable(columns: fixed + properties, typeNames: typeNames, rows: rows) + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProNeo4jCore/Neo4jUpdateCounts.swift b/apple/Packages/TableProCore/Sources/TableProNeo4jCore/Neo4jUpdateCounts.swift new file mode 100644 index 0000000000..619c215945 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProNeo4jCore/Neo4jUpdateCounts.swift @@ -0,0 +1,41 @@ +import Foundation + +public struct Neo4jUpdateCounts: Equatable, Sendable { + public enum Counter: String, CaseIterable, Sendable { + case nodesCreated = "nodes-created" + case nodesDeleted = "nodes-deleted" + case relationshipsCreated = "relationships-created" + case relationshipsDeleted = "relationships-deleted" + case propertiesSet = "properties-set" + case labelsAdded = "labels-added" + case labelsRemoved = "labels-removed" + case indexesAdded = "indexes-added" + case indexesRemoved = "indexes-removed" + case constraintsAdded = "constraints-added" + case constraintsRemoved = "constraints-removed" + case systemUpdates = "system-updates" + } + + public let counters: [(counter: Counter, count: Int)] + + public init(summary: [String: PackStreamValue]) { + let stats = summary["stats"]?.mapValue ?? [:] + counters = Counter.allCases.compactMap { counter in + let count = Int(stats[counter.rawValue]?.intValue ?? 0) + return count > 0 ? (counter, count) : nil + } + } + + public func count(_ counter: Counter) -> Int { + counters.first { $0.counter == counter }?.count ?? 0 + } + + // A property or label counter counts values, not entities. + public var entitiesChanged: Int { + [.nodesCreated, .nodesDeleted, .relationshipsCreated, .relationshipsDeleted].reduce(0) { $0 + count($1) } + } + + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.counters.elementsEqual(rhs.counters) { $0.counter == $1.counter && $0.count == $1.count } + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProNeo4jCore/Neo4jValueText.swift b/apple/Packages/TableProCore/Sources/TableProNeo4jCore/Neo4jValueText.swift new file mode 100644 index 0000000000..4285727595 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProNeo4jCore/Neo4jValueText.swift @@ -0,0 +1,305 @@ +import Foundation + +public enum Neo4jValueText { + public static func text(_ value: PackStreamValue) -> String? { + switch value { + case .null: + return nil + case .bool(let flag): + return flag ? "true" : "false" + case .int(let number): + return String(number) + case .float(let number): + return floatText(number) + case .string(let text): + return text + case .bytes(let bytes): + return "0x" + bytes.map { String(format: "%02x", $0) }.joined() + case .list, .map: + return json(jsonObject(value)) + case .structure(let tag, let fields): + if let scalar = structureText(tag: tag, fields: fields) { return scalar } + return json(jsonObject(value)) + } + } + + public static func typeName(_ value: PackStreamValue) -> String { + switch value { + case .null: return "NULL" + case .bool: return "BOOLEAN" + case .int: return "INTEGER" + case .float: return "FLOAT" + case .string: return "STRING" + case .bytes: return "BYTES" + case .list: return "LIST" + case .map: return "MAP" + case .structure(let tag, _): + switch tag { + case Neo4jStructureTag.node: return "NODE" + case Neo4jStructureTag.relationship, Neo4jStructureTag.unboundRelationship: return "RELATIONSHIP" + case Neo4jStructureTag.path: return "PATH" + case Neo4jStructureTag.date: return "DATE" + case Neo4jStructureTag.time: return "ZONED TIME" + case Neo4jStructureTag.localTime: return "LOCAL TIME" + case Neo4jStructureTag.dateTime, Neo4jStructureTag.dateTimeZoneId, + Neo4jStructureTag.legacyDateTime, Neo4jStructureTag.legacyDateTimeZoneId: + return "ZONED DATETIME" + case Neo4jStructureTag.localDateTime: return "LOCAL DATETIME" + case Neo4jStructureTag.duration: return "DURATION" + case Neo4jStructureTag.point2D, Neo4jStructureTag.point3D: return "POINT" + default: return "ANY" + } + } + } + + public static func jsonObject(_ value: PackStreamValue) -> Any { + switch value { + case .null: + return NSNull() + case .bool(let flag): + return flag + case .int(let number): + return number + case .float(let number): + return number.isFinite ? number as Any : floatText(number) + case .string(let text): + return text + case .bytes: + return text(value) ?? "" + case .list(let items): + return items.map(jsonObject) + case .map(let entries): + return entries.mapValues(jsonObject) + case .structure(let tag, let fields): + if let entity = Neo4jEntity(value) { + return entityObject(entity) + } + if tag == Neo4jStructureTag.unboundRelationship, fields.count >= 3 { + return [ + "elementId": fields.count > 3 ? fields[3].stringValue ?? "" : fields[0].intValue.map(String.init) ?? "", + "type": fields[1].stringValue ?? "", + "properties": (fields[2].mapValue ?? [:]).mapValues(jsonObject) + ] as [String: Any] + } + if tag == Neo4jStructureTag.path, fields.count == 3 { + return pathObject(fields) ?? [ + "nodes": (fields[0].listValue ?? []).map(jsonObject), + "relationships": (fields[1].listValue ?? []).map(jsonObject) + ] + } + return structureText(tag: tag, fields: fields) ?? NSNull() + } + } + + // Bolt sends a path's distinct nodes and relationships plus (relationship, node) index pairs; a positive + // 1-based relationship index walks forward, a negative one backward. + private static func pathObject(_ fields: [PackStreamValue]) -> [String: Any]? { + let nodes = (fields[0].listValue ?? []).compactMap(Neo4jEntity.init) + let relationships = fields[1].listValue ?? [] + let sequence = (fields[2].listValue ?? []).compactMap(\.intValue) + guard let start = nodes.first, sequence.count.isMultiple(of: 2) else { return nil } + + var orderedNodes = [entityObject(start)] + var orderedRelationships: [Any] = [] + var current = start + for step in stride(from: 0, to: sequence.count, by: 2) { + let relationshipIndex = Int(sequence[step].magnitude) - 1 + let nodeIndex = Int(sequence[step + 1]) + guard sequence[step] != 0, relationships.indices.contains(relationshipIndex), + nodes.indices.contains(nodeIndex), + case .structure(_, let relationshipFields) = relationships[relationshipIndex], + relationshipFields.count >= 3 + else { return nil } + let next = nodes[nodeIndex] + let forward = sequence[step] > 0 + orderedRelationships.append(entityObject(Neo4jEntity( + elementId: relationshipFields.count > 3 + ? relationshipFields[3].stringValue ?? "" + : relationshipFields[0].intValue.map(String.init) ?? "", + kind: .relationship( + type: relationshipFields[1].stringValue ?? "", + startId: forward ? current.elementId : next.elementId, + endId: forward ? next.elementId : current.elementId + ), + properties: relationshipFields[2].mapValue ?? [:] + ))) + orderedNodes.append(entityObject(next)) + current = next + } + return ["nodes": orderedNodes, "relationships": orderedRelationships] + } + + private static func entityObject(_ entity: Neo4jEntity) -> [String: Any] { + var object: [String: Any] = [ + "elementId": entity.elementId, + "properties": entity.properties.mapValues(jsonObject) + ] + switch entity.kind { + case .node(let labels): + object["labels"] = labels + case .relationship(let type, let startId, let endId): + object["type"] = type + object["startElementId"] = startId + object["endElementId"] = endId + } + return object + } + + private static func json(_ object: Any) -> String { + guard JSONSerialization.isValidJSONObject(object), + let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys, .withoutEscapingSlashes]), + let text = String(data: data, encoding: .utf8) + else { return String(describing: object) } + return text + } + + static func floatText(_ number: Double) -> String { + if number.isNaN { return "NaN" } + if number.isInfinite { return number < 0 ? "-Infinity" : "Infinity" } + return String(number) + } + + // MARK: - Temporal and spatial + + static func structureText(tag: UInt8, fields: [PackStreamValue]) -> String? { + let ints = fields.map { $0.intValue } + switch tag { + case Neo4jStructureTag.date: + guard let days = ints.first ?? nil else { return nil } + return dateText(days: days) + case Neo4jStructureTag.localTime: + guard let nanos = ints.first ?? nil else { return nil } + return timeText(nanosOfDay: nanos) + case Neo4jStructureTag.time: + guard ints.count == 2, let nanos = ints[0], let offset = ints[1] else { return nil } + return timeText(nanosOfDay: nanos) + offsetText(offset) + case Neo4jStructureTag.localDateTime: + guard ints.count == 2, let seconds = ints[0], let nanos = ints[1] else { return nil } + return dateTimeText(localSeconds: seconds, nanos: nanos) + case Neo4jStructureTag.dateTime, Neo4jStructureTag.legacyDateTime: + guard ints.count == 3, let seconds = ints[0], let nanos = ints[1], let offset = ints[2] else { return nil } + let local = tag == Neo4jStructureTag.dateTime ? seconds + offset : seconds + return dateTimeText(localSeconds: local, nanos: nanos) + offsetText(offset) + case Neo4jStructureTag.dateTimeZoneId, Neo4jStructureTag.legacyDateTimeZoneId: + guard fields.count == 3, let seconds = ints[0], let nanos = ints[1], + let zoneId = fields[2].stringValue else { return nil } + let zone = TimeZone(identifier: zoneId) + let offset: Int64 + if tag == Neo4jStructureTag.dateTimeZoneId { + offset = Int64(zone?.secondsFromGMT(for: Date(timeIntervalSince1970: TimeInterval(seconds))) ?? 0) + } else { + let guess = Int64(zone?.secondsFromGMT(for: Date(timeIntervalSince1970: TimeInterval(seconds))) ?? 0) + offset = Int64(zone?.secondsFromGMT(for: Date(timeIntervalSince1970: TimeInterval(seconds - guess))) ?? 0) + } + let local = tag == Neo4jStructureTag.dateTimeZoneId ? seconds + offset : seconds + return dateTimeText(localSeconds: local, nanos: nanos) + offsetText(offset) + "[\(zoneId)]" + case Neo4jStructureTag.duration: + guard ints.count == 4, let months = ints[0], let days = ints[1], + let seconds = ints[2], let nanos = ints[3] else { return nil } + return durationText(months: months, days: days, seconds: seconds, nanos: nanos) + case Neo4jStructureTag.point2D, Neo4jStructureTag.point3D: + guard let srid = ints.first ?? nil else { return nil } + let names = ["x", "y", "z"] + let coordinates = fields.dropFirst().enumerated().map { index, field -> String in + let number: Double + switch field { + case .float(let value): number = value + case .int(let value): number = Double(value) + default: number = .nan + } + return "\(names[min(index, 2)]): \(floatText(number))" + } + return "point({srid: \(srid), " + coordinates.joined(separator: ", ") + "})" + default: + return nil + } + } + + static func dateText(days: Int64) -> String { + let (year, month, day) = civilDate(daysSinceEpoch: days) + let yearText: String + if year < 0 || year > 9_999 { + yearText = (year < 0 ? "-" : "+") + String(format: "%04lld", abs(year)) + } else { + yearText = String(format: "%04lld", year) + } + return yearText + String(format: "-%02lld-%02lld", month, day) + } + + static func timeText(nanosOfDay: Int64) -> String { + let seconds = nanosOfDay / 1_000_000_000 + let nanos = nanosOfDay % 1_000_000_000 + return String(format: "%02lld:%02lld:%02lld", seconds / 3_600, seconds / 60 % 60, seconds % 60) + + fractionText(nanos) + } + + static func dateTimeText(localSeconds: Int64, nanos: Int64) -> String { + let days = floorDivide(localSeconds, 86_400) + let secondsOfDay = localSeconds - days * 86_400 + return dateText(days: days) + "T" + timeText(nanosOfDay: secondsOfDay * 1_000_000_000 + nanos) + } + + static func offsetText(_ offsetSeconds: Int64) -> String { + guard offsetSeconds != 0 else { return "Z" } + let sign = offsetSeconds < 0 ? "-" : "+" + let magnitude = abs(offsetSeconds) + var text = sign + String(format: "%02lld:%02lld", magnitude / 3_600, magnitude / 60 % 60) + if magnitude % 60 != 0 { text += String(format: ":%02lld", magnitude % 60) } + return text + } + + static func durationText(months: Int64, days: Int64, seconds: Int64, nanos: Int64) -> String { + var text = "P" + if months / 12 != 0 { text += "\(months / 12)Y" } + if months % 12 != 0 { text += "\(months % 12)M" } + if days != 0 { text += "\(days)D" } + // Seconds and nanoseconds stay apart: their product overflows Int64 for durations Neo4j accepts. + let carry = floorDivide(nanos, 1_000_000_000) + let (total, overflow) = seconds.addingReportingOverflow(carry) + let signedSeconds = overflow ? seconds : total + var fraction = nanos - carry * 1_000_000_000 + if signedSeconds != 0 || fraction != 0 { + let negative = signedSeconds < 0 + var wholeSeconds = signedSeconds.magnitude + if negative, fraction != 0 { + wholeSeconds -= 1 + fraction = 1_000_000_000 - fraction + } + var time = "T" + if wholeSeconds / 3_600 != 0 { time += (negative ? "-" : "") + "\(wholeSeconds / 3_600)H" } + if wholeSeconds / 60 % 60 != 0 { time += (negative ? "-" : "") + "\(wholeSeconds / 60 % 60)M" } + let remainder = wholeSeconds % 60 + if remainder != 0 || fraction != 0 { + time += (negative ? "-" : "") + "\(remainder)" + fractionText(fraction) + "S" + } + text += time + } + return text == "P" ? "PT0S" : text + } + + private static func fractionText(_ nanos: Int64) -> String { + guard nanos != 0 else { return "" } + var digits = String(format: "%09lld", nanos) + while digits.hasSuffix("0") { digits.removeLast() } + return "." + digits + } + + private static func floorDivide(_ value: Int64, _ divisor: Int64) -> Int64 { + let quotient = value / divisor + return (value % divisor != 0 && (value < 0) != (divisor < 0)) ? quotient - 1 : quotient + } + + // Howard Hinnant's `civil_from_days`. + private static func civilDate(daysSinceEpoch: Int64) -> (Int64, Int64, Int64) { + let shifted = daysSinceEpoch + 719_468 + let era = floorDivide(shifted, 146_097) + let dayOfEra = shifted - era * 146_097 + let yearOfEra = (dayOfEra - dayOfEra / 1_460 + dayOfEra / 36_524 - dayOfEra / 146_096) / 365 + let dayOfYear = dayOfEra - (365 * yearOfEra + yearOfEra / 4 - yearOfEra / 100) + let monthIndex = (5 * dayOfYear + 2) / 153 + let day = dayOfYear - (153 * monthIndex + 2) / 5 + 1 + let month = monthIndex < 10 ? monthIndex + 3 : monthIndex - 9 + let year = yearOfEra + era * 400 + (month <= 2 ? 1 : 0) + return (year, month, day) + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProNeo4jCore/PackStream.swift b/apple/Packages/TableProCore/Sources/TableProNeo4jCore/PackStream.swift new file mode 100644 index 0000000000..c6874b6a1c --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProNeo4jCore/PackStream.swift @@ -0,0 +1,257 @@ +import Foundation + +public indirect enum PackStreamValue: Equatable, Sendable { + case null + case bool(Bool) + case int(Int64) + case float(Double) + case bytes(Data) + case string(String) + case list([PackStreamValue]) + case map([String: PackStreamValue]) + case structure(tag: UInt8, fields: [PackStreamValue]) + + public var stringValue: String? { + if case .string(let value) = self { return value } + return nil + } + + public var intValue: Int64? { + if case .int(let value) = self { return value } + return nil + } + + public var boolValue: Bool? { + if case .bool(let value) = self { return value } + return nil + } + + public var listValue: [PackStreamValue]? { + if case .list(let value) = self { return value } + return nil + } + + public var mapValue: [String: PackStreamValue]? { + if case .map(let value) = self { return value } + return nil + } +} + +public enum PackStreamError: Error, Equatable, Sendable { + case truncated + case unknownMarker(UInt8) + case invalidString + case nonStringKey + case nestedTooDeeply +} + +public enum PackStreamEncoder { + public static func encode(_ value: PackStreamValue) -> Data { + var data = Data() + write(value, into: &data) + return data + } + + public static func write(_ value: PackStreamValue, into data: inout Data) { + switch value { + case .null: + data.append(0xC0) + case .bool(let flag): + data.append(flag ? 0xC3 : 0xC2) + case .int(let number): + writeInt(number, into: &data) + case .float(let number): + data.append(0xC1) + appendBigEndian(number.bitPattern, into: &data) + case .bytes(let bytes): + writeHeader(count: bytes.count, tiny: nil, markers: (0xCC, 0xCD, 0xCE), into: &data) + data.append(bytes) + case .string(let text): + let utf8 = Data(text.utf8) + writeHeader(count: utf8.count, tiny: 0x80, markers: (0xD0, 0xD1, 0xD2), into: &data) + data.append(utf8) + case .list(let items): + writeHeader(count: items.count, tiny: 0x90, markers: (0xD4, 0xD5, 0xD6), into: &data) + for item in items { write(item, into: &data) } + case .map(let entries): + writeHeader(count: entries.count, tiny: 0xA0, markers: (0xD8, 0xD9, 0xDA), into: &data) + for key in entries.keys.sorted() { + write(.string(key), into: &data) + write(entries[key] ?? .null, into: &data) + } + case .structure(let tag, let fields): + precondition(fields.count < 16, "Bolt has no marker for a structure over 15 fields") + data.append(0xB0 | UInt8(fields.count)) + data.append(tag) + for field in fields { write(field, into: &data) } + } + } + + private static func writeInt(_ number: Int64, into data: inout Data) { + switch number { + case -16 ... 127: + data.append(UInt8(bitPattern: Int8(number))) + case Int64(Int8.min) ... Int64(Int8.max): + data.append(0xC8) + data.append(UInt8(bitPattern: Int8(number))) + case Int64(Int16.min) ... Int64(Int16.max): + data.append(0xC9) + appendBigEndian(UInt16(bitPattern: Int16(number)), into: &data) + case Int64(Int32.min) ... Int64(Int32.max): + data.append(0xCA) + appendBigEndian(UInt32(bitPattern: Int32(number)), into: &data) + default: + data.append(0xCB) + appendBigEndian(UInt64(bitPattern: number), into: &data) + } + } + + private static func writeHeader( + count: Int, + tiny: UInt8?, + markers: (UInt8, UInt8, UInt8), + into data: inout Data + ) { + if let tiny, count < 16 { + data.append(tiny | UInt8(count)) + } else if count <= Int(UInt8.max) { + data.append(markers.0) + data.append(UInt8(count)) + } else if count <= Int(UInt16.max) { + data.append(markers.1) + appendBigEndian(UInt16(count), into: &data) + } else { + data.append(markers.2) + appendBigEndian(UInt32(count), into: &data) + } + } + + private static func appendBigEndian(_ value: T, into data: inout Data) { + withUnsafeBytes(of: value.bigEndian) { data.append(contentsOf: $0) } + } +} + +public struct PackStreamDecoder { + // Each level is a stack frame, so a server cannot overflow the stack with a deeply nested list. + public static let maximumDepth = 64 + + private let bytes: [UInt8] + private var index = 0 + private var depth = 0 + + public init(_ data: Data) { + bytes = [UInt8](data) + } + + public var isAtEnd: Bool { index >= bytes.count } + + public static func decode(_ data: Data) throws -> PackStreamValue { + var decoder = PackStreamDecoder(data) + return try decoder.next() + } + + public mutating func next() throws -> PackStreamValue { + depth += 1 + defer { depth -= 1 } + guard depth <= Self.maximumDepth else { throw PackStreamError.nestedTooDeeply } + let marker = try byte() + switch marker { + case 0x00 ... 0x7F: + return .int(Int64(marker)) + case 0xF0 ... 0xFF: + return .int(Int64(Int8(bitPattern: marker))) + case 0x80 ... 0x8F: + return try string(count: Int(marker & 0x0F)) + case 0x90 ... 0x9F: + return try list(count: Int(marker & 0x0F)) + case 0xA0 ... 0xAF: + return try map(count: Int(marker & 0x0F)) + case 0xB0 ... 0xBF: + let tag = try byte() + var fields: [PackStreamValue] = [] + for _ in 0 ..< Int(marker & 0x0F) { fields.append(try next()) } + return .structure(tag: tag, fields: fields) + case 0xC0: + return .null + case 0xC1: + return .float(Double(bitPattern: try integer(UInt64.self))) + case 0xC2: + return .bool(false) + case 0xC3: + return .bool(true) + case 0xC8: + return .int(Int64(Int8(bitPattern: try byte()))) + case 0xC9: + return .int(Int64(Int16(bitPattern: try integer(UInt16.self)))) + case 0xCA: + return .int(Int64(Int32(bitPattern: try integer(UInt32.self)))) + case 0xCB: + return .int(Int64(bitPattern: try integer(UInt64.self))) + case 0xCC: + return .bytes(Data(try take(Int(try byte())))) + case 0xCD: + return .bytes(Data(try take(Int(try integer(UInt16.self))))) + case 0xCE: + return .bytes(Data(try take(Int(try integer(UInt32.self))))) + case 0xD0: + return try string(count: Int(try byte())) + case 0xD1: + return try string(count: Int(try integer(UInt16.self))) + case 0xD2: + return try string(count: Int(try integer(UInt32.self))) + case 0xD4: + return try list(count: Int(try byte())) + case 0xD5: + return try list(count: Int(try integer(UInt16.self))) + case 0xD6: + return try list(count: Int(try integer(UInt32.self))) + case 0xD8: + return try map(count: Int(try byte())) + case 0xD9: + return try map(count: Int(try integer(UInt16.self))) + case 0xDA: + return try map(count: Int(try integer(UInt32.self))) + default: + throw PackStreamError.unknownMarker(marker) + } + } + + private mutating func byte() throws -> UInt8 { + guard index < bytes.count else { throw PackStreamError.truncated } + defer { index += 1 } + return bytes[index] + } + + private mutating func take(_ count: Int) throws -> ArraySlice { + guard count >= 0, bytes.count - index >= count else { throw PackStreamError.truncated } + defer { index += count } + return bytes[index ..< index + count] + } + + private mutating func integer(_: T.Type) throws -> T { + try take(MemoryLayout.size).reduce(T.zero) { ($0 << 8) | T($1) } + } + + private mutating func string(count: Int) throws -> PackStreamValue { + guard let text = String(bytes: try take(count), encoding: .utf8) else { + throw PackStreamError.invalidString + } + return .string(text) + } + + private mutating func list(count: Int) throws -> PackStreamValue { + var items: [PackStreamValue] = [] + items.reserveCapacity(min(count, 1_024)) + for _ in 0 ..< count { items.append(try next()) } + return .list(items) + } + + private mutating func map(count: Int) throws -> PackStreamValue { + var entries: [String: PackStreamValue] = [:] + for _ in 0 ..< count { + guard case .string(let key) = try next() else { throw PackStreamError.nonStringKey } + entries[key] = try next() + } + return .map(entries) + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalGrammar.swift b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalGrammar.swift index 79f0d0b989..3b1cf18fac 100644 --- a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalGrammar.swift +++ b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalGrammar.swift @@ -142,6 +142,9 @@ public struct SQLLexicalGrammar: OptionSet, Hashable, Sendable { /// it, so the kit has no feature for it. public static let pipeOperatorChains = SQLLexicalGrammar(rawValue: 1 << 37) + // `--` is code, as in Cypher's `(a)--(b)`. Only the curated Cypher grammar carries it, so the kit has no feature. + public static let withoutDashComments = SQLLexicalGrammar(rawValue: 1 << 38) + /// Standard SQL: quotes close only on a doubled quote, `--` and flat `/* */` are the only comments. public static let ansi: SQLLexicalGrammar = [] diff --git a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift index 4241c8d5ce..cd65a2a312 100644 --- a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift +++ b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift @@ -200,6 +200,13 @@ public struct SQLLexicalProfile: Sendable, Hashable { .doubleSlashLineComments, .statementsAsValues, ] + // Cypher, from the Neo4j manual: a backslash escapes in both quotes, backticks quote names, `//` is a comment and + // `--` is a pattern. Not measured. + static let cypher: SQLLexicalGrammar = [ + .backslashEscapesInSingleQuotes, .backslashEscapesInDoubleQuotes, .backtickQuotes, .doubleSlashLineComments, + .withoutDashComments, + ] + /// DynamoDB, from the driver's statement forms: a request is ` {JSON}`, and a backslash escapes inside a /// JSON string. PartiQL, the other form, documents only a doubled quote, so plain ANSI is kept as an alternative /// and a gate reads both. Not measured. @@ -313,6 +320,7 @@ public struct SQLLexicalProfile: Sendable, Hashable { .backslashEscapesInBackticks, .backslashEscapesInAngleQuotes, .carriageReturnEndsLineComments, ] ), + "Neo4j": SQLLexicalProfile(grammar: cypher, undetermined: [.carriageReturnEndsLineComments]), "Redis": commandLineFamily, "MongoDB": commandLineFamily, "etcd": commandLineFamily, diff --git a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalReadings.swift b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalReadings.swift index ce7cfb20c5..8f07b8e957 100644 --- a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalReadings.swift +++ b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalReadings.swift @@ -175,7 +175,7 @@ enum SQLLexicalRelevance { facts.insert(.executableComments) } case SqlLexer.dash where next == SqlLexer.dash: - facts.insert(.dashCommentsNeedWhitespace) + facts.formUnion([.dashCommentsNeedWhitespace, .withoutDashComments]) if index > 0, [capitalE, smallE].contains(text.character(at: index - 1)) { facts.insert(.exponentSignsNeedNoDigits) } diff --git a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLNonCodeSpan.swift b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLNonCodeSpan.swift index db717c6f52..705fe3d49a 100644 --- a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLNonCodeSpan.swift +++ b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLNonCodeSpan.swift @@ -228,7 +228,7 @@ public enum SQLNonCodeSpan { grammar: SQLLexicalGrammar ) -> Bool { if character == SqlLexer.dash { - guard SqlLexer.startsDashComment( + guard !grammar.contains(.withoutDashComments), SqlLexer.startsDashComment( text, at: index, length: length, diff --git a/apple/Packages/TableProCore/Tests/TableProNeo4jCoreTests/Neo4jCypherTests.swift b/apple/Packages/TableProCore/Tests/TableProNeo4jCoreTests/Neo4jCypherTests.swift new file mode 100644 index 0000000000..c774086150 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProNeo4jCoreTests/Neo4jCypherTests.swift @@ -0,0 +1,216 @@ +import Foundation +@testable import TableProNeo4jCore +import Testing + +struct Neo4jCypherTests { + @Test("Identifiers are backtick-quoted with embedded backticks doubled") + func quoting() { + #expect(Neo4jCypher.quote("Person") == "`Person`") + #expect(Neo4jCypher.quote("we`ird") == "`we``ird`") + #expect(Neo4jCypher.quote(#"a\b"#) == #"`a\b`"#) + #expect(Neo4jCypher.quote(#"x\u0060 RETURN 1"#) == #"`x\uZZZZ0060 RETURN 1`"#) + #expect(Neo4jCypher.literal(#"it's \ ok"#) == #"'it\'s \\ ok'"#) + } + + @Test("A label browse pages, sorts and carries the marker the driver reads back") + func labelBrowse() throws { + let query = try Neo4jCypher.browseQuery( + kind: .node, + name: "Person", + sort: [(column: "name", ascending: false), (column: "", ascending: true)], + limit: 200, + offset: 400 + ) + #expect(query == """ + // TablePro browse: node + MATCH (n:`Person`) + RETURN n + ORDER BY n.`name` DESC, elementId(n) ASC + SKIP 400 LIMIT 200 + """) + #expect(Neo4jCypher.browseKind(of: query) == .node) + #expect(Neo4jCypher.browseKind(of: "MATCH (n) RETURN n") == nil) + } + + @Test("A relationship browse matches directed relationships of one type") + func relationshipBrowse() throws { + let query = try Neo4jCypher.browseQuery(kind: .relationship, name: "KNOWS", limit: 10, offset: 0, usesElementId: false) + #expect(query.contains("MATCH ()-[n:`KNOWS`]->()")) + #expect(Neo4jCypher.browseKind(of: query) == .relationship) + } + + @Test("Filters become a WHERE clause joined by the logic mode") + func filters() throws { + let query = try Neo4jCypher.browseQuery( + kind: .node, + name: "Person", + filters: [ + Neo4jFilter(column: "age", op: ">=", value: "30"), + Neo4jFilter(column: "name", op: "CONTAINS", value: "an", isCaseSensitive: false) + ], + matchesAll: false, + limit: 10, + offset: 0 + ) + #expect(query.contains("WHERE (n.`age` >= 30) OR (toLower(toStringOrNull(n.`name`)) CONTAINS 'an')")) + } + + @Test("Equality on a number also matches the same text stored as a string") + func untypedEquality() throws { + let clause = try Neo4jCypher.condition(for: Neo4jFilter(column: "zip", op: "=", value: "123"), usesElementId: true) + #expect(clause == "(n.`zip` = 123 OR n.`zip` = '123')") + let text = try Neo4jCypher.condition(for: Neo4jFilter(column: "name", op: "=", value: "Ann"), usesElementId: true) + #expect(text == "n.`name` = 'Ann'") + let id = try Neo4jCypher.condition(for: Neo4jFilter(column: "", op: "=", value: "4:x:1"), usesElementId: true) + #expect(id == "elementId(n) = '4:x:1'") + } + + @Test("Text filters read any value as text, and labels match when one of them does") + func textFilters() throws { + let number = try Neo4jCypher.condition(for: Neo4jFilter(column: "age", op: "CONTAINS", value: "3"), usesElementId: true) + #expect(number == "toStringOrNull(n.`age`) CONTAINS '3'") + let labels = try Neo4jCypher.condition( + for: Neo4jFilter(column: "", op: "STARTS WITH", value: "Per", isCaseSensitive: false), usesElementId: true + ) + #expect(labels == "any(label IN labels(n) WHERE toLower(label) STARTS WITH 'per')") + let legacyId = try Neo4jCypher.condition(for: Neo4jFilter(column: "", op: "ENDS WITH", value: "7"), usesElementId: false) + #expect(legacyId == "toStringOrNull(id(n)) ENDS WITH '7'") + } + + @Test("Clearing case sensitivity applies to equality and lists too") + func caseInsensitiveEquality() throws { + let equal = try Neo4jCypher.condition( + for: Neo4jFilter(column: "name", op: "=", value: "Alice", isCaseSensitive: false), usesElementId: true + ) + #expect(equal == "toLower(toStringOrNull(n.`name`)) IN ['alice']") + let number = try Neo4jCypher.condition( + for: Neo4jFilter(column: "age", op: "!=", value: "30", isCaseSensitive: false), usesElementId: true + ) + #expect(number == "NOT ((n.`age` IN [30] OR toLower(toStringOrNull(n.`age`)) IN ['30']))") + let list = try Neo4jCypher.condition( + for: Neo4jFilter(column: "name", op: "NOT IN", value: "Ann, Bo", isCaseSensitive: false), usesElementId: true + ) + #expect(list == "NOT (toLower(toStringOrNull(n.`name`)) IN ['ann', 'bo'])") + let mixed = try Neo4jCypher.condition( + for: Neo4jFilter(column: "zip", op: "IN", value: "1, A", isCaseSensitive: false), usesElementId: true + ) + #expect(mixed == "(n.`zip` IN [1] OR toLower(toStringOrNull(n.`zip`)) IN ['1', 'a'])") + let id = try Neo4jCypher.condition( + for: Neo4jFilter(column: "", op: "=", value: "4:X:1", isCaseSensitive: false), usesElementId: true + ) + #expect(id == "elementId(n) = '4:X:1'") + } + + @Test("A list matches each item as typed and as text, like equality does") + func inList() throws { + let list = try Neo4jCypher.condition(for: Neo4jFilter(column: "zip", op: "IN", value: "1, a"), usesElementId: true) + #expect(list == "n.`zip` IN [1, '1', 'a']") + } + + @Test("BETWEEN takes its bounds from the joined value the host sends, not the whole of it") + func betweenBounds() throws { + let joined = Neo4jFilter(column: "age", op: "BETWEEN", value: "10,20", secondValue: "20") + #expect(try Neo4jCypher.condition(for: joined, usesElementId: true) == "n.`age` >= 10 AND n.`age` <= 20") + let unsplit = Neo4jFilter(column: "name", op: "BETWEEN", value: "a, m") + #expect(try Neo4jCypher.condition(for: unsplit, usesElementId: true) == "n.`name` >= 'a' AND n.`name` <= 'm'") + let commaInBound = Neo4jFilter(column: "tag", op: "BETWEEN", value: "a,b,c", secondValue: "c") + #expect(try Neo4jCypher.condition(for: commaInBound, usesElementId: true) == "n.`tag` >= 'a,b' AND n.`tag` <= 'c'") + #expect(throws: Neo4jCypherError.incompleteRange) { + try Neo4jCypher.condition(for: Neo4jFilter(column: "age", op: "BETWEEN", value: "10"), usesElementId: true) + } + } + + @Test("Every operator on matches against each label, since labels(n) is a list") + func labelOperators() throws { + func clause(_ op: String, _ value: String = "Person", caseSensitive: Bool = true) throws -> String { + try Neo4jCypher.condition( + for: Neo4jFilter(column: "", op: op, value: value, isCaseSensitive: caseSensitive), + usesElementId: true + ) + } + #expect(try clause("=") == "any(label IN labels(n) WHERE label = 'Person')") + #expect(try clause("!=") == "NOT (any(label IN labels(n) WHERE label = 'Person'))") + #expect(try clause("=", caseSensitive: false) == "any(label IN labels(n) WHERE toLower(label) = 'person')") + #expect(try clause("IN", "A, B") == "any(label IN labels(n) WHERE label IN ['A', 'B'])") + #expect(try clause("NOT IN", "A, B") == "NOT (any(label IN labels(n) WHERE label IN ['A', 'B']))") + #expect(try clause("REGEX", "Per.*") == "any(label IN labels(n) WHERE label =~ 'Per.*')") + #expect(try clause(">", "M") == "any(label IN labels(n) WHERE label > 'M')") + #expect(try clause("IS EMPTY", "") == "size(labels(n)) = 0") + #expect(try clause("IS NOT EMPTY", "") == "size(labels(n)) > 0") + } + + @Test("REGEX reads the value as text, like the other text operators") + func regexReadsText() throws { + let sensitive = try Neo4jCypher.condition(for: Neo4jFilter(column: "zip", op: "REGEX", value: "9.*"), usesElementId: true) + #expect(sensitive == "toStringOrNull(n.`zip`) =~ '9.*'") + let folded = try Neo4jCypher.condition( + for: Neo4jFilter(column: "zip", op: "REGEX", value: "a.*", isCaseSensitive: false), usesElementId: true + ) + #expect(folded == "toStringOrNull(n.`zip`) =~ '(?i)a.*'") + } + + @Test("A string literal escapes a backslash, so the server's unicode-escape pass cannot reopen it") + func literalEscapes() { + let value = "\\" + "u0027) DETACH DELETE n //" + #expect(Neo4jCypher.literal(value) == "'\\\\" + "u0027) DETACH DELETE n //'") + } + + @Test("An export reads every entity, with no page limit") + func exportQuery() throws { + #expect(try Neo4jCypher.exportQuery(kind: .node, name: "Person") == "// TablePro browse: node\nMATCH (n:`Person`)\nRETURN n") + #expect(Neo4jCypher.browsedName(in: try Neo4jCypher.exportQuery(kind: .relationship, name: "we`ird")) == "we`ird") + } + + @Test("Only canonical numbers become number literals") + func numberLiterals() { + #expect(Neo4jCypher.typedLiteral("42") == "42") + #expect(Neo4jCypher.typedLiteral("-7") == "-7") + #expect(Neo4jCypher.typedLiteral("2.5") == "2.5") + #expect(Neo4jCypher.typedLiteral("01234") == "'01234'") + #expect(Neo4jCypher.typedLiteral("08") == "'08'") + #expect(Neo4jCypher.typedLiteral("99999999999999999999") == "'99999999999999999999'") + #expect(Neo4jCypher.typedLiteral("5.") == "'5.'") + #expect(Neo4jCypher.typedLiteral("+3") == "'+3'") + #expect(Neo4jCypher.typedLiteral("TRUE") == "true") + } + + @Test("A filtered count uses the same conditions as the page it counts") + func filteredCount() throws { + let query = try Neo4jCypher.countQuery( + kind: .relationship, + name: "KNOWS", + filters: [Neo4jFilter(column: "since", op: ">", value: "2020")], + usesElementId: true + ) + #expect(query == "MATCH ()-[n:`KNOWS`]->()\nWHERE n.`since` > 2020\nRETURN count(n) AS count") + #expect(try Neo4jCypher.countQuery(kind: .node, name: "A") == "MATCH (n:`A`)\nRETURN count(n) AS count") + } + + @Test("A name holding a unicode escape is refused, since Neo4j would decode it inside the quotes") + func unicodeEscapeNames() { + let name = #"x\u0060 IS NULL DETACH DELETE n"# + #expect(throws: Neo4jCypherError.unquotableName(name)) { + try Neo4jCypher.browseQuery(kind: .node, name: name, limit: 1, offset: 0) + } + #expect(throws: Neo4jCypherError.unquotableName(name)) { + try Neo4jCypher.countQuery(kind: .node, name: "A", filters: [Neo4jFilter(column: name, op: "IS NULL", value: "")]) + } + #expect(throws: Neo4jCypherError.unquotableName(name)) { + try Neo4jCypher.browseQuery(kind: .node, name: "A", sort: [(name, true)], limit: 1, offset: 0) + } + } + + @Test("An unknown operator is refused") + func unknownOperator() { + #expect(throws: Neo4jCypherError.unsupportedOperator("~~")) { + try Neo4jCypher.condition(for: Neo4jFilter(column: "a", op: "~~", value: "x"), usesElementId: true) + } + } + + @Test("Schema names map back to entity kinds") + func kinds() { + #expect(Neo4jEntityKind(schemaName: "Labels") == .node) + #expect(Neo4jEntityKind(schemaName: "Relationship Types") == .relationship) + #expect(Neo4jEntityKind(schemaName: "public") == nil) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProNeo4jCoreTests/Neo4jValueTextTests.swift b/apple/Packages/TableProCore/Tests/TableProNeo4jCoreTests/Neo4jValueTextTests.swift new file mode 100644 index 0000000000..1d23038ddc --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProNeo4jCoreTests/Neo4jValueTextTests.swift @@ -0,0 +1,161 @@ +import Foundation +@testable import TableProNeo4jCore +import Testing + +struct Neo4jValueTextTests { + private func structure(_ tag: UInt8, _ fields: [PackStreamValue]) -> PackStreamValue { + .structure(tag: tag, fields: fields) + } + + @Test("Temporal values read the way Cypher's toString writes them") + func temporals() { + #expect(Neo4jValueText.text(structure(0x44, [.int(19_723)])) == "2024-01-01") + #expect(Neo4jValueText.text(structure(0x44, [.int(-1)])) == "1969-12-31") + #expect(Neo4jValueText.text(structure(0x74, [.int(45_296_500_000_000)])) == "12:34:56.5") + #expect(Neo4jValueText.text(structure(0x54, [.int(3_600_000_000_000), .int(25_200)])) == "01:00:00+07:00") + #expect(Neo4jValueText.text(structure(0x64, [.int(1_704_067_200), .int(0)])) == "2024-01-01T00:00:00") + #expect(Neo4jValueText.text(structure(0x49, [.int(1_704_067_200), .int(0), .int(25_200)])) + == "2024-01-01T07:00:00+07:00") + #expect(Neo4jValueText.text(structure(0x46, [.int(1_704_067_200), .int(0), .int(0)])) + == "2024-01-01T00:00:00Z") + #expect(Neo4jValueText.text(structure(0x69, [.int(1_704_067_200), .int(0), .string("Asia/Ho_Chi_Minh")])) + == "2024-01-01T07:00:00+07:00[Asia/Ho_Chi_Minh]") + } + + @Test("Durations collapse months into years and seconds into hours and minutes") + func durations() { + #expect(Neo4jValueText.text(structure(0x45, [.int(14), .int(3), .int(3_700), .int(500_000_000)])) + == "P1Y2M3DT1H1M40.5S") + #expect(Neo4jValueText.text(structure(0x45, [.int(0), .int(0), .int(0), .int(0)])) == "PT0S") + } + + @Test("Durations too long for Int64 nanoseconds still format") + func largeDurations() { + #expect(Neo4jValueText.text(structure(0x45, [.int(0), .int(0), .int(10_000_000_000), .int(0)])) + == "PT2777777H46M40S") + #expect(Neo4jValueText.text(structure(0x45, [.int(0), .int(0), .int(-10_000_000_000), .int(0)])) + == "PT-2777777H-46M-40S") + #expect(Neo4jValueText.text(structure(0x45, [.int(0), .int(0), .int(-2), .int(500_000_000)])) == "PT-1.5S") + #expect(Neo4jValueText.text(structure(0x45, [.int(0), .int(0), .int(.max), .int(999_999_999)])) + == "PT2562047788015215H30M7.999999999S") + #expect(Neo4jValueText.text(structure(0x45, [.int(0), .int(0), .int(.min), .int(0)])) + == "PT-2562047788015215H-30M-8S") + } + + @Test("A path lists its steps in order, with each relationship pointing the way it was walked") + func pathSteps() throws { + let first = structure(0x4E, [.int(1), .list([.string("A")]), .map([:]), .string("n1")]) + let second = structure(0x4E, [.int(2), .list([.string("B")]), .map([:]), .string("n2")]) + let knows = structure(0x72, [.int(9), .string("KNOWS"), .map([:]), .string("r9")]) + func steps(_ sequence: [Int64]) throws -> [[String: Any]] { + let path = structure(0x50, [.list([first, second]), .list([knows]), .list(sequence.map(PackStreamValue.int))]) + let text = try #require(Neo4jValueText.text(path)) + let object = try #require(try JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any]) + let nodes = try #require(object["nodes"] as? [[String: Any]]) + let relationships = try #require(object["relationships"] as? [[String: Any]]) + #expect(nodes.count == relationships.count + 1) + return relationships + } + + let forward = try steps([1, 1]) + #expect(forward.map { $0["startElementId"] as? String } == ["n1"]) + #expect(forward.map { $0["endElementId"] as? String } == ["n2"]) + + let outAndBack = try steps([1, 1, -1, 0]) + #expect(outAndBack.count == 2) + #expect(outAndBack.map { $0["elementId"] as? String } == ["r9", "r9"]) + #expect(outAndBack.map { $0["startElementId"] as? String } == ["n1", "n1"]) + + let backward = try steps([-1, 1]) + #expect(backward.map { $0["startElementId"] as? String } == ["n2"]) + #expect(backward.map { $0["endElementId"] as? String } == ["n1"]) + } + + @Test("Update counters keep only what changed, and only created or deleted entities count as affected") + func updateCounts() { + let setOnly = Neo4jUpdateCounts(summary: ["stats": .map(["properties-set": .int(2)])]) + #expect(setOnly.entitiesChanged == 0) + #expect(setOnly.counters.map(\.counter) == [.propertiesSet]) + #expect(setOnly.count(.propertiesSet) == 2) + + let created = Neo4jUpdateCounts(summary: ["stats": .map([ + "nodes-created": .int(1_002), "labels-added": .int(1_002), "relationships-created": .int(3), + "contains-updates": .bool(true) + ])]) + #expect(created.entitiesChanged == 1_005) + #expect(created.counters.map(\.counter) == [.nodesCreated, .relationshipsCreated, .labelsAdded]) + + #expect(Neo4jUpdateCounts(summary: [:]).counters.isEmpty) + } + + @Test("Points name their SRID and coordinates") + func points() { + #expect(Neo4jValueText.text(structure(0x58, [.int(4_326), .float(1.5), .float(2)])) + == "point({srid: 4326, x: 1.5, y: 2.0})") + } + + @Test("A node becomes JSON with its element id, labels and properties") + func node() throws { + let node = structure(0x4E, [.int(7), .list([.string("Person")]), .map(["name": .string("Ann")]), .string("4:x:7")]) + let text = try #require(Neo4jValueText.text(node)) + let object = try #require(try JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any]) + #expect(object["elementId"] as? String == "4:x:7") + #expect(object["labels"] as? [String] == ["Person"]) + #expect((object["properties"] as? [String: Any])?["name"] as? String == "Ann") + #expect(Neo4jValueText.typeName(node) == "NODE") + } + + @Test("A Bolt 4 node falls back to its numeric id") + func legacyNode() { + let node = structure(0x4E, [.int(7), .list([]), .map([:])]) + #expect(Neo4jEntity(node)?.elementId == "7") + } + + @Test("Null stays null and scalars stay plain") + func scalars() { + #expect(Neo4jValueText.text(.null) == nil) + #expect(Neo4jValueText.text(.bool(true)) == "true") + #expect(Neo4jValueText.text(.float(.infinity)) == "Infinity") + #expect(Neo4jValueText.text(.list([.int(1), .string("a")])) == #"[1,"a"]"#) + } +} + +struct Neo4jResultTableTests { + private func node(_ id: String, _ labels: [String], _ properties: [String: PackStreamValue]) -> [PackStreamValue] { + [.structure(tag: 0x4E, fields: [.int(0), .list(labels.map(PackStreamValue.string)), .map(properties), .string(id)])] + } + + @Test("Browsing a label spreads nodes into id, labels and one column per property") + func nodes() { + let table = Neo4jResultTable.entities( + kind: .node, + records: [ + node("a", ["Person"], ["name": .string("Ann"), "age": .int(30)]), + node("b", ["Person", "Admin"], ["name": .string("Bo"), "email": .string("bo@x")]) + ], + knownProperties: ["name", "age"] + ) + #expect(table.columns == ["", "", "name", "age", "email"]) + #expect(table.typeNames == ["STRING", "LIST", "STRING", "INTEGER", "STRING"]) + #expect(table.rows[0] == ["a", #"["Person"]"#, "Ann", "30", nil]) + #expect(table.rows[1] == ["b", #"["Person","Admin"]"#, "Bo", nil, "bo@x"]) + } + + @Test("Browsing a relationship type shows its start and end nodes") + func relationships() { + let relationship = PackStreamValue.structure(tag: 0x52, fields: [ + .int(1), .int(2), .int(3), .string("KNOWS"), .map(["since": .int(2_020)]), + .string("r1"), .string("n2"), .string("n3") + ]) + let table = Neo4jResultTable.entities(kind: .relationship, records: [[relationship]], knownProperties: []) + #expect(table.columns == ["", "", "", "since"]) + #expect(table.rows == [["r1", "n2", "n3", "2020"]]) + } + + @Test("A Cypher result keeps one column per key, typed by its first value") + func records() { + let table = Neo4jResultTable.records(keys: ["n", "count"], records: [[.null, .int(2)], [.string("x"), .int(3)]]) + #expect(table.typeNames == ["STRING", "INTEGER"]) + #expect(table.rows == [[nil, "2"], ["x", "3"]]) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProNeo4jCoreTests/PackStreamTests.swift b/apple/Packages/TableProCore/Tests/TableProNeo4jCoreTests/PackStreamTests.swift new file mode 100644 index 0000000000..8b247bf490 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProNeo4jCoreTests/PackStreamTests.swift @@ -0,0 +1,103 @@ +import Foundation +@testable import TableProNeo4jCore +import Testing + +struct PackStreamTests { + @Test("Integers use the smallest marker the Bolt spec allows", arguments: [ + (Int64(1), [0x01]), + (Int64(-16), [0xF0]), + (Int64(-17), [0xC8, 0xEF]), + (Int64(128), [0xC9, 0x00, 0x80]), + (Int64(-129), [0xC9, 0xFF, 0x7F]), + (Int64(32_768), [0xCA, 0x00, 0x00, 0x80, 0x00]), + (Int64(2_147_483_648), [0xCB, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00]) + ] as [(Int64, [UInt8])]) + func integers(value: Int64, bytes: [UInt8]) throws { + #expect([UInt8](PackStreamEncoder.encode(.int(value))) == bytes) + #expect(try PackStreamDecoder.decode(Data(bytes)) == .int(value)) + } + + @Test("Strings, lists and maps round-trip through tiny and sized headers") + func containers() throws { + let long = String(repeating: "a", count: 300) + let value = PackStreamValue.map([ + "short": .string("hi"), + "long": .string(long), + "list": .list((0 ..< 20).map { .int($0) }), + "nested": .map(["flag": .bool(true), "none": .null, "pi": .float(3.14)]), + "bytes": .bytes(Data([1, 2, 3])) + ]) + #expect(try PackStreamDecoder.decode(PackStreamEncoder.encode(value)) == value) + } + + @Test("The spec's example string encodes byte for byte") + func specString() { + #expect([UInt8](PackStreamEncoder.encode(.string("a"))) == [0x81, 0x61]) + #expect([UInt8](PackStreamEncoder.encode(.float(1.1))) == [0xC1, 0x3F, 0xF1, 0x99, 0x99, 0x99, 0x99, 0x99, 0x9A]) + } + + @Test("A value cut short is refused instead of read as garbage") + func truncated() { + #expect(throws: PackStreamError.truncated) { try PackStreamDecoder.decode(Data([0xD0, 0x05, 0x61])) } + #expect(throws: PackStreamError.unknownMarker(0xC4)) { try PackStreamDecoder.decode(Data([0xC4])) } + } +} + +struct BoltMessageTests { + @Test("A list nested deeper than the limit is refused instead of overflowing the stack") + func depthLimit() throws { + func nested(_ depth: Int) -> Data { + Data(Array(repeating: 0x91, count: depth - 1) + [0x01]) + } + #expect(try PackStreamDecoder.decode(nested(PackStreamDecoder.maximumDepth)) != .null) + #expect(throws: PackStreamError.nestedTooDeeply) { + try PackStreamDecoder.decode(nested(PackStreamDecoder.maximumDepth + 1)) + } + } + + @Test("The handshake offers 5.4 down to 5.0, then 4.4 down to 4.2") + func handshake() { + #expect([UInt8](BoltHandshake.request.prefix(8)) == [0x60, 0x60, 0xB0, 0x17, 0x00, 0x04, 0x04, 0x05]) + #expect(BoltHandshake.version(fromResponse: [0, 0, 4, 5]) == BoltVersion(major: 5, minor: 4)) + #expect(BoltHandshake.version(fromResponse: [0, 0, 0, 0]) == nil) + } + + @Test("RESET is a field-less structure in one chunk and an end marker") + func reset() { + #expect([UInt8](BoltRequest.reset.framed) == [0x00, 0x02, 0xB0, 0x0F, 0x00, 0x00]) + } + + @Test("A message over 64 KiB is split into several chunks and joined back") + func chunking() throws { + let query = String(repeating: "x", count: 70_000) + let framed = BoltRequest.run(query: query, parameters: [:], extra: [:]).framed + #expect(framed[0] == 0xFF && framed[1] == 0xFF) + + var dechunker = BoltDechunker() + dechunker.append([0x00, 0x00]) + dechunker.append(framed.prefix(100)) + #expect(try dechunker.nextMessage() == nil) + dechunker.append(framed.dropFirst(100)) + let message = try #require(try dechunker.nextMessage()) + guard case .structure(0x10, let fields) = try PackStreamDecoder.decode(message) else { + Issue.record("not a RUN structure") + return + } + #expect(fields.first == .string(query)) + #expect(try dechunker.nextMessage() == nil) + } + + @Test("Replies decode to success, record, ignored and failure") + func responses() throws { + func message(_ tag: UInt8, _ fields: [PackStreamValue]) -> Data { + PackStreamEncoder.encode(.structure(tag: tag, fields: fields)) + } + #expect(try BoltResponse.decode(message(0x70, [.map(["fields": .list([.string("n")])])])) + == .success(["fields": .list([.string("n")])])) + #expect(try BoltResponse.decode(message(0x71, [.list([.int(1)])])) == .record([.int(1)])) + #expect(try BoltResponse.decode(message(0x7E, [])) == .ignored) + #expect(try BoltResponse.decode(message(0x7F, [.map([ + "code": .string("Neo.ClientError.Statement.SyntaxError"), "message": .string("bad") + ])])) == .failure(code: "Neo.ClientError.Statement.SyntaxError", message: "bad")) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLNonCodeSpanTests.swift b/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLNonCodeSpanTests.swift index da4e34f808..675af17549 100644 --- a/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLNonCodeSpanTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLNonCodeSpanTests.swift @@ -60,6 +60,16 @@ struct SQLNonCodeSpanTests { #expect(end("--", .dashCommentsNeedWhitespace) == 2) } + @Test("Cypher's -- is code under every reading of a Neo4j text, so the ; after it still splits") + func cypherDashesAreCode() { + #expect(end("-- a\nb", .withoutDashComments) == nil) + let text = "MATCH (a)-- (b) RETURN a; MATCH (n) RETURN n" + let readings = SQLLexicalReadings.resolve(databaseTypeId: "Neo4j", declared: nil, session: nil) + for grammar in readings.distinct(for: text) { + #expect(SQLStatementScanner.executableStatements(in: text, grammar: grammar).count == 2) + } + } + @Test("Every control character after -- starts a MySQL comment, U+007F among them") func deleteAfterDashesStartsAComment() { #expect(end("--\u{01}a\nb", .dashCommentsNeedWhitespace) == 4) diff --git a/apple/Plugins/KafkaDriverPlugin/KafkaConnection.swift b/apple/Plugins/KafkaDriverPlugin/KafkaConnection.swift index 3ed9f52acb..ce3102c704 100644 --- a/apple/Plugins/KafkaDriverPlugin/KafkaConnection.swift +++ b/apple/Plugins/KafkaDriverPlugin/KafkaConnection.swift @@ -94,50 +94,6 @@ private final class KafkaResponseHandler: ChannelInboundHandler, @unchecked Send } } -/// Resumes a connect's caller exactly once, whichever of the connect and the cancellation -/// gets there first. -private final class ConnectGate: @unchecked Sendable { - private var continuation: CheckedContinuation? - private var settled = false - private let lock = NIOLock() - - func arm(_ continuation: CheckedContinuation) { - let alreadySettled: Bool = lock.withLock { - if settled { return true } - self.continuation = continuation - return false - } - // Cancellation can land before the continuation is armed, so the check is not - // redundant: without it the caller would wait forever for a gate already closed. - if alreadySettled { continuation.resume(throwing: CancellationError()) } - } - - /// Returns false when the caller has already been resumed, meaning the channel is unowned. - func deliver(_ channel: Channel) -> Bool { - let waiting = take() - guard let waiting else { return false } - waiting.resume(returning: channel) - return true - } - - func fail(_ error: Error) { - take()?.resume(throwing: error) - } - - func cancel() { - take()?.resume(throwing: CancellationError()) - } - - private func take() -> CheckedContinuation? { - lock.withLock { - guard !settled else { return nil } - settled = true - defer { continuation = nil } - return continuation - } - } -} - /// One TCP connection to one broker. /// /// Requests are strictly one at a time. Kafka allows pipelining, but a browsing client gains @@ -204,15 +160,11 @@ actor KafkaConnection { let connected: Channel do { - connected = try await Self.connect(bootstrap, host: endpoint.host, port: endpoint.port) + connected = try await PluginNIOChannel.connect(bootstrap, host: endpoint.host, port: endpoint.port) } catch is CancellationError { throw CancellationError() - } catch let error as NIOSSLError { - throw KafkaError.connectionFailed(Self.describeTLS(error)) - } catch let error as NIOSSLExtraError { - throw KafkaError.connectionFailed(Self.describeTLSExtra(error)) } catch { - throw KafkaError.connectionFailed(Self.describeConnect(error)) + throw KafkaError.connectionFailed(PluginNIOChannel.describe(error)) } channel = connected @@ -234,34 +186,6 @@ actor KafkaConnection { } } - /// Dials, and returns as soon as the caller is cancelled rather than waiting out the - /// connect timeout. - /// - /// Cancelling a `Task` cannot interrupt a connect already in flight, so the two have to be - /// raced: whichever arrives first resumes the caller, and the loser cleans up after - /// itself. Without this a cancelled connect sat for the full timeout, which is the shape - /// CLAUDE.md's cancellation invariant exists to prevent, and it was measured at 60s. - private static func connect(_ bootstrap: ClientBootstrap, host: String, port: Int) async throws -> Channel { - let gate = ConnectGate() - return try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - gate.arm(continuation) - bootstrap.connect(host: host, port: port).whenComplete { result in - switch result { - case .success(let channel): - // A channel that arrives after the caller gave up belongs to nobody, - // so it is closed here rather than leaked. - if !gate.deliver(channel) { channel.close(promise: nil) } - case .failure(let error): - gate.fail(error) - } - } - } - } onCancel: { - gate.cancel() - } - } - func close() async { let existing = channel handler?.finish(with: KafkaError.notConnected) @@ -388,65 +312,7 @@ actor KafkaConnection { private static func makeSSLContext(_ ssl: SSLConfiguration) throws -> NIOSSLContext? { if let error = tlsConfigurationError(for: ssl) { throw error } - guard ssl.isEnabled else { return nil } - var configuration = TLSConfiguration.makeClientConfiguration() - configuration.certificateVerification = verification(for: ssl.mode) - - if ssl.verifiesCertificate, !ssl.caCertificatePath.isEmpty { - configuration.trustRoots = .certificates(try NIOSSLCertificate.fromPEMFile(ssl.caCertificatePath)) - } - if !ssl.clientCertificatePath.isEmpty { - let chain = try NIOSSLCertificate.fromPEMFile(ssl.clientCertificatePath) - configuration.certificateChain = chain.map { .certificate($0) } - } - if !ssl.clientKeyPath.isEmpty { - configuration.privateKey = .privateKey(try NIOSSLPrivateKey(file: ssl.clientKeyPath, format: .pem)) - } - return try NIOSSLContext(configuration: configuration) - } - - private static func verification(for mode: SSLMode) -> CertificateVerification { - switch mode { - case .verifyIdentity: return .fullVerification - case .verifyCa: return .noHostnameVerification - default: return .none - } - } - - private static func describeTLS(_ error: NIOSSLError) -> String { - switch error { - case .handshakeFailed: - return String(localized: "the TLS handshake failed") - case .failedToLoadCertificate: - return String(localized: "the certificate file could not be read") - case .failedToLoadPrivateKey: - return String(localized: "the private key file could not be read") - default: - return String(describing: error) - } - } - - private static func describeTLSExtra(_ error: NIOSSLExtraError) -> String { - switch error { - case .failedToValidateHostname: - return String(localized: "the broker's certificate does not cover the hostname you connected to") - default: - return String(describing: error) - } - } - - /// A Kafka listener that is not actually a Kafka listener is a common misconfiguration, so - /// the two failures worth naming are the ones a user can act on. Each names only what went - /// wrong: what to do about it belongs in the docs, and a second sentence here would make - /// the error unusable as a heading anyone could search for. - private static func describeConnect(_ error: Error) -> String { - if let channelError = error as? ChannelError, case .connectTimeout = channelError { - return String(localized: "the connection timed out") - } - if let ioError = error as? IOError, ioError.errnoCode == ECONNREFUSED { - return String(localized: "the connection was refused") - } - return error.localizedDescription + return try PluginNIOChannel.tlsContext(for: ssl) } } diff --git a/apple/Plugins/Neo4jDriverPlugin/Info.plist b/apple/Plugins/Neo4jDriverPlugin/Info.plist new file mode 100644 index 0000000000..7293e94d0b --- /dev/null +++ b/apple/Plugins/Neo4jDriverPlugin/Info.plist @@ -0,0 +1,12 @@ + + + + + TableProPluginKitVersion + TABLEPRO_PLUGINKIT_VERSION + TableProProvidesDatabaseTypeIds + + Neo4j + + + diff --git a/apple/Plugins/Neo4jDriverPlugin/Neo4jConnection.swift b/apple/Plugins/Neo4jDriverPlugin/Neo4jConnection.swift new file mode 100644 index 0000000000..1ed9290efb --- /dev/null +++ b/apple/Plugins/Neo4jDriverPlugin/Neo4jConnection.swift @@ -0,0 +1,496 @@ +import Foundation +import NIOConcurrencyHelpers +import NIOCore +import NIOPosix +import NIOSSL +import os +import TableProNeo4jCore +import TableProPluginKit + +struct BoltReply: Sendable { + enum Outcome: Sendable { + case success([String: PackStreamValue]) + case failure(code: String, message: String) + case ignored + } + + var records: [[PackStreamValue]] = [] + var outcome: Outcome = .ignored + + var metadata: [String: PackStreamValue] { + if case .success(let metadata) = outcome { return metadata } + return [:] + } + + var hasMore: Bool { metadata["has_more"]?.boolValue == true } +} + +struct BoltResult: Sendable { + let keys: [String] + let records: [[PackStreamValue]] + let summary: [String: PackStreamValue] + let hasMore: Bool +} + +struct Neo4jCredentials: Sendable { + let username: String + let password: String +} + +// Bolt answers requests in the order they were sent, so a FIFO of waiters is the whole routing table. +private final class BoltResponseHandler: ChannelInboundHandler, @unchecked Sendable { + typealias InboundIn = ByteBuffer + + private final class Pending { + let expected: Int + let interruptible: Bool + let continuation: CheckedContinuation<[BoltReply], Error>? + var replies: [BoltReply] = [] + var current = BoltReply() + + init(expected: Int, interruptible: Bool, continuation: CheckedContinuation<[BoltReply], Error>?) { + self.expected = expected + self.interruptible = interruptible + self.continuation = continuation + } + } + + private let lock = NIOLock() + private var handshake: CheckedContinuation<[UInt8], Error>? + private var handshakeBytes: [UInt8] = [] + private var handshakeDone = false + private var pending: [Pending] = [] + private var failure: Error? + private var dechunker = BoltDechunker() + private var cancels = 0 + private var interrupted = false + + var cancelCount: Int { lock.withLock { cancels } } + + func expectHandshake(_ continuation: CheckedContinuation<[UInt8], Error>) { + let resolved: Error? = lock.withLock { + if let failure { return failure } + handshake = continuation + return nil + } + if let resolved { continuation.resume(throwing: resolved) } + } + + // Queued and written under one lock, so a RESET from `interrupt` never lands between a batch and its waiter. + // A user statement passes the cancel count it saw when it queued for the connection; a cancel since then + // stops it before it is sent. + func dispatch( + _ requests: [BoltRequest], + cancellableSince: Int?, + on channel: Channel, + continuation: CheckedContinuation<[BoltReply], Error> + ) { + let expected = requests.filter(\.expectsResponse).count + let refusal: Error? = lock.withLock { + if let failure { return failure } + if let cancellableSince, cancellableSince != cancels { return Neo4jError.cancelled } + if expected > 0 { + pending.append(Pending( + expected: expected, interruptible: cancellableSince != nil, continuation: continuation + )) + } + var buffer = channel.allocator.buffer(capacity: 256) + for request in requests { buffer.writeBytes(request.framed) } + channel.writeAndFlush(buffer).whenFailure { [weak self] error in + self?.finish(with: Neo4jError.connectionFailed(PluginNIOChannel.describe(error))) + } + return nil + } + if let refusal { + continuation.resume(throwing: refusal) + } else if expected == 0 { + continuation.resume(returning: []) + } + } + + // RESET goes out only while a user statement is on the wire, never to metadata work holding the connection. + func interrupt(on channel: Channel) { + lock.withLock { + cancels += 1 + guard failure == nil, pending.contains(where: \.interruptible) else { return } + interrupted = true + pending.append(Pending(expected: 1, interruptible: false, continuation: nil)) + var buffer = channel.allocator.buffer(capacity: 8) + buffer.writeBytes(BoltRequest.reset.framed) + channel.writeAndFlush(buffer, promise: nil) + } + } + + // Once an interrupting RESET is answered the server is READY, where the turn's own RESET or DISCARD would fail. + func takeInterrupted() -> Bool { + lock.withLock { + defer { interrupted = false } + return interrupted + } + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + var buffer = unwrapInboundIn(data) + var completed: [(Pending, Result<[BoltReply], Error>)] = [] + var handshakeResult: (CheckedContinuation<[UInt8], Error>, [UInt8])? + + lock.withLock { + if !handshakeDone { + let needed = 4 - handshakeBytes.count + handshakeBytes += buffer.readBytes(length: min(needed, buffer.readableBytes)) ?? [] + guard handshakeBytes.count == 4 else { return } + handshakeDone = true + if let handshake { handshakeResult = (handshake, handshakeBytes) } + handshake = nil + } + dechunker.append(buffer.readableBytesView) + do { + while let message = try dechunker.nextMessage() { + guard let head = pending.first else { continue } + switch try BoltResponse.decode(message) { + case .record(let fields): + head.current.records.append(fields) + continue + case .success(let metadata): + head.current.outcome = .success(metadata) + case .failure(let code, let message): + head.current.outcome = .failure(code: code, message: message) + case .ignored: + head.current.outcome = .ignored + } + head.replies.append(head.current) + head.current = BoltReply() + guard head.replies.count == head.expected else { continue } + pending.removeFirst() + completed.append((head, .success(head.replies))) + } + } catch { + failure = failure ?? error + completed += pending.map { ($0, .failure(error)) } + pending.removeAll() + } + } + + if let (continuation, bytes) = handshakeResult { continuation.resume(returning: bytes) } + for (entry, result) in completed { entry.continuation?.resume(with: result) } + } + + func errorCaught(context: ChannelHandlerContext, error: Error) { + finish(with: Neo4jError.connectionFailed(PluginNIOChannel.describe(error))) + context.close(promise: nil) + } + + func channelInactive(context: ChannelHandlerContext) { + finish(with: Neo4jError.connectionFailed(String(localized: "the server closed the connection"))) + } + + func finish(with error: Error) { + let (handshake, waiting): (CheckedContinuation<[UInt8], Error>?, [Pending]) = lock.withLock { + if failure == nil { failure = error } + defer { + self.handshake = nil + pending.removeAll() + } + return (self.handshake, pending) + } + handshake?.resume(throwing: error) + for entry in waiting { entry.continuation?.resume(throwing: error) } + } +} + +// Statements take turns on the one Bolt connection; only `cancel` writes out of turn. +actor Neo4jConnection { + private struct Wire { + let channel: Channel + let handler: BoltResponseHandler + } + + private static let timeoutError = Neo4jError.connectionFailed(String(localized: "the connection timed out")) + private static let logger = Logger(subsystem: "com.TablePro", category: "Neo4jConnection") + + private let host: String + private let port: Int + private let wire = NIOLockedValueBox(nil) + private var turnIsTaken = false + private var waitingForTurn: [CheckedContinuation] = [] + private var opening = false + private var timedOut = false + private(set) var version = BoltVersion(major: 0, minor: 0) + private(set) var serverAgent: String? + + init(host: String, port: Int) { + self.host = host + self.port = port + } + + func open(ssl: SSLConfiguration, credentials: Neo4jCredentials, userAgent: String, timeout: TimeAmount) async throws { + guard wire.withLockedValue({ $0 }) == nil else { return } + opening = true + timedOut = false + defer { opening = false } + + let timeoutNanoseconds = UInt64(max(1, timeout.nanoseconds)) + let timeoutTask = Task { [weak self] in + try await Task.sleep(nanoseconds: timeoutNanoseconds) + await self?.abortOpening() + } + defer { timeoutTask.cancel() } + + do { + if ssl.mode == .preferred { + do { + try await dial(ssl: ssl, timeout: timeout) + } catch let error where !(error is CancellationError) && !timedOut { + let reason = error.localizedDescription + Self.logger.info("TLS failed under SSL Preferred, retrying without it: \(reason, privacy: .public)") + var plain = ssl + plain.mode = .disabled + try await dial(ssl: plain, timeout: timeout) + } + } else { + try await dial(ssl: ssl, timeout: timeout) + } + try await authenticate(credentials, userAgent: userAgent) + } catch { + await close() + throw timedOut ? Self.timeoutError : error + } + } + + private func dial(ssl: SSLConfiguration, timeout: TimeAmount) async throws { + let sslContext: NIOSSLContext? + do { + sslContext = try PluginNIOChannel.tlsContext(for: ssl) + } catch { + throw Neo4jError.connectionFailed(PluginNIOChannel.describe(error)) + } + // NIOSSL refuses an IP address as SNI; Aura routes on SNI, so a hostname always sends it. + let serverHostname = Self.isIPAddress(host) ? nil : host + let handler = BoltResponseHandler() + let bootstrap = ClientBootstrap(group: NIOSingletons.posixEventLoopGroup) + .connectTimeout(timeout) + .channelOption(ChannelOptions.socketOption(.so_keepalive), value: 1) + .channelInitializer { channel in + do { + if let sslContext { + let tls = try NIOSSLClientHandler(context: sslContext, serverHostname: serverHostname) + try channel.pipeline.syncOperations.addHandler(tls) + } + try channel.pipeline.syncOperations.addHandler(handler) + return channel.eventLoop.makeSucceededVoidFuture() + } catch { + return channel.eventLoop.makeFailedFuture(error) + } + } + + let channel: Channel + do { + channel = try await PluginNIOChannel.connect(bootstrap, host: host, port: port) + } catch is CancellationError { + throw CancellationError() + } catch { + throw Neo4jError.connectionFailed(PluginNIOChannel.describe(error)) + } + guard !timedOut else { + channel.close(promise: nil) + throw Self.timeoutError + } + wire.withLockedValue { $0 = Wire(channel: channel, handler: handler) } + do { + try await negotiate(on: channel, handler: handler, usesTLS: ssl.isEnabled) + } catch { + await close() + throw error + } + } + + private func negotiate(on channel: Channel, handler: BoltResponseHandler, usesTLS: Bool) async throws { + let bytes: [UInt8] + do { + bytes = try await withCheckedThrowingContinuation { continuation in + handler.expectHandshake(continuation) + var buffer = channel.allocator.buffer(capacity: 20) + buffer.writeBytes(BoltHandshake.request) + channel.writeAndFlush(buffer, promise: nil) + } + } catch { + guard !usesTLS, !timedOut else { throw error } + throw Neo4jError.connectionFailed(String( + format: String(localized: "%@ during the Bolt handshake. If the server requires TLS, turn on SSL."), + Neo4jError.detail(of: error) + )) + } + if bytes == Array("HTTP".utf8) { + throw Neo4jError.connectionFailed(String(localized: "this port speaks HTTP; use the Bolt port, usually 7687")) + } + guard let negotiated = BoltHandshake.version(fromResponse: bytes) else { + throw Neo4jError.connectionFailed(BoltProtocolError.unsupportedServer.localizedDescription) + } + version = negotiated + } + + private func authenticate(_ credentials: Neo4jCredentials, userAgent: String) async throws { + var hello: [String: PackStreamValue] = ["user_agent": .string(userAgent)] + if version.sendsBoltAgent { + hello["bolt_agent"] = .map(["product": .string(userAgent)]) + } + let auth: [String: PackStreamValue] = credentials.username.isEmpty + ? ["scheme": .string("none")] + : [ + "scheme": .string("basic"), + "principal": .string(credentials.username), + "credentials": .string(credentials.password) + ] + let requests: [BoltRequest] = version.separatesLogon + ? [.hello(extra: hello), .logon(auth: auth)] + : [.hello(extra: hello.merging(auth) { current, _ in current })] + let replies = try await withTurn(interruptible: false) { wire, _ in + try await Self.exchange(requests, on: wire, cancellableSince: nil) + } + serverAgent = replies.first?.metadata["server"]?.stringValue + try Self.requireSuccess(replies) + } + + // RUN and one PULL of `pull` records (-1 for all); records left on the server are discarded in the same turn, + // and the DISCARD's summary is the statement's. + func run(_ statement: BoltRequest, pull: Int64, interruptible: Bool) async throws -> BoltResult { + try await withTurn(interruptible: interruptible) { wire, since in + let replies = try await Self.exchange([statement, .pull(count: pull)], on: wire, cancellableSince: since) + try Self.requireSuccess(replies) + guard replies.count == 2 else { throw Neo4jError.notConnected } + var summary = replies[1].metadata + if replies[1].hasMore { + let discarded = try await Self.exchange([.discard], on: wire, cancellableSince: since) + try Self.requireSuccess(discarded) + summary = discarded.first?.metadata ?? summary + } + return BoltResult( + keys: Self.keys(of: replies[0]), + records: replies[1].records, + summary: summary, + hasMore: replies[1].hasMore + ) + } + } + + // RUN, then PULL `batchSize` records at a time and hand each batch to `receive` until it returns false, the task + // is cancelled or the server has no more. The first call always comes, with the keys, even for no records. + func stream( + _ statement: BoltRequest, + batchSize: Int64, + interruptible: Bool, + receive: @Sendable (_ keys: [String], _ records: [[PackStreamValue]], _ isFirst: Bool) -> Bool + ) async throws { + try await withTurn(interruptible: interruptible) { wire, since in + let replies = try await Self.exchange([statement, .pull(count: batchSize)], on: wire, cancellableSince: since) + try Self.requireSuccess(replies) + guard replies.count == 2 else { throw Neo4jError.notConnected } + let keys = Self.keys(of: replies[0]) + var batch = replies[1] + var isFirst = true + while true { + let wanted = receive(keys, batch.records, isFirst) + isFirst = false + guard batch.hasMore else { return } + guard wanted, !Task.isCancelled else { + try Self.requireSuccess(try await Self.exchange([.discard], on: wire, cancellableSince: since)) + return + } + let next = try await Self.exchange([.pull(count: batchSize)], on: wire, cancellableSince: since) + try Self.requireSuccess(next) + guard let reply = next.first else { throw Neo4jError.notConnected } + batch = reply + } + } + } + + // The cancel count is read before queueing, so a cancel while the statement waits for its turn still stops it. + // A failure leaves the server ignoring everything until a RESET, so one is answered before the turn moves on. + private func withTurn( + interruptible: Bool, + _ body: (Wire, Int?) async throws -> T + ) async throws -> T { + let since = interruptible ? wire.withLockedValue({ $0 })?.handler.cancelCount : nil + await claimTurn() + defer { releaseTurn() } + guard let current = wire.withLockedValue({ $0 }), current.channel.isActive else { throw Neo4jError.notConnected } + try Task.checkCancellation() + _ = current.handler.takeInterrupted() + do { + return try await body(current, since) + } catch { + if !current.handler.takeInterrupted() { + _ = try? await Self.exchange([.reset], on: current, cancellableSince: nil) + } + throw error + } + } + + private static func exchange( + _ requests: [BoltRequest], + on wire: Wire, + cancellableSince: Int? + ) async throws -> [BoltReply] { + try await withCheckedThrowingContinuation { continuation in + wire.handler.dispatch(requests, cancellableSince: cancellableSince, on: wire.channel, continuation: continuation) + } + } + + private static func requireSuccess(_ replies: [BoltReply]) throws { + if let error = replies.lazy.compactMap(Neo4jError.init(reply:)).first { throw error } + } + + private static func keys(of reply: BoltReply) -> [String] { + reply.metadata["fields"]?.listValue?.compactMap(\.stringValue) ?? [] + } + + nonisolated func cancel() { + guard let current = wire.withLockedValue({ $0 }), current.channel.isActive else { return } + current.handler.interrupt(on: current.channel) + } + + func close() async { + let existing = wire.withLockedValue { current -> Wire? in + defer { current = nil } + return current + } + guard let existing else { return } + if existing.channel.isActive { + var buffer = existing.channel.allocator.buffer(capacity: 8) + buffer.writeBytes(BoltRequest.goodbye.framed) + existing.channel.writeAndFlush(buffer, promise: nil) + } + existing.handler.finish(with: Neo4jError.notConnected) + try? await existing.channel.close() + } + + // Only an open still in progress is aborted: a timer firing as `open` returns must not close what it handed over. + private func abortOpening() async { + guard opening else { return } + timedOut = true + wire.withLockedValue { $0 }?.handler.finish(with: Self.timeoutError) + await close() + } + + private static func isIPAddress(_ host: String) -> Bool { + var v4 = in_addr() + var v6 = in6_addr() + return inet_pton(AF_INET, host, &v4) == 1 || inet_pton(AF_INET6, host, &v6) == 1 + } + + private func claimTurn() async { + guard turnIsTaken else { + turnIsTaken = true + return + } + await withCheckedContinuation { waitingForTurn.append($0) } + } + + private func releaseTurn() { + guard !waitingForTurn.isEmpty else { + turnIsTaken = false + return + } + waitingForTurn.removeFirst().resume() + } +} diff --git a/apple/Plugins/Neo4jDriverPlugin/Neo4jError.swift b/apple/Plugins/Neo4jDriverPlugin/Neo4jError.swift new file mode 100644 index 0000000000..2d448dca37 --- /dev/null +++ b/apple/Plugins/Neo4jDriverPlugin/Neo4jError.swift @@ -0,0 +1,50 @@ +import Foundation + +enum Neo4jError: Error, LocalizedError, Sendable { + case notConnected + case connectionFailed(String) + case server(code: String, message: String) + case cancelled + + init?(reply: BoltReply) { + switch reply.outcome { + case .success: + return nil + case .failure(let code, let message): + self = .server(code: code, message: message) + case .ignored: + self = .cancelled + } + } + + static func detail(of error: Error) -> String { + if case .connectionFailed(let detail) = error as? Neo4jError { return detail } + return PluginNIOChannel.describe(error) + } + + var errorDescription: String? { + switch self { + case .notConnected: + return String(localized: "Not connected to Neo4j.") + case .connectionFailed(let detail): + return String(format: String(localized: "Could not connect to Neo4j: %@"), detail) + case .server(_, let message): + return message + case .cancelled: + return String(localized: "The query was cancelled.") + } + } + + // Only a failure of the session's own credentials; Forbidden on one database is not one. + var isAuthenticationFailure: Bool { + guard case .server(let code, _) = self else { return false } + return Self.authenticationCodes.contains(code) + } + + private static let authenticationCodes: Set = [ + "Neo.ClientError.Security.Unauthorized", + "Neo.ClientError.Security.CredentialsExpired", + "Neo.ClientError.Security.TokenExpired", + "Neo.ClientError.Security.AuthenticationRateLimit" + ] +} diff --git a/apple/Plugins/Neo4jDriverPlugin/Neo4jPlugin.swift b/apple/Plugins/Neo4jDriverPlugin/Neo4jPlugin.swift new file mode 100644 index 0000000000..d9ac5869b2 --- /dev/null +++ b/apple/Plugins/Neo4jDriverPlugin/Neo4jPlugin.swift @@ -0,0 +1,89 @@ +import Foundation +import TableProNeo4jCore +import TableProPluginKit + +final class Neo4jPlugin: NSObject, TableProPlugin, DriverPlugin { + static let pluginName = "Neo4j Driver" + static let pluginVersion = Bundle(for: Neo4jPlugin.self) + .object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0.0" + static let pluginDescription = "Neo4j over the Bolt protocol with Cypher" + static let capabilities: [PluginCapability] = [.databaseDriver] + + static let databaseTypeId = "Neo4j" + static let databaseDisplayName = "Neo4j" + static let iconName = "neo4j-icon" + static let defaultPort = 7_687 + static let isDownloadable = true + + static let connectionMode: ConnectionMode = .network + static let navigationModel: NavigationModel = .standard + static let urlSchemes = ["neo4j", "neo4j+s", "neo4j+ssc", "bolt", "bolt+s", "bolt+ssc"] + static let brandColorHex = "#018BFF" + static let queryLanguageName = "Cypher" + static let editorLanguage: EditorLanguage = .custom("cypher") + static let parameterStyle: ParameterStyle = .dollar + + static let supportsSSH = true + static let supportsSSL = true + static let supportsImport = false + static let supportsExport = true + static let supportsReadOnlyMode = true + static let supportsHealthMonitor = true + static let supportsSchemaEditing = false + static let supportsForeignKeys = false + static let supportsForeignKeyDisable = false + static let supportsTriggers = false + static let supportsRoutines = false + static let supportsDropDatabase = false + static let supportsDropSchema = false + static let supportsAddColumn = false + static let supportsModifyColumn = false + static let supportsDropColumn = false + static let supportsRenameColumn = false + static let supportsAddIndex = false + static let supportsDropIndex = false + static let supportsModifyPrimaryKey = false + + static let supportsDatabaseSwitching = true + static let supportsSchemaSwitching = true + static let databaseGroupingStrategy: GroupingStrategy = .bySchema + static let defaultGroupName = "neo4j" + static let defaultSchemaName = Neo4jEntityKind.node.schemaName + static let systemDatabaseNames = ["system"] + static let tableEntityName = "Types" + static let containerEntityName = "Database" + static let schemaEntityName = "Kind" + static let defaultPrimaryKeyColumn: String? = Neo4jCypher.idColumn + static let immutableColumns = [Neo4jCypher.idColumn, Neo4jCypher.labelsColumn, Neo4jCypher.startColumn, Neo4jCypher.endColumn] + static let structureColumnFields: [StructureColumnField] = [.name, .type, .nullable] + static let sqlDialect: SQLDialectDescriptor? = nil + + static let columnTypesByCategory: [String: [String]] = neo4jColumnTypes + static var statementCompletions: [CompletionEntry] { neo4jCompletions } + + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { + Neo4jPluginDriver(config: config) + } +} + +let neo4jColumnTypes: [String: [String]] = [ + "Scalar": ["STRING", "INTEGER", "FLOAT", "BOOLEAN", "BYTES"], + "Temporal": ["DATE", "LOCAL TIME", "ZONED TIME", "LOCAL DATETIME", "ZONED DATETIME", "DURATION"], + "Spatial": ["POINT"], + "Structured": ["LIST", "MAP"], + "Graph": ["NODE", "RELATIONSHIP", "PATH"] +] + +let neo4jCompletions: [CompletionEntry] = [ + CompletionEntry(label: "MATCH nodes", insertText: "MATCH (n:Label) RETURN n LIMIT 25"), + CompletionEntry(label: "MATCH relationships", insertText: "MATCH (a)-[r:TYPE]->(b) RETURN a, r, b LIMIT 25"), + CompletionEntry(label: "MATCH path", insertText: "MATCH p = (a)-[*1..3]->(b) RETURN p LIMIT 25"), + CompletionEntry(label: "CREATE node", insertText: "CREATE (n:Label {name: 'value'}) RETURN n"), + CompletionEntry(label: "MERGE node", insertText: "MERGE (n:Label {id: 1}) RETURN n"), + CompletionEntry(label: "SHOW DATABASES", insertText: "SHOW DATABASES"), + CompletionEntry(label: "SHOW INDEXES", insertText: "SHOW INDEXES"), + CompletionEntry(label: "SHOW CONSTRAINTS", insertText: "SHOW CONSTRAINTS"), + CompletionEntry(label: "db.labels", insertText: "CALL db.labels()"), + CompletionEntry(label: "db.relationshipTypes", insertText: "CALL db.relationshipTypes()"), + CompletionEntry(label: "db.schema.visualization", insertText: "CALL db.schema.visualization()") +] diff --git a/apple/Plugins/Neo4jDriverPlugin/Neo4jPluginDriver+Metadata.swift b/apple/Plugins/Neo4jDriverPlugin/Neo4jPluginDriver+Metadata.swift new file mode 100644 index 0000000000..5f472da676 --- /dev/null +++ b/apple/Plugins/Neo4jDriverPlugin/Neo4jPluginDriver+Metadata.swift @@ -0,0 +1,246 @@ +import Foundation +import os +import TableProNeo4jCore +import TableProPluginKit + +extension Neo4jPluginDriver { + static let tableType = "external table" + + func fetchDatabases() async throws -> [String] { + do { + let statement = try await run( + "SHOW DATABASES YIELD name RETURN DISTINCT name ORDER BY name", + database: "system" + ) + let names = statement.records.compactMap { $0.first?.stringValue } + if !names.isEmpty { return names } + } catch let error as Neo4jError { + // A user without access to `system` still has the database they connected to. + guard case .server = error, !error.isAuthenticationFailure else { throw error } + Self.logger.info("SHOW DATABASES failed, listing the current database only") + } + let current = currentDatabase + return current.isEmpty ? [] : [current] + } + + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } + + func fetchSchemas() async throws -> [String] { + Neo4jEntityKind.allCases.map(\.schemaName) + } + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { + guard let kind = Neo4jEntityKind(schemaName: schema ?? currentSchema) else { return [] } + return try await tables(of: kind) + } + + func fetchTablesInAllSchemas() async throws -> [PluginTableInfo]? { + var tables: [PluginTableInfo] = [] + for kind in Neo4jEntityKind.allCases { + tables += try await self.tables(of: kind) + } + return tables + } + + private func tables(of kind: Neo4jEntityKind) async throws -> [PluginTableInfo] { + let query = switch kind { + case .node: "CALL db.labels() YIELD label RETURN label ORDER BY label" + case .relationship: "CALL db.relationshipTypes() YIELD relationshipType RETURN relationshipType ORDER BY relationshipType" + } + return try await run(query).records.compactMap { record in + record.first?.stringValue.map { + PluginTableInfo(name: $0, type: Self.tableType, schema: kind.schemaName) + } + } + } + + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { + let kind = Neo4jEntityKind(schemaName: schema ?? currentSchema) ?? .node + let sample = try await run(Neo4jCypher.propertySampleQuery(kind: kind, name: table)) + let properties = sample.records.compactMap { record -> (String, String)? in + guard let key = record.first?.stringValue else { return nil } + let type = record.count > 1 ? Neo4jValueText.typeName(record[1]) : "ANY" + return (key, type) + } + rememberProperties(properties.map(\.0), kind: kind, name: table) + + let fixed = Neo4jCypher.fixedColumns(for: kind).map { name in + PluginColumnInfo( + name: name, + dataType: name == Neo4jCypher.labelsColumn ? "LIST" : "STRING", + isNullable: false, + isPrimaryKey: name == Neo4jCypher.idColumn + ) + } + return fixed + properties.map { PluginColumnInfo(name: $0.0, dataType: $0.1, isNullable: true) } + } + + // Neo4j 5 names an index's constraint in `owningConstraint`; 4.4 has only `uniqueness`. + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { + let kind = Neo4jEntityKind(schemaName: schema ?? currentSchema) ?? .node + let unique = usesElementId ? "owningConstraint IS NOT NULL" : "uniqueness = 'UNIQUE'" + let query = """ + SHOW INDEXES YIELD * + WHERE $name IN labelsOrTypes AND entityType = $entityType + RETURN name, type, properties, \(unique) AS isUnique + """ + let statement: BoltResult + do { + statement = try await run(query, parameters: ["name": .string(table), "entityType": .string(kind.entityType)]) + } catch { + Self.logger.info("SHOW INDEXES failed: \(error.localizedDescription, privacy: .private)") + return [] + } + return statement.records.compactMap { record in + guard record.count == 4, let name = record[0].stringValue else { return nil } + return PluginIndexInfo( + name: name, + columns: record[2].listValue?.compactMap(\.stringValue) ?? [], + isUnique: record[3].boolValue ?? false, + isPrimary: false, + type: record[1].stringValue ?? "" + ) + } + } + + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { + [] + } + + func fetchApproximateRowCount(table: String, schema: String?) async throws -> Int? { + try await rowCount(table: table, schema: schema, queryFilters: [], logicMode: "and") + } + + func fetchFilteredRowCount(table: String, queryFilters: [PluginQueryFilter], logicMode: String) async throws -> Int? { + try await rowCount(table: table, schema: nil, queryFilters: queryFilters, logicMode: logicMode) + } + + func fetchExactRowCount( + table: String, + schema: String?, + queryFilters: [PluginQueryFilter], + logicMode: String + ) async throws -> Int? { + try await rowCount(table: table, schema: schema, queryFilters: queryFilters, logicMode: logicMode) + } + + private func rowCount(table: String, schema: String?, queryFilters: [PluginQueryFilter], logicMode: String) async throws -> Int? { + let kind = Neo4jEntityKind(schemaName: schema ?? currentSchema) ?? .node + let query = try Neo4jCypher.countQuery( + kind: kind, + name: table, + filters: Self.neo4jFilters(queryFilters), + matchesAll: logicMode.lowercased() != "or", + usesElementId: usesElementId + ) + return try await run(query).records.first?.first?.intValue.map(Int.init) + } + + static func neo4jFilters(_ queryFilters: [PluginQueryFilter]) -> [Neo4jFilter] { + queryFilters.map { + Neo4jFilter( + column: $0.column, + op: $0.op, + value: $0.value, + secondValue: $0.secondValue, + isCaseSensitive: $0.isCaseSensitive + ) + } + } + + func fetchTableDDL(table: String, schema: String?) async throws -> String { + let kind = Neo4jEntityKind(schemaName: schema ?? currentSchema) ?? .node + let query = """ + SHOW CONSTRAINTS YIELD labelsOrTypes, entityType, createStatement + WHERE $name IN labelsOrTypes AND entityType = $entityType + RETURN createStatement + """ + let parameters: [String: PackStreamValue] = ["name": .string(table), "entityType": .string(kind.entityType)] + let statements = (try? await run(query, parameters: parameters))? + .records.compactMap { $0.first?.stringValue } ?? [] + let pattern = Neo4jCypher.pattern(for: kind, name: table) + " RETURN n LIMIT 25;" + return (statements.map { $0 + ";" } + [pattern]).joined(separator: "\n") + } + + func fetchViewDefinition(view: String, schema: String?) async throws -> String { + throw Neo4jError.server(code: "", message: String(localized: "Neo4j has no views.")) + } + + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table, engine: "Neo4j") + } + + // MARK: - Browsing + + func buildBrowseQuery( + table: String, + schema: String?, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? { + buildFilteredQuery( + table: table, + schema: schema, + queryFilters: [], + logicMode: "and", + sortColumns: sortColumns, + columns: columns, + limit: limit, + offset: offset, + columnKinds: [:] + ) + } + + func buildBrowseQuery( + table: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? { + buildBrowseQuery( + table: table, schema: nil, sortColumns: sortColumns, columns: columns, limit: limit, offset: offset + ) + } + + func buildFilteredQuery( + table: String, + schema: String?, + queryFilters: [PluginQueryFilter], + logicMode: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int, + columnKinds: [String: PluginColumnKind] + ) -> String? { + let kind = Neo4jEntityKind(schemaName: schema ?? currentSchema) ?? .node + let sort = sortColumns.compactMap { sort -> (column: String, ascending: Bool)? in + guard columns.indices.contains(sort.columnIndex) else { return nil } + return (columns[sort.columnIndex], sort.ascending) + } + return try? Neo4jCypher.browseQuery( + kind: kind, + name: table, + filters: Self.neo4jFilters(queryFilters), + matchesAll: logicMode.lowercased() != "or", + sort: sort, + limit: limit, + offset: offset, + usesElementId: usesElementId + ) + } + + func defaultExportQuery(table: String, schema: String?) -> String? { + let kind = Neo4jEntityKind(schemaName: schema ?? currentSchema) ?? .node + return try? Neo4jCypher.exportQuery(kind: kind, name: table) + } + + func defaultExportQuery(table: String) -> String? { + defaultExportQuery(table: table, schema: nil) + } +} diff --git a/apple/Plugins/Neo4jDriverPlugin/Neo4jPluginDriver.swift b/apple/Plugins/Neo4jDriverPlugin/Neo4jPluginDriver.swift new file mode 100644 index 0000000000..95deb9499c --- /dev/null +++ b/apple/Plugins/Neo4jDriverPlugin/Neo4jPluginDriver.swift @@ -0,0 +1,329 @@ +import Foundation +import NIOCore +import os +import TableProNeo4jCore +import TableProPluginKit + +final class Neo4jPluginDriver: PluginDatabaseDriver, @unchecked Sendable { + static let logger = Logger(subsystem: "com.TablePro", category: "Neo4jPluginDriver") + static let defaultConnectTimeoutMilliseconds = 15_000 + static let streamBatchSize: Int64 = 1_000 + + private let config: DriverConnectionConfig + private let lock = NSLock() + private var connection: Neo4jConnection? + private var database: String + private var schema = Neo4jEntityKind.node.schemaName + private var transactionTimeoutSeconds = 0 + private var propertyCache: [String: [String]] = [:] + private var serverAgent: String? + private var boltVersion = BoltVersion(major: 0, minor: 0) + + init(config: DriverConnectionConfig) { + self.config = config + database = config.database.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var capabilities: PluginCapabilities { [.cancelQuery] } + var supportsTransactions: Bool { false } + var supportsSchemas: Bool { true } + var currentSchema: String? { lock.withLock { schema } } + var serverVersion: String? { lock.withLock { serverAgent } } + var parameterStyle: ParameterStyle { .dollar } + + var currentDatabase: String { lock.withLock { database } } + var usesElementId: Bool { lock.withLock { boltVersion.hasElementIds } } + + func beginTransaction() async throws {} + func commitTransaction() async throws {} + func rollbackTransaction() async throws {} + + func quoteIdentifier(_ name: String) -> String { + Neo4jCypher.quote(name) + } + + // MARK: - Connection + + func connect() async throws { + let milliseconds = PluginConnectTimeout.milliseconds( + in: config.additionalFields, + default: Self.defaultConnectTimeoutMilliseconds + ) + let connection = Neo4jConnection(host: config.host, port: config.port) + try await connection.open( + ssl: config.ssl, + credentials: Neo4jCredentials(username: config.username, password: config.password), + userAgent: "TablePro/\(Neo4jPlugin.pluginVersion)", + timeout: .milliseconds(Int64(milliseconds)) + ) + let agent = await connection.serverAgent + let version = await connection.version + let replaced = lock.withLock { () -> Neo4jConnection? in + defer { + self.connection = connection + serverAgent = agent + boltVersion = version + propertyCache.removeAll() + } + return self.connection + } + if let replaced { await replaced.close() } + if currentDatabase.isEmpty, let home = try? await homeDatabase() { + lock.withLock { database = home } + } + } + + func disconnect() { + let existing = lock.withLock { () -> Neo4jConnection? in + defer { connection = nil } + return connection + } + guard let existing else { return } + Task { await existing.close() } + } + + func ping() async throws { + _ = try await run("RETURN 1") + } + + func cancelQuery() throws { + lock.withLock { connection }?.cancel() + } + + func applyQueryTimeout(_ seconds: Int) async throws { + lock.withLock { transactionTimeoutSeconds = max(0, seconds) } + } + + func switchDatabase(to database: String) async throws { + lock.withLock { + self.database = database + propertyCache.removeAll() + } + } + + func switchSchema(to schema: String) async throws { + lock.withLock { self.schema = schema } + } + + func requireConnection() throws -> Neo4jConnection { + guard let connection = lock.withLock({ connection }) else { throw Neo4jError.notConnected } + return connection + } + + private func homeDatabase() async throws -> String? { + try await run("CALL db.info() YIELD name RETURN name").records.first?.first?.stringValue + } + + // MARK: - Running Cypher + + // Internal reads go out in Bolt's read mode, where the server refuses a write. + func run( + _ query: String, + parameters: [String: PackStreamValue] = [:], + database override: String? = nil + ) async throws -> BoltResult { + try await requireConnection().run( + request(query, parameters: parameters, database: override, readOnly: true), + pull: -1, + interruptible: false + ) + } + + private func request( + _ query: String, + parameters: [String: PackStreamValue], + database override: String? = nil, + readOnly: Bool + ) -> BoltRequest { + let (database, timeout) = lock.withLock { (self.database, transactionTimeoutSeconds) } + var extra: [String: PackStreamValue] = [:] + let target = override ?? database + if !target.isEmpty { extra["db"] = .string(target) } + if timeout > 0 { extra["tx_timeout"] = .int(Int64(timeout) * 1_000) } + if readOnly { extra["mode"] = .string("r") } + return .run(query: query, parameters: parameters, extra: extra) + } + + // A grid page never writes, whatever a raw filter in it says. + private static func readsOnly(_ query: String, _ context: PluginStatementContext) -> Bool { + context.readOnly || Neo4jCypher.browseKind(of: query) != nil + } + + func execute(query: String) async throws -> PluginQueryResult { + try await execute(query: query, context: PluginStatementContext()) + } + + func execute(query: String, context: PluginStatementContext) async throws -> PluginQueryResult { + try await executeUserQuery(query: query, rowCap: nil, parameters: nil, context: context) + } + + func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult { + try await executeParameterized(query: query, parameters: parameters, context: PluginStatementContext()) + } + + func executeParameterized( + query: String, + parameters: [PluginCellValue], + context: PluginStatementContext + ) async throws -> PluginQueryResult { + try await executeUserQuery(query: query, rowCap: nil, parameters: parameters, context: context) + } + + func executeUserQuery(query: String, rowCap: Int?, parameters: [PluginCellValue]?) async throws -> PluginQueryResult { + try await executeUserQuery(query: query, rowCap: rowCap, parameters: parameters, context: PluginStatementContext()) + } + + func executeUserQuery( + query: String, + rowCap: Int?, + parameters: [PluginCellValue]?, + context: PluginStatementContext + ) async throws -> PluginQueryResult { + let started = Date() + let cap = rowCap.flatMap { $0 > 0 ? $0 : nil } + let result = try await requireConnection().run( + request(Self.trimmed(query), parameters: Self.boltParameters(parameters), readOnly: Self.readsOnly(query, context)), + pull: cap.map { Int64($0) + 1 } ?? -1, + interruptible: true + ) + var records = result.records + var isTruncated = result.hasMore + if let cap, records.count > cap { + records.removeLast(records.count - cap) + isTruncated = true + } + let table = resultTable(query: query, keys: result.keys, records: records) + let counts = Neo4jUpdateCounts(summary: result.summary) + return PluginQueryResult( + columns: table.columns, + columnTypeNames: table.typeNames, + rows: Self.cells(table.rows), + rowsAffected: counts.entitiesChanged, + executionTime: Date().timeIntervalSince(started), + isTruncated: isTruncated, + statusMessage: Self.countersText(counts) + ) + } + + private func resultTable(query: String, keys: [String], records: [[PackStreamValue]]) -> Neo4jResultTable { + guard let kind = Neo4jCypher.browseKind(of: query), keys.count == 1 else { + return .records(keys: keys, records: records) + } + let name = Neo4jCypher.browsedName(in: query) ?? "" + let known = lock.withLock { propertyCache[Self.cacheKey(kind, name)] } ?? [] + return .entities(kind: kind, records: records, knownProperties: known) + } + + // MARK: - Streaming + + func streamRows(query: String) -> AsyncThrowingStream { + streamRows(query: query, context: PluginStatementContext()) + } + + // Pulls a batch at a time, so an export never holds the whole result. + func streamRows(query: String, context: PluginStatementContext) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + try await self.stream(query, context: context, into: continuation) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + private func stream( + _ query: String, + context: PluginStatementContext, + into continuation: AsyncThrowingStream.Continuation + ) async throws { + let connection = try requireConnection() + var entityColumns: (kind: Neo4jEntityKind, properties: [String])? + if let kind = Neo4jCypher.browseKind(of: query), let name = Neo4jCypher.browsedName(in: query) { + let keys = try await run(Neo4jCypher.propertyKeysQuery(kind: kind, name: name)) + entityColumns = (kind, keys.records.compactMap { $0.first?.stringValue }) + } + let spread = entityColumns + try await connection.stream( + request(Self.trimmed(query), parameters: [:], readOnly: Self.readsOnly(query, context)), + batchSize: Self.streamBatchSize, + interruptible: true + ) { keys, records, isFirst in + let table: Neo4jResultTable + if let spread, keys.count == 1 { + table = .entities( + kind: spread.kind, records: records, knownProperties: spread.properties, addsUnseenProperties: false + ) + } else { + table = .records(keys: keys, records: records) + } + if isFirst { + continuation.yield(.header(PluginStreamHeader(columns: table.columns, columnTypeNames: table.typeNames))) + } + guard !table.rows.isEmpty else { return true } + if case .terminated = continuation.yield(.rows(Self.cells(table.rows))) { return false } + return true + } + } + + // MARK: - Helpers + + private static func cells(_ rows: [[String?]]) -> [[PluginCellValue]] { + rows.map { $0.map { $0.map(PluginCellValue.text) ?? .null } } + } + + static func trimmed(_ query: String) -> String { + var text = query.trimmingCharacters(in: .whitespacesAndNewlines) + while text.hasSuffix(";") { + text.removeLast() + text = text.trimmingCharacters(in: .whitespacesAndNewlines) + } + return text + } + + // Host parameters are positional; Cypher reads them as `$1`, `$2` and so on. + static func boltParameters(_ values: [PluginCellValue]?) -> [String: PackStreamValue] { + guard let values else { return [:] } + var parameters: [String: PackStreamValue] = [:] + for (index, value) in values.enumerated() { + parameters[String(index + 1)] = value.asText.map(PackStreamValue.string) ?? .null + } + return parameters + } + + // A statement that only set properties or labels says what changed instead of a row count. + static func countersText(_ counts: Neo4jUpdateCounts) -> String? { + guard !counts.counters.isEmpty else { return nil } + return counts.counters.map { counter, count in + String(format: counterFormat(counter), Int64(count)) + }.joined(separator: ", ") + } + + private static func counterFormat(_ counter: Neo4jUpdateCounts.Counter) -> String { + switch counter { + case .nodesCreated: return String(localized: "%lld node(s) created") + case .nodesDeleted: return String(localized: "%lld node(s) deleted") + case .relationshipsCreated: return String(localized: "%lld relationship(s) created") + case .relationshipsDeleted: return String(localized: "%lld relationship(s) deleted") + case .propertiesSet: return String(localized: "%lld property value(s) set") + case .labelsAdded: return String(localized: "%lld label(s) added") + case .labelsRemoved: return String(localized: "%lld label(s) removed") + case .indexesAdded: return String(localized: "%lld index(es) added") + case .indexesRemoved: return String(localized: "%lld index(es) removed") + case .constraintsAdded: return String(localized: "%lld constraint(s) added") + case .constraintsRemoved: return String(localized: "%lld constraint(s) removed") + case .systemUpdates: return String(localized: "%lld system update(s)") + } + } + + static func cacheKey(_ kind: Neo4jEntityKind, _ name: String) -> String { + kind.rawValue + "\u{0}" + name + } + + func rememberProperties(_ keys: [String], kind: Neo4jEntityKind, name: String) { + lock.withLock { propertyCache[Self.cacheKey(kind, name)] = keys } + } +} diff --git a/apple/Plugins/SharedNIO/PluginNIOChannel.swift b/apple/Plugins/SharedNIO/PluginNIOChannel.swift new file mode 100644 index 0000000000..9f5cf49eac --- /dev/null +++ b/apple/Plugins/SharedNIO/PluginNIOChannel.swift @@ -0,0 +1,119 @@ +// +// PluginNIOChannel.swift +// TablePro +// + +import Foundation +import NIOConcurrencyHelpers +import NIOCore +import NIOPosix +import NIOSSL +import TableProPluginKit + +enum PluginNIOChannel { + // Cancelling a task cannot interrupt a connect in flight, so the connect and the cancellation race: whichever + // lands first resumes the caller, and a channel that arrives after the caller left is closed. + static func connect(_ bootstrap: ClientBootstrap, host: String, port: Int) async throws -> Channel { + let gate = ConnectGate() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + gate.arm(continuation) + bootstrap.connect(host: host, port: port).whenComplete { result in + switch result { + case .success(let channel): + if !gate.deliver(channel) { channel.close(promise: nil) } + case .failure(let error): + gate.fail(error) + } + } + } + } onCancel: { + gate.cancel() + } + } + + static func tlsContext(for ssl: SSLConfiguration) throws -> NIOSSLContext? { + guard ssl.isEnabled else { return nil } + var configuration = TLSConfiguration.makeClientConfiguration() + switch ssl.mode { + case .verifyIdentity: configuration.certificateVerification = .fullVerification + case .verifyCa: configuration.certificateVerification = .noHostnameVerification + default: configuration.certificateVerification = .none + } + if ssl.verifiesCertificate, !ssl.caCertificatePath.isEmpty { + configuration.trustRoots = .certificates(try NIOSSLCertificate.fromPEMFile(ssl.caCertificatePath)) + } + if !ssl.clientCertificatePath.isEmpty { + let chain = try NIOSSLCertificate.fromPEMFile(ssl.clientCertificatePath) + configuration.certificateChain = chain.map { .certificate($0) } + } + if !ssl.clientKeyPath.isEmpty { + configuration.privateKey = .privateKey(try NIOSSLPrivateKey(file: ssl.clientKeyPath, format: .pem)) + } + return try NIOSSLContext(configuration: configuration) + } + + static func describe(_ error: Error) -> String { + if let tlsError = error as? NIOSSLError { + switch tlsError { + case .handshakeFailed: return String(localized: "the TLS handshake failed") + case .failedToLoadCertificate: return String(localized: "the certificate file could not be read") + case .failedToLoadPrivateKey: return String(localized: "the private key file could not be read") + default: return String(describing: tlsError) + } + } + if let tlsError = error as? NIOSSLExtraError, tlsError == .failedToValidateHostname { + return String(localized: "the server's certificate does not cover the hostname you connected to") + } + if let channelError = error as? ChannelError, case .connectTimeout = channelError { + return String(localized: "the connection timed out") + } + if let connectError = error as? NIOConnectionError, let first = connectError.connectionErrors.first { + return describe(first.error) + } + if let ioError = error as? IOError { + if ioError.errnoCode == ECONNREFUSED { return String(localized: "the connection was refused") } + return String(cString: strerror(ioError.errnoCode)) + } + return error.localizedDescription + } +} + +private final class ConnectGate: @unchecked Sendable { + private var continuation: CheckedContinuation? + private var settled = false + private let lock = NIOLock() + + func arm(_ continuation: CheckedContinuation) { + let alreadySettled: Bool = lock.withLock { + if settled { return true } + self.continuation = continuation + return false + } + // A cancellation can land before the continuation is armed. + if alreadySettled { continuation.resume(throwing: CancellationError()) } + } + + func deliver(_ channel: Channel) -> Bool { + guard let waiting = take() else { return false } + waiting.resume(returning: channel) + return true + } + + func fail(_ error: Error) { + take()?.resume(throwing: error) + } + + func cancel() { + take()?.resume(throwing: CancellationError()) + } + + private func take() -> CheckedContinuation? { + lock.withLock { + guard !settled else { return nil } + settled = true + defer { continuation = nil } + return continuation + } + } +} diff --git a/apple/TablePro/Assets.xcassets/neo4j-icon.imageset/Contents.json b/apple/TablePro/Assets.xcassets/neo4j-icon.imageset/Contents.json new file mode 100644 index 0000000000..8c52de7ec1 --- /dev/null +++ b/apple/TablePro/Assets.xcassets/neo4j-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "neo4j.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/apple/TablePro/Assets.xcassets/neo4j-icon.imageset/neo4j.svg b/apple/TablePro/Assets.xcassets/neo4j-icon.imageset/neo4j.svg new file mode 100644 index 0000000000..5e6f461c85 --- /dev/null +++ b/apple/TablePro/Assets.xcassets/neo4j-icon.imageset/neo4j.svg @@ -0,0 +1 @@ +Neo4j \ No newline at end of file diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+Neo4jDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+Neo4jDefaults.swift new file mode 100644 index 0000000000..e1f1ea749c --- /dev/null +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+Neo4jDefaults.swift @@ -0,0 +1,94 @@ +// +// PluginMetadataRegistry+Neo4jDefaults.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +extension PluginMetadataRegistry { + // Kept identical to `Neo4jPlugin`, so the type is in the New Connection picker before the plugin is installed. + func neo4jPluginDefaults() -> [(typeId: String, snapshot: PluginMetadataSnapshot)] { + [ + ("Neo4j", PluginMetadataSnapshot( + displayName: "Neo4j", iconName: "neo4j-icon", defaultPort: 7_687, + requiresAuthentication: true, supportsForeignKeys: false, supportsSchemaEditing: false, + isDownloadable: true, primaryUrlScheme: "neo4j", parameterStyle: .dollar, + navigationModel: .standard, explainVariants: [], pathFieldRole: .database, + supportsHealthMonitor: true, + urlSchemes: ["neo4j", "neo4j+s", "neo4j+ssc", "bolt", "bolt+s", "bolt+ssc"], + postConnectActions: [], + brandColorHex: "#018BFF", + queryLanguageName: "Cypher", editorLanguage: .custom("cypher"), + connectionMode: .network, supportsDatabaseSwitching: true, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: true, + supportsImport: false, + supportsExport: true, + supportsSSH: true, + supportsSSL: true, + supportsCascadeDrop: false, + supportsForeignKeyDisable: false, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: false, + supportsDropSchema: false, + supportsAddColumn: false, + supportsModifyColumn: false, + supportsDropColumn: false, + supportsAddIndex: false, + supportsDropIndex: false, + supportsModifyPrimaryKey: false, + supportsOpportunisticTLS: false + ), + schema: PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "Labels", + defaultGroupName: "neo4j", + tableEntityName: "Types", + containerEntityName: "Database", + schemaEntityName: "Kind", + defaultPrimaryKeyColumn: "", + immutableColumns: ["", "", "", ""], + systemDatabaseNames: ["system"], + systemSchemaNames: [], + fileExtensions: [], + databaseGroupingStrategy: .bySchema, + structureColumnFields: [.name, .type, .nullable] + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: nil, + statementCompletions: neo4jCompletions, + columnTypesByCategory: neo4jColumnTypes + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: [], + category: .other, + tagline: String(localized: "Graph database queried with Cypher") + ) + )) + ] + } +} + +private let neo4jColumnTypes: [String: [String]] = [ + "Scalar": ["STRING", "INTEGER", "FLOAT", "BOOLEAN", "BYTES"], + "Temporal": ["DATE", "LOCAL TIME", "ZONED TIME", "LOCAL DATETIME", "ZONED DATETIME", "DURATION"], + "Spatial": ["POINT"], + "Structured": ["LIST", "MAP"], + "Graph": ["NODE", "RELATIONSHIP", "PATH"] +] + +private let neo4jCompletions: [CompletionEntry] = [ + CompletionEntry(label: "MATCH nodes", insertText: "MATCH (n:Label) RETURN n LIMIT 25"), + CompletionEntry(label: "MATCH relationships", insertText: "MATCH (a)-[r:TYPE]->(b) RETURN a, r, b LIMIT 25"), + CompletionEntry(label: "MATCH path", insertText: "MATCH p = (a)-[*1..3]->(b) RETURN p LIMIT 25"), + CompletionEntry(label: "CREATE node", insertText: "CREATE (n:Label {name: 'value'}) RETURN n"), + CompletionEntry(label: "MERGE node", insertText: "MERGE (n:Label {id: 1}) RETURN n"), + CompletionEntry(label: "SHOW DATABASES", insertText: "SHOW DATABASES"), + CompletionEntry(label: "SHOW INDEXES", insertText: "SHOW INDEXES"), + CompletionEntry(label: "SHOW CONSTRAINTS", insertText: "SHOW CONSTRAINTS"), + CompletionEntry(label: "db.labels", insertText: "CALL db.labels()"), + CompletionEntry(label: "db.relationshipTypes", insertText: "CALL db.relationshipTypes()"), + CompletionEntry(label: "db.schema.visualization", insertText: "CALL db.schema.visualization()") +] diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 1df518f949..791d9304e4 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -1046,6 +1046,7 @@ extension PluginMetadataRegistry { + cloudPluginDefaults() + elasticsearchPluginDefaults() + surrealDBPluginDefaults() + kafkaPluginDefaults() + typesensePluginDefaults() + r2SQLPluginDefaults() + weaviatePluginDefaults() + hanaPluginDefaults() + firebasePluginDefaults() + bigtablePluginDefaults() + + neo4jPluginDefaults() + trinoPluginDefaults() } // swiftlint:enable function_body_length diff --git a/apple/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift b/apple/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift index 16bf4da860..299200fbf1 100644 --- a/apple/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift +++ b/apple/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift @@ -111,6 +111,7 @@ struct ConnectionURLParser { } var scheme = trimmed[trimmed.startIndex.. SSLMode? { + switch suffix { + case "+s": return .verifyIdentity + case "+ssc": return .required + default: return nil + } + } + private static func resolveDBType(from scheme: String) -> DatabaseType? { switch scheme { case "postgresql", "postgres": diff --git a/apple/TablePro/Core/Utilities/SQL/CatalogChangeClassifier.swift b/apple/TablePro/Core/Utilities/SQL/CatalogChangeClassifier.swift index 662751fd98..8e9583ab44 100644 --- a/apple/TablePro/Core/Utilities/SQL/CatalogChangeClassifier.swift +++ b/apple/TablePro/Core/Utilities/SQL/CatalogChangeClassifier.swift @@ -52,6 +52,6 @@ enum CatalogChangeClassifier { } private static let documentStoreTypes: Set = [ - .mongodb, .elasticsearch, .typesense, .weaviate, .etcd, .firestore, .firebaseRealtimeDatabase, .bigtable + .mongodb, .elasticsearch, .typesense, .weaviate, .etcd, .firestore, .firebaseRealtimeDatabase, .bigtable, .neo4j ] } diff --git a/apple/TablePro/Core/Utilities/SQL/QueryClassifier+Cypher.swift b/apple/TablePro/Core/Utilities/SQL/QueryClassifier+Cypher.swift new file mode 100644 index 0000000000..13f46773a0 --- /dev/null +++ b/apple/TablePro/Core/Utilities/SQL/QueryClassifier+Cypher.swift @@ -0,0 +1,187 @@ +// +// QueryClassifier+Cypher.swift +// TablePro +// + +import Foundation + +extension QueryClassifier { + static func cypherClassification(_ text: String) -> QueryClassification { + cypherReadings(of: text).reduce(.safe) { worst, code in + worst.escalated(with: cypherClassification(code: code)) + } + } + + static func cypherDeletesUnfiltered(_ text: String) -> Bool { + cypherReadings(of: text).contains { code in + code.split(separator: ";").contains { statement in + let words = Set(cypherClauseWords(in: Array(statement)).map(\.text)) + return words.contains("DELETE") && words.isDisjoint(with: ["WHERE", "LIMIT"]) && !statement.contains("{") + } + } + } + + // Neo4j's parser decodes `\uXXXX` before it lexes, so `\u0044ELETE` is DELETE and `\u0060` closes a backtick + // name. A server that does not decode reads the text as written, so both readings are tiered. + private static func cypherReadings(of text: String) -> [[Character]] { + let decoded = cypherDecodingUnicodeEscapes(text) + return decoded == text ? [cypherCode(text)] : [cypherCode(text), cypherCode(decoded)] + } + + private static func cypherClassification(code: [Character]) -> QueryClassification { + var result = QueryClassification.safe + for word in cypherClauseWords(in: code) { + switch word.text { + case "DROP", "DENY", "REVOKE": + result = result.escalated(to: .destructive) + case "CREATE", "INSERT", "MERGE", "SET", "REMOVE", "DELETE", "FOREACH", "GRANT": + result = result.escalated(to: .write) + case let admin where cypherAdminObjects[admin] != nil: + // Only before the object it administers, so a variable named `start` stays a read. + if let next = cypherNextWord(in: code, after: word.end), cypherAdminObjects[admin]?.contains(next) == true { + result = result.escalated(to: .write) + } + case "LOAD": + result = result.markingUnsafeSurface() + case "CALL": + if let procedure = cypherProcedureName(in: code, after: word.end), + !cypherReadProcedures.contains(where: procedure.hasPrefix) { + result = result.escalated(to: .write) + } + default: + continue + } + } + return result + } + + static let cypherAdminObjects: [String: Set] = [ + "ALTER": ["USER", "CURRENT", "DATABASE", "SERVER", "ALIAS"], + "RENAME": ["USER", "ROLE", "SERVER"], + "START": ["DATABASE"], + "STOP": ["DATABASE"], + "TERMINATE": ["TRANSACTION", "TRANSACTIONS"], + "ENABLE": ["SERVER"], + "DEALLOCATE": ["DATABASE", "DATABASES"], + "REALLOCATE": ["DATABASE", "DATABASES"] + ] + + static let cypherReadProcedures: [String] = [ + "db.labels", "db.relationshiptypes", "db.propertykeys", "db.schema.", "db.info", "db.ping", + "db.indexes", "db.constraints", "db.index.fulltext.query", "db.index.vector.query", + "dbms.components", "dbms.showcurrentuser", "dbms.procedures", "dbms.functions", "apoc.meta." + ] + + // JavaCC's unicode-escape rule: a backslash after an even run of backslashes, one or more `u`, four hex digits. + static func cypherDecodingUnicodeEscapes(_ text: String) -> String { + let units = Array(text.utf16) + let backslash = UInt16(UInt8(ascii: "\\")) + let letterU = UInt16(UInt8(ascii: "u")) + var decoded: [UInt16] = [] + decoded.reserveCapacity(units.count) + var backslashRun = 0 + var index = 0 + while index < units.count { + let unit = units[index] + if unit == backslash, backslashRun.isMultiple(of: 2), index + 1 < units.count, units[index + 1] == letterU { + var hexStart = index + 1 + while hexStart < units.count, units[hexStart] == letterU { hexStart += 1 } + if hexStart + 4 <= units.count, + let scalar = UInt16(String(decoding: units[hexStart ..< hexStart + 4], as: UTF16.self), radix: 16) { + decoded.append(scalar) + backslashRun = 0 + index = hexStart + 4 + continue + } + } + backslashRun = unit == backslash ? backslashRun + 1 : 0 + decoded.append(unit) + index += 1 + } + return String(decoding: decoded, as: UTF16.self) + } + + // Strings and comments blanked, each backtick name replaced by `_q`, which no clause or procedure matches. + // Cypher has no `--` comment: `(a)-- (b)` is a pattern. + private static func cypherCode(_ text: String) -> [Character] { + let characters = Array(text) + var code: [Character] = [] + code.reserveCapacity(characters.count) + var index = 0 + while index < characters.count { + let character = characters[index] + let next = index + 1 < characters.count ? characters[index + 1] : nil + switch character { + case "'", "\"": + index += 1 + while index < characters.count, characters[index] != character { + index += characters[index] == "\\" ? 2 : 1 + } + code.append(" ") + case "`": + index += 1 + while index < characters.count { + if characters[index] == "`" { + guard index + 1 < characters.count, characters[index + 1] == "`" else { break } + index += 1 + } + index += 1 + } + code += ["_", "q"] + case "/" where next == "/": + while index < characters.count, !characters[index].isNewline { index += 1 } + code.append("\n") + case "/" where next == "*": + index += 2 + while index + 1 < characters.count, !(characters[index] == "*" && characters[index + 1] == "/") { + index += 1 + } + index += 1 + code.append(" ") + default: + code.append(character) + } + index += 1 + } + return code + } + + // A word after `.`, `:` or `$`, or before `:`, is a property, a label, a parameter or a map key. + private static func cypherClauseWords(in code: [Character]) -> [(text: String, end: Int)] { + var words: [(text: String, end: Int)] = [] + var index = 0 + while index < code.count { + guard code[index].isLetter || code[index] == "_" else { + index += 1 + continue + } + let start = index + while index < code.count, code[index].isLetter || code[index].isNumber || code[index] == "_" { + index += 1 + } + if let before = code[.. String? { + guard let start = code[end...].firstIndex(where: { !$0.isWhitespace }) else { return nil } + let word = code[start...].prefix { $0.isLetter || $0.isNumber || $0 == "_" } + return word.isEmpty ? nil : String(word).uppercased() + } + + // Nil for a `CALL { ... }` or `CALL (x) { ... }` subquery, whose own clauses are tiered. + private static func cypherProcedureName(in code: [Character], after end: Int) -> String? { + guard let start = code[end...].firstIndex(where: { !$0.isWhitespace }), code[start] != "{" else { return nil } + if code[start] == "(" { + let afterScope = code[start...].firstIndex(of: ")").flatMap { close in + code[(close + 1)...].first { !$0.isWhitespace } + } + return afterScope == "{" ? nil : "" + } + let name = code[start...].prefix { $0.isLetter || $0.isNumber || $0 == "_" || $0 == "." } + return String(name).lowercased() + } +} diff --git a/apple/TablePro/Core/Utilities/SQL/QueryClassifier+MongoDB.swift b/apple/TablePro/Core/Utilities/SQL/QueryClassifier+MongoDB.swift index 1c3d1ecfa5..ea42892c39 100644 --- a/apple/TablePro/Core/Utilities/SQL/QueryClassifier+MongoDB.swift +++ b/apple/TablePro/Core/Utilities/SQL/QueryClassifier+MongoDB.swift @@ -17,7 +17,8 @@ extension QueryClassifier { // Their reads hold though the driver declares no read-only mode: MongoDB, Redis and etcd classify their own // language and call anything not proven a read a write, and SAP HANA is SQL, read as every SQL engine is. - static let enginesWithProvenReads: Set = [.mongodb, .redis, .etcd, .sapHana] + // Neo4j is here so its driver opens a proven read in Bolt's read mode, where the server refuses a write. + static let enginesWithProvenReads: Set = [.mongodb, .redis, .etcd, .sapHana, .neo4j] private static func queryTier(of tier: MongoStatementTier) -> QueryTier { switch tier { diff --git a/apple/TablePro/Core/Utilities/SQL/QueryClassifier.swift b/apple/TablePro/Core/Utilities/SQL/QueryClassifier.swift index 16b070d1cb..e2a38b52df 100644 --- a/apple/TablePro/Core/Utilities/SQL/QueryClassifier.swift +++ b/apple/TablePro/Core/Utilities/SQL/QueryClassifier.swift @@ -114,6 +114,7 @@ enum QueryClassifier { if classification.tier == .destructive { return true } // A MongoDB statement is JavaScript, where `delete d.x` removes a field from a local object. guard databaseType != .redis, databaseType != .mongodb else { return false } + if databaseType == .neo4j { return cypherDeletesUnfiltered(sql) } guard documentStoreClassification(sql, databaseType: databaseType, readings: readings) == nil else { let trimmed = StatementBlank.trimming(SQLLeadingComments.stripping(sql, grammar: readings.execution)) let code = SQLCodeProjection.code(of: trimmed, grammar: readings.execution).uppercased() @@ -1017,6 +1018,8 @@ private extension QueryClassifier { return worstRequest { firebaseClassification($0, databaseType: databaseType) } case .bigtable: return worstRequest(bigtableClassification) + case .neo4j: + return cypherClassification(sql) default: return nil } diff --git a/apple/TablePro/Core/Utilities/SQL/SQLFileParser.swift b/apple/TablePro/Core/Utilities/SQL/SQLFileParser.swift index 75acb61bef..7800231139 100644 --- a/apple/TablePro/Core/Utilities/SQL/SQLFileParser.swift +++ b/apple/TablePro/Core/Utilities/SQL/SQLFileParser.swift @@ -460,7 +460,7 @@ final class SQLFileParser: Sendable { processDelimiterChange(&ctx, char: char) - if char == kDash && nextChar == kDash { + if char == kDash && nextChar == kDash, !ctx.grammar.contains(.withoutDashComments) { if ctx.grammar.contains(.dashCommentsNeedWhitespace), i + 2 >= bufLen, !ctx.atEndOfInput { return StepResult(advanced: false, deferred: true) } diff --git a/apple/TablePro/Models/Connection/DatabaseType.swift b/apple/TablePro/Models/Connection/DatabaseType.swift index bce99781a3..e8d90a048d 100644 --- a/apple/TablePro/Models/Connection/DatabaseType.swift +++ b/apple/TablePro/Models/Connection/DatabaseType.swift @@ -57,6 +57,7 @@ extension DatabaseType { static let firestore = DatabaseType(rawValue: "Firestore") static let firebaseRealtimeDatabase = DatabaseType(rawValue: "Firebase Realtime Database") static let kafka = DatabaseType(rawValue: "Kafka") + static let neo4j = DatabaseType(rawValue: "Neo4j") static let sapHana = DatabaseType(rawValue: "SAP HANA") } diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index 4b999ea5d9..4546c66163 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -3384,6 +3384,10 @@ } } }, + "%@ during the Bolt handshake. If the server requires TLS, turn on SSL." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "%@ failed" : { "comment" : "Failure count, %@ is a count", "localizations" : { @@ -10472,6 +10476,14 @@ } } }, + "%lld constraint(s) added" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "%lld constraint(s) removed" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "%lld dims" : { }, @@ -10500,6 +10512,14 @@ } } }, + "%lld index(es) added" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "%lld index(es) removed" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "%lld inside" : { "localizations" : { "fr" : { @@ -10552,6 +10572,14 @@ }, "%lld keys" : { + }, + "%lld label(s) added" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "%lld label(s) removed" : { + "extractionState" : "manual", + "generatesSymbol" : false }, "%lld large object values were longer than 64 MiB and were cut short." : { "extractionState" : "manual", @@ -10707,6 +10735,14 @@ }, "%lld new columns" : { + }, + "%lld node(s) created" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "%lld node(s) deleted" : { + "extractionState" : "manual", + "generatesSymbol" : false }, "%lld objects" : { "localizations" : { @@ -11151,6 +11187,18 @@ }, "%lld properties" : { + }, + "%lld property value(s) set" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "%lld relationship(s) created" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "%lld relationship(s) deleted" : { + "extractionState" : "manual", + "generatesSymbol" : false }, "%lld roles own database objects" : { "localizations" : { @@ -11756,6 +11804,10 @@ } } }, + "%lld system update(s)" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "%lld table%@ to export" : { "extractionState" : "stale", "localizations" : { @@ -52367,6 +52419,10 @@ } } }, + "Could not connect to Neo4j: %@" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Could not connect to the database: %@" : { }, @@ -87057,6 +87113,9 @@ } } } + }, + "Graph database queried with Cypher" : { + }, "Gray" : { "localizations" : { @@ -112578,6 +112637,14 @@ } } }, + "Neo4j cannot query the name %@ because it contains \\u." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Neo4j has no views." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Nested field path" : { "localizations" : { "fr" : { @@ -121465,6 +121532,10 @@ } } }, + "Not connected to Neo4j." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Not connected to Redis" : { "extractionState" : "manual", "generatesSymbol" : false, @@ -182719,6 +182790,10 @@ } } }, + "The %@ filter is not available for Neo4j." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The %@ line in ~/.ssh/config does not accept the token %@." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -189126,6 +189201,10 @@ } } }, + "The query was cancelled." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The query was stopped by closing its connection, because MySQL before 5.0 cannot stop a query any other way. The session's open transaction, table locks and variables were discarded. Run the statements again." : { "extractionState" : "manual", "generatesSymbol" : false @@ -190195,6 +190274,10 @@ } } }, + "the server closed the connection" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The server computes this column, so it is left out." : { }, @@ -190246,6 +190329,10 @@ } } }, + "The server does not speak Bolt 4.2 to 5.4. Neo4j 4.2 or later is required." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The server ended the session." : { "localizations" : { "fr" : { @@ -190474,6 +190561,14 @@ "extractionState" : "manual", "generatesSymbol" : false }, + "The server sent a %d-byte message, which is over the limit." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The server sent a message this client does not understand (%@)." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The server sent an unexpected message and the connection was reset. Run the query again." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -190548,6 +190643,10 @@ } } }, + "the server's certificate does not cover the hostname you connected to" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The server's socket file on this Mac. SELECT @@socket in the mysql client shows it." : { }, @@ -198336,6 +198435,10 @@ } } }, + "this port speaks HTTP; use the Bolt port, usually 7687" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "This private key belongs to a security key (FIDO), which TablePro signs with only through an SSH agent. Choose SSH Agent and add the key to it." : { }, diff --git a/apple/TableProTests/Core/Plugins/PluginDriverAdapterStatementContextTests.swift b/apple/TableProTests/Core/Plugins/PluginDriverAdapterStatementContextTests.swift index 75288ff3a9..ee0b6eea98 100644 --- a/apple/TableProTests/Core/Plugins/PluginDriverAdapterStatementContextTests.swift +++ b/apple/TableProTests/Core/Plugins/PluginDriverAdapterStatementContextTests.swift @@ -91,6 +91,15 @@ struct PluginDriverAdapterStatementContextTests { #expect(driver.contexts.map { $0.readOnly } == [false]) } + @Test("A Cypher read is marked, so the driver sends it in Bolt's read mode, and a write is not") + func cypherReadIsMarked() async throws { + let driver = ContextRecordingDriver() + let adapter = makeAdapter(.neo4j, driver: driver) + _ = try await adapter.executeUserQuery(query: "MATCH (n:Person) RETURN n LIMIT 5", rowCap: nil, parameters: nil) + _ = try await adapter.executeUserQuery(query: "MATCH (n) DETACH DELETE n", rowCap: nil, parameters: nil) + #expect(driver.contexts.map { $0.readOnly } == [true, false]) + } + /// A SQL driver ignores the flag, so its statements are not classified for it. @Test("A SQL read is not marked") func sqlReadIsNotMarked() async throws { diff --git a/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift index e5d6ddaacd..0158bf2b91 100644 --- a/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift +++ b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift @@ -10,7 +10,8 @@ struct PluginMetadataRegistryTypeCountTests { "CockroachDB", "Dameng", "Databend", "DuckDB", "DynamoDB", "Elasticsearch", "etcd", "Kafka", "KingbaseES", "libSQL", "MariaDB", "MongoDB", "MySQL", "Oracle", "PGlite", "PostgreSQL", "Redis", "Redshift", "SAP HANA", "ScyllaDB", "Snowflake", "Spanner", "SQL Server", "SQLite", "SurrealDB", "Teradata", "TiDB", - "Trino", "Presto", "Turso", "Typesense", "Weaviate", "OceanBase", "Firestore", "Firebase Realtime Database", "Bigtable" + "Trino", "Presto", "Turso", "Typesense", "Weaviate", "OceanBase", "Firestore", "Firebase Realtime Database", "Bigtable", + "Neo4j" ] private static func builtInTypeIds() -> Set { diff --git a/apple/TableProTests/Core/Utilities/ConnectionURLParserTLSPortTests.swift b/apple/TableProTests/Core/Utilities/ConnectionURLParserTLSPortTests.swift index 5f64d2997f..aa34392ba4 100644 --- a/apple/TableProTests/Core/Utilities/ConnectionURLParserTLSPortTests.swift +++ b/apple/TableProTests/Core/Utilities/ConnectionURLParserTLSPortTests.swift @@ -150,6 +150,22 @@ struct ConnectionURLParserTLSPortTests { #expect(connection.sslConfig.mode == .disabled) } + @Test("A Neo4j URL's +s verifies the certificate, +ssc accepts a self-signed one, and plain stays plain") + func neo4jSchemeSetsTLS() throws { + let cases: [(url: String, mode: SSLMode?)] = [ + ("neo4j+s://abc.databases.neo4j.io", .verifyIdentity), + ("bolt+s://db.example.com:7687", .verifyIdentity), + ("neo4j+ssc://db.example.com", .required), + ("bolt://localhost:7687", nil), + ("neo4j://localhost", nil) + ] + for (url, mode) in cases { + let parsed = try parse(url) + #expect(parsed.type == .neo4j, "\(url)") + #expect(parsed.sslMode == mode, "\(url)") + } + } + @Test("An etcd URL sets the TLS Mode its driver reads, never the generic SSL Mode it ignores") func etcdURLSetsTheDriverTLSMode() throws { let cases: [(url: String, tlsMode: String)] = [ diff --git a/apple/TableProTests/Core/Utilities/SQL/QueryClassifierHardeningTests.swift b/apple/TableProTests/Core/Utilities/SQL/QueryClassifierHardeningTests.swift index 1261179ed8..6b36262694 100644 --- a/apple/TableProTests/Core/Utilities/SQL/QueryClassifierHardeningTests.swift +++ b/apple/TableProTests/Core/Utilities/SQL/QueryClassifierHardeningTests.swift @@ -913,6 +913,88 @@ struct QueryClassifierNonSqlTests { #expect(QueryClassifier.isWriteQuery("SELECT * FROM events", databaseType: .bigtable)) } + @Test("Cypher reads unless a clause writes, wherever the clause sits") + func cypherTiers() { + let reads = [ + "// TablePro browse: node\nMATCH (n:`Person`)\nRETURN n\nSKIP 0 LIMIT 200", + "MATCH (n) WHERE n.set = 1 AND n.name STARTS WITH 'CREATE' RETURN n {delete: n.x}", + "MATCH (a)--(b) RETURN count(*)", + "CALL db.labels() YIELD label RETURN label", + "CALL db.schema.nodeTypeProperties()", + "SHOW DATABASES", + "MATCH (n:Create:`DELETE`) RETURN n // SET n.x = 1" + ] + for query in reads { + #expect(!QueryClassifier.isWriteQuery(query, databaseType: .neo4j), "\(query)") + } + #expect(QueryClassifier.classifyTier("MATCH (n) SET n.seen = true", databaseType: .neo4j) == .write) + #expect(QueryClassifier.classifyTier("MATCH (n) DETACH DELETE n", databaseType: .neo4j) == .write) + #expect(QueryClassifier.classifyTier("MERGE (n:Person {id: 1})", databaseType: .neo4j) == .write) + #expect(QueryClassifier.classifyTier("INSERT (:Person {id: 1})", databaseType: .neo4j) == .write) + #expect(QueryClassifier.classifyTier("MATCH (n) CALL (n) { INSERT (n)-[:R]->(:M) } RETURN n", databaseType: .neo4j) == .write) + #expect(QueryClassifier.classifyTier("DEALLOCATE DATABASES FROM SERVER 's1'", databaseType: .neo4j) == .write) + #expect(QueryClassifier.classifyTier("START DATABASE sales", databaseType: .neo4j) == .write) + #expect(QueryClassifier.classifyTier("USE system STOP DATABASE sales", databaseType: .neo4j) == .write) + #expect(QueryClassifier.classifyTier("ENABLE SERVER 'abc'", databaseType: .neo4j) == .write) + #expect(QueryClassifier.classifyTier("ALTER USER bob SET PASSWORD 'x'", databaseType: .neo4j) == .write) + #expect(QueryClassifier.classifyTier("TERMINATE TRANSACTIONS 'neo4j-transaction-1'", databaseType: .neo4j) == .write) + #expect(!QueryClassifier.isWriteQuery("MATCH (start:Station)-[:NEXT]->(stop) RETURN start.name, stop", databaseType: .neo4j)) + #expect(!QueryClassifier.isWriteQuery("MATCH (n) RETURN n.x AS rename, n.y AS enable", databaseType: .neo4j)) + #expect(!QueryClassifier.isWriteQuery("MATCH (n:`INSERT`) WHERE n.insert = 'INSERT' RETURN n", databaseType: .neo4j)) + #expect(QueryClassifier.classifyTier("CALL apoc.periodic.iterate('a', 'b', {})", databaseType: .neo4j) == .write) + #expect(QueryClassifier.classifyTier("MATCH (n) CALL { WITH n CREATE (m) } RETURN n", databaseType: .neo4j) == .write) + #expect(QueryClassifier.classifyTier("DROP INDEX person_name", databaseType: .neo4j) == .destructive) + #expect(QueryClassifier.classifyTier("MATCH (a)<-- (b) DETACH DELETE a", databaseType: .neo4j) == .write) + #expect(QueryClassifier.classifyTier("CALL `apoc.periodic.iterate`('a', 'b', {})", databaseType: .neo4j) == .write) + #expect(QueryClassifier.classifyTier("MATCH (n) CALL (n) { SET n.x = 1 } RETURN n", databaseType: .neo4j) == .write) + #expect(!QueryClassifier.isWriteQuery("MATCH (n) CALL (n) { RETURN n.x AS x } RETURN x", databaseType: .neo4j)) + #expect(QueryClassifier.classifyTier("RETURN 'it\\'s' /* x */ ; CREATE (n)", databaseType: .neo4j) == .write) + let load = QueryClassifier.classify("LOAD CSV FROM 'file:///x.csv' AS row RETURN row", databaseType: .neo4j) + #expect(load.reachesFilesystemOrExecutesCode) + } + + @Test("A Cypher keyword or backtick written as a unicode escape is tiered as the server decodes it") + func cypherUnicodeEscapes() { + let escapedDelete = "MATCH (n) \\u0044ETACH \\u0044ELETE n" + #expect(QueryClassifier.classifyTier(escapedDelete, databaseType: .neo4j) == .write) + let closedBacktick = "MATCH (n:`x\\u0060) DETACH DELETE n //`) RETURN n" + #expect(QueryClassifier.classifyTier(closedBacktick, databaseType: .neo4j) == .write) + let repeatedU = "MATCH (n) \\uuu0053ET n.x = 1" + #expect(QueryClassifier.classifyTier(repeatedU, databaseType: .neo4j) == .write) + let escapedBackslash = "RETURN '\\\\u0044ELETE' AS x" + #expect(!QueryClassifier.isWriteQuery(escapedBackslash, databaseType: .neo4j)) + #expect(QueryClassifier.cypherDecodingUnicodeEscapes("a\\u0041\\\\u0041\\u00") == "aA\\\\u0041\\u00") + } + + @Test("A Cypher DELETE that nothing narrows gets the dangerous-query confirmation") + func cypherUnfilteredDelete() { + let dangerous = [ + "MATCH (n) DETACH DELETE n", + "MATCH (n:Temp) DELETE n", + "MATCH (n) WHERE n.x = 1 SET n.y = 2; MATCH (m) DELETE m", + "DROP INDEX person_name" + ] + for query in dangerous { + #expect(QueryClassifier.isDangerousQuery(query, databaseType: .neo4j), "\(query)") + } + let narrowed = [ + "MATCH (n:Temp) WHERE n.age > 3 DELETE n", + "MATCH (n {id: 1}) DETACH DELETE n", + "MATCH (n) WITH n LIMIT 10 DELETE n", + "MATCH (n) RETURN n {delete: n.x}", + "MATCH (n) SET n.seen = true" + ] + for query in narrowed { + #expect(!QueryClassifier.isDangerousQuery(query, databaseType: .neo4j), "\(query)") + } + } + + @Test("Cypher's -- is a pattern, so a ; after it still ends the statement") + func cypherDashesAreCode() { + #expect(QueryClassifier.isMultiStatement("MATCH (a)-- (b) RETURN a; MATCH (n) RETURN n", databaseType: .neo4j)) + #expect(!QueryClassifier.isMultiStatement("MATCH (a)-- (b) RETURN a", databaseType: .neo4j)) + } + @Test("Firebase request lines are tiered by their verb, and a grid page is a read") func firebaseTiers() { for type in [DatabaseType.firestore, .firebaseRealtimeDatabase] { diff --git a/apple/changelog.d/3925.added.md b/apple/changelog.d/3925.added.md new file mode 100644 index 0000000000..fdc10e5da2 --- /dev/null +++ b/apple/changelog.d/3925.added.md @@ -0,0 +1 @@ +Neo4j connections, with labels and relationship types in the sidebar and Cypher in the editor. diff --git a/apple/project.yml b/apple/project.yml index cbf751b9d2..9bff6db292 100644 --- a/apple/project.yml +++ b/apple/project.yml @@ -1059,6 +1059,8 @@ targets: templateAttributes: folder: KafkaDriverPlugin principalClass: KafkaPlugin + sources: + - Plugins/SharedNIO dependencies: - package: zstd product: libzstd @@ -1167,6 +1169,23 @@ targets: - -lz SWIFT_INCLUDE_PATHS: $(SRCROOT)/Plugins/MongoDBDriverPlugin/CLibMongoc + Neo4jDriverPlugin: + templates: [DriverPlugin] + templateAttributes: + folder: Neo4jDriverPlugin + principalClass: Neo4jPlugin + sources: + - Plugins/SharedNIO + dependencies: + - package: TableProCore + product: TableProNeo4jCore + - package: swift-nio + product: NIOCore + - package: swift-nio + product: NIOPosix + - package: swift-nio-ssl + product: NIOSSL + OracleDriver: templates: [DriverPlugin] templateAttributes: @@ -1304,6 +1323,7 @@ aggregateTargets: - LibSQLDriverPlugin - MSSQLDriver - MongoDBDriver + - Neo4jDriverPlugin - OracleDriver - ParquetExport - SnowflakeDriverPlugin diff --git a/apple/scripts/probes/check-neo4j.sh b/apple/scripts/probes/check-neo4j.sh new file mode 100755 index 0000000000..d483251416 --- /dev/null +++ b/apple/scripts/probes/check-neo4j.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Build the actual registry driver and probe an existing, disposable Neo4j server, for example +# `docker run -p 7687:7687 -e NEO4J_AUTH=neo4j/password neo4j:5`. No server is started. +# TP_NEO4J_{HOST,PORT,USER,PASSWORD} select the server. TP_NEO4J_SSL_MODE takes an SSLMode raw +# value ("Required", "Verify Identity") and TP_NEO4J_CA a PEM file to trust. +# The probe writes nodes under a random label and deletes them before it exits. +# TP_NEO4J_BUILD_DIR preserves build artifacts; otherwise they are removed on exit. +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +WORK="${TP_NEO4J_BUILD_DIR:-}" +if [ -z "$WORK" ]; then + WORK="$(mktemp -d)" + trap 'rm -rf "$WORK"' EXIT +fi +mkdir -p "$WORK/Sources/Probe" +SOURCES="$WORK/Sources/Probe" +rm -f "$SOURCES"/*.swift +for source in "$ROOT/Plugins/Neo4jDriverPlugin"/*.swift; do + ln -s "$source" "$SOURCES/$(basename "$source")" +done +ln -s "$ROOT/Plugins/Shared/PluginConnectTimeout.swift" "$SOURCES/PluginConnectTimeout.swift" +ln -s "$ROOT/Plugins/SharedNIO/PluginNIOChannel.swift" "$SOURCES/PluginNIOChannel.swift" +ln -s "$ROOT/scripts/probes/neo4j-probe.swift" "$SOURCES/main.swift" +cat > "$WORK/Package.swift" < "$WORK/build.log" 2>&1); then + tail -60 "$WORK/build.log" >&2 + exit 3 +fi +"$WORK/.build/debug/Probe" diff --git a/apple/scripts/probes/neo4j-probe.swift b/apple/scripts/probes/neo4j-probe.swift new file mode 100644 index 0000000000..b87fd302e4 --- /dev/null +++ b/apple/scripts/probes/neo4j-probe.swift @@ -0,0 +1,279 @@ +import Foundation +import TableProNeo4jCore +import TableProPluginKit + +// Run by check-neo4j.sh against a live server. Exits 1 on the first expectation that fails. + +let environment = ProcessInfo.processInfo.environment +var ssl = SSLConfiguration() +ssl.mode = SSLMode(rawValue: environment["TP_NEO4J_SSL_MODE"] ?? "") ?? .disabled +ssl.caCertificatePath = environment["TP_NEO4J_CA"] ?? "" +let driver = Neo4jPluginDriver(config: DriverConnectionConfig( + host: environment["TP_NEO4J_HOST"] ?? "127.0.0.1", + port: Int(environment["TP_NEO4J_PORT"] ?? "") ?? 7_687, + username: environment["TP_NEO4J_USER"] ?? "neo4j", + password: environment["TP_NEO4J_PASSWORD"] ?? "", + database: "", + ssl: ssl +)) +let label = "TableProProbe\(Int.random(in: 100_000 ... 999_999))" +var failures = 0 + +func expect(_ condition: Bool, _ message: String) { + print(condition ? "ok \(message)" : "FAIL \(message)") + if !condition { failures += 1 } +} + +func text(_ result: PluginQueryResult) -> [[String?]] { + result.rows.map { $0.map(\.asText) } +} + +func run() async throws { + try await driver.connect() + print("server: \(driver.serverVersion ?? "unknown"), database: \(driver.currentDatabase)") + + _ = try await driver.execute(query: """ + CREATE (a:\(label) {name: 'Ann', age: 30, born: date('1994-05-01')}), + (b:\(label) {name: 'Bo', email: 'bo@example.com'}), + (a)-[:\(label)_KNOWS {since: 2020}]->(b) + """) + + let databases = try await driver.fetchDatabases() + expect(databases.contains(driver.currentDatabase), "the current database is listed: \(databases)") + + let tables = try await driver.fetchTablesInAllSchemas() ?? [] + expect(tables.contains { $0.name == label && $0.schema == "Labels" }, "the label is listed under Labels") + expect(tables.contains { $0.name == "\(label)_KNOWS" && $0.schema == "Relationship Types" }, + "the relationship type is listed under Relationship Types") + + let columns = try await driver.fetchColumns(table: label, schema: "Labels").map(\.name) + expect(columns == ["", "", "age", "born", "email", "name"], "columns: \(columns)") + let count = try await driver.fetchApproximateRowCount(table: label, schema: "Labels") + expect(count == 2, "row count: \(String(describing: count))") + + let nameIndex = try require(columns.firstIndex(of: "name")) + let browse = try require(driver.buildBrowseQuery( + table: label, schema: "Labels", sortColumns: [(nameIndex, true)], columns: columns, limit: 200, offset: 0 + )) + let page = try await driver.execute(query: browse) + expect(page.columns == columns, "browse columns: \(page.columns)") + expect(text(page).map { $0[nameIndex] } == ["Ann", "Bo"], "browse sorts by name") + expect(text(page).first?[columns.firstIndex(of: "born") ?? 0] == "1994-05-01", "a date reads as Cypher writes it") + + let relationships = try await driver.execute(query: try require(driver.buildBrowseQuery( + table: "\(label)_KNOWS", schema: "Relationship Types", sortColumns: [], columns: [], limit: 200, offset: 0 + ))) + expect(relationships.columns == ["", "", "", "since"], "relationship columns: \(relationships.columns)") + + let ageFilter = [PluginQueryFilter(column: "age", op: "CONTAINS", value: "3", isCaseSensitive: false)] + let filtered = try await driver.execute(query: try require(driver.buildFilteredQuery( + table: label, schema: "Labels", queryFilters: ageFilter, logicMode: "and", + sortColumns: [], columns: columns, limit: 200, offset: 0, columnKinds: [:] + ))) + expect(text(filtered).map { $0[nameIndex] } == ["Ann"], "a text filter on an integer property matches as text") + let filteredCount = try await driver.fetchFilteredRowCount(table: label, queryFilters: ageFilter, logicMode: "and") + expect(filteredCount == 1, "a filtered count: \(String(describing: filteredCount))") + + let escapeColumn = #"x\u0060 IS NULL WITH n DETACH DELETE n WITH n WHERE n.\u0060x"# + do { + _ = try await driver.fetchFilteredRowCount( + table: label, queryFilters: [PluginQueryFilter(column: escapeColumn, op: "IS NULL", value: "")], logicMode: "and" + ) + expect(false, "a column holding a unicode escape is refused") + } catch { + expect(true, "a column holding a unicode escape is refused") + } + do { + _ = try await driver.execute(query: "MATCH (n:\(label)) WHERE n.\(Neo4jCypher.quote(escapeColumn)) IS NULL RETURN count(n)") + expect(false, "a quoted unicode escape fails on the server") + } catch { + expect(true, "a quoted unicode escape fails on the server") + } + let afterEscape = try await driver.fetchApproximateRowCount(table: label, schema: "Labels") + expect(afterEscape == 2, "no node was deleted through a quoted name: \(String(describing: afterEscape))") + + _ = try await driver.execute(query: "CREATE INDEX \(label)_name IF NOT EXISTS FOR (n:\(label)) ON (n.name)") + let indexes = try await driver.fetchIndexes(table: label, schema: "Labels").map(\.name) + expect(indexes == ["\(label)_name"], "the label's index is listed: \(indexes)") + let relationshipIndexes = try await driver.fetchIndexes(table: label, schema: "Relationship Types") + expect(relationshipIndexes.isEmpty, "a relationship type does not borrow a label's index") + _ = try await driver.execute(query: "DROP INDEX \(label)_name IF EXISTS") + + let capped = try await driver.executeUserQuery(query: "UNWIND range(1, 50) AS x RETURN x", rowCap: 10, parameters: nil) + expect(capped.rows.count == 10 && capped.isTruncated, "a row cap truncates on the server") + let afterCap = try await driver.execute(query: "RETURN 3 AS ok") + expect(text(afterCap) == [["3"]], "the next statement runs after a capped one") + + let cappedWrite = try await driver.executeUserQuery( + query: "UNWIND range(1, 12) AS x CREATE (n:\(label) {seq: x}) RETURN n", rowCap: 10, parameters: nil + ) + expect(cappedWrite.rows.count == 10 && cappedWrite.isTruncated, "a capped write returns the capped rows") + expect(cappedWrite.rowsAffected == 12, "a capped write counts every node it created: \(cappedWrite.rowsAffected)") + _ = try await driver.execute(query: "MATCH (n:\(label)) WHERE n.seq IS NOT NULL DELETE n") + + let setOnly = try await driver.execute(query: "MATCH (n:\(label) {name: 'Ann'}) SET n.a = 1, n.b = 2") + expect(setOnly.rowsAffected == 0 && setOnly.statusMessage == "2 property value(s) set", + "a property update reports its counter: \(setOnly.rowsAffected), \(setOnly.statusMessage ?? "nil")") + + let duration = try await driver.execute(query: "RETURN duration({seconds: 10000000000}) AS d") + expect(text(duration) == [["PT2777777H46M40S"]], "a long duration formats: \(text(duration))") + + let path = try await driver.execute( + query: "MATCH p = (:\(label) {name: 'Bo'})<-[:\(label)_KNOWS]-(:\(label) {name: 'Ann'}) RETURN p" + ) + let pathObject = try require(text(path).first?.first.flatMap { $0 }.flatMap { + try? JSONSerialization.jsonObject(with: Data($0.utf8)) as? [String: Any] + }) + let pathNodes = (pathObject["nodes"] as? [[String: Any]] ?? []).compactMap { $0["elementId"] as? String } + let pathStep = (pathObject["relationships"] as? [[String: Any]])?.first + expect(pathNodes.count == 2 && pathStep?["startElementId"] as? String == pathNodes.last + && pathStep?["endElementId"] as? String == pathNodes.first, + "a path walked against its relationship keeps the relationship's direction") + + try await checkReadModeFiltersExportAndCancel(columns: columns, nameIndex: nameIndex) + + try driver.cancelQuery() + let afterIdleCancel = try await driver.execute(query: "RETURN 4 AS ok") + expect(text(afterIdleCancel) == [["4"]], "a cancel with nothing running leaves the next statement alone") + + do { + _ = try await driver.execute(query: "MATCH (n RETURN n") + expect(false, "a syntax error throws") + } catch { + expect(true, "a syntax error throws: \(error.localizedDescription.prefix(60))") + } + let afterError = try await driver.execute(query: "RETURN 1 AS ok") + expect(text(afterError) == [["1"]], "the connection recovers after an error") + + let slow = Task { try await driver.execute(query: "UNWIND range(1, 1000000000) AS x WITH x WHERE x < 0 RETURN count(x)") } + try await Task.sleep(nanoseconds: 500_000_000) + try driver.cancelQuery() + do { + _ = try await slow.value + expect(false, "cancel stops a running query") + } catch { + expect(true, "cancel stops a running query") + } + let afterCancel = try await driver.execute(query: "RETURN 2 AS ok") + expect(text(afterCancel) == [["2"]], "the connection recovers after a cancel") +} + +func checkReadModeFiltersExportAndCancel(columns: [String], nameIndex: Int) async throws { + var readContext = PluginStatementContext() + readContext.readOnly = true + do { + _ = try await driver.execute(query: "CREATE (n:\(label) {probe: 'read mode'})", context: readContext) + expect(false, "a statement marked read-only cannot write") + } catch { + expect(true, "a statement marked read-only cannot write: \(error.localizedDescription.prefix(70))") + } + let rawWrite = [PluginQueryFilter(column: "__RAW__", op: "", value: "true WITH n SET n.raw = 1 WITH n WHERE true")] + do { + _ = try await driver.execute(query: try require(driver.buildFilteredQuery( + table: label, schema: "Labels", queryFilters: rawWrite, logicMode: "and", + sortColumns: [], columns: columns, limit: 200, offset: 0, columnKinds: [:] + ))) + expect(false, "a raw filter cannot write through a grid page") + } catch { + expect(true, "a raw filter cannot write through a grid page") + } + let rawCount = try await driver.execute(query: "MATCH (n:\(label)) WHERE n.raw IS NOT NULL RETURN count(n)") + expect(text(rawCount) == [["0"]], "no property was set by the raw filter") + + func filteredNames(_ filters: [PluginQueryFilter]) async throws -> [String?] { + let page = try await driver.execute(query: try require(driver.buildFilteredQuery( + table: label, schema: "Labels", queryFilters: filters, logicMode: "and", + sortColumns: [(nameIndex, true)], columns: columns, limit: 200, offset: 0, columnKinds: [:] + ))) + return text(page).map { $0[nameIndex] } + } + let between = try await filteredNames([PluginQueryFilter(column: "age", op: "BETWEEN", value: "20,40", secondValue: "40", elementScope: nil)]) + expect(between == ["Ann"], "BETWEEN uses both bounds: \(between)") + let byLabel = try await filteredNames([PluginQueryFilter(column: "", op: "=", value: label)]) + expect(byLabel == ["Ann", "Bo"], " = matches a node carrying the label: \(byLabel)") + let notLabel = try await filteredNames([PluginQueryFilter(column: "", op: "!=", value: label)]) + expect(notLabel.isEmpty, " != excludes it: \(notLabel)") + let regex = try await filteredNames([PluginQueryFilter(column: "age", op: "REGEX", value: "3.*")]) + expect(regex == ["Ann"], "REGEX on an integer property matches it as text: \(regex)") + + _ = try await driver.execute(query: "UNWIND range(1, 2500) AS x CREATE (:\(label)_Bulk {seq: x})") + let export = try require(driver.defaultExportQuery(table: "\(label)_Bulk", schema: "Labels")) + var exportColumns: [String] = [] + var exportRows = 0 + var exportBatches = 0 + for try await element in driver.streamRows(query: export) { + switch element { + case .header(let header): exportColumns = header.columns + case .rows(let rows): + exportRows += rows.count + exportBatches += 1 + } + } + expect(exportColumns == ["", "", "seq"], "an export spreads entities: \(exportColumns)") + expect(exportRows == 2_500 && exportBatches == 3, "an export pulls in batches: \(exportRows) rows in \(exportBatches)") + let slowFilter = [PluginQueryFilter( + column: "__RAW__", op: "", value: "size([x IN range(1, 200000) WHERE x < 0]) = 0" + )] + let countStarted = Date() + let slowCount = Task { + try await driver.fetchFilteredRowCount(table: "\(label)_Bulk", queryFilters: slowFilter, logicMode: "and") + } + try await Task.sleep(nanoseconds: 300_000_000) + let queued = Task { try await driver.execute(query: "RETURN 5 AS queued") } + try await Task.sleep(nanoseconds: 300_000_000) + try driver.cancelQuery() + let countAfterCancel = try? await slowCount.value + print("info the slow count took \(Int(Date().timeIntervalSince(countStarted) * 1_000)) ms") + expect(countAfterCancel == 2_500, "a cancel leaves metadata work holding the connection alone: \(String(describing: countAfterCancel))") + do { + _ = try await queued.value + expect(false, "a cancel stops the user statement waiting for its turn") + } catch { + expect(true, "a cancel stops the user statement waiting for its turn") + } + _ = try await driver.execute(query: "MATCH (n:\(label)_Bulk) DELETE n") + + let decoded = try await driver.execute(query: "RETURN 1 AS \\u0061bc") + print("info the server decodes unicode escapes outside strings: \(decoded.columns == ["abc"])") + + var preferred = SSLConfiguration() + preferred.mode = .preferred + let fallback = Neo4jPluginDriver(config: DriverConnectionConfig( + host: environment["TP_NEO4J_HOST"] ?? "127.0.0.1", + port: Int(environment["TP_NEO4J_PORT"] ?? "") ?? 7_687, + username: environment["TP_NEO4J_USER"] ?? "neo4j", + password: environment["TP_NEO4J_PASSWORD"] ?? "", + database: "", + ssl: preferred + )) + do { + try await fallback.connect() + let ping = try await fallback.execute(query: "RETURN 6 AS ok") + expect(text(ping) == [["6"]], "SSL Preferred connects to a server without TLS") + } catch { + expect(ssl.mode != .disabled, "SSL Preferred connects to a server without TLS: \(error.localizedDescription)") + } + fallback.disconnect() +} + +struct RequireError: Error {} + +func require(_ value: T?) throws -> T { + guard let value else { throw RequireError() } + return value +} + +let done = DispatchSemaphore(value: 0) +Task { + do { + try await run() + _ = try? await driver.execute(query: "MATCH (n:\(label)) DETACH DELETE n") + } catch { + print("FAIL \(error.localizedDescription)") + failures += 1 + } + driver.disconnect() + done.signal() +} +done.wait() +exit(failures == 0 ? 0 : 1)