diff --git a/.github/plugin-registry.json b/.github/plugin-registry.json index 778efe9f7e..dbda3557e7 100644 --- a/.github/plugin-registry.json +++ b/.github/plugin-registry.json @@ -40,6 +40,9 @@ "HanaDriver": { "name": "SAP HANA Driver" }, + "InfluxDBDriverPlugin": { + "name": "InfluxDB Driver" + }, "KafkaDriverPlugin": { "name": "Kafka Driver" }, diff --git a/apple/Packages/TableProCore/Package.swift b/apple/Packages/TableProCore/Package.swift index e0b658ff61..53d96abf4e 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: "TableProInfluxDBCore", targets: ["TableProInfluxDBCore"]), .library(name: "TableProNeo4jCore", targets: ["TableProNeo4jCore"]), .library(name: "TableProFirebaseCore", targets: ["TableProFirebaseCore"]), .library(name: "TableProBigtableCore", targets: ["TableProBigtableCore"]), @@ -194,6 +195,11 @@ let package = Package( dependencies: [], path: "Sources/TableProWeaviateCore" ), + .target( + name: "TableProInfluxDBCore", + dependencies: [], + path: "Sources/TableProInfluxDBCore" + ), .target( name: "TableProNeo4jCore", dependencies: [], @@ -313,6 +319,11 @@ let package = Package( dependencies: ["TableProTeradataCore"], path: "Tests/TableProTeradataCoreTests" ), + .testTarget( + name: "TableProInfluxDBCoreTests", + dependencies: ["TableProInfluxDBCore"], + path: "Tests/TableProInfluxDBCoreTests" + ), .testTarget( name: "TableProTrinoCoreTests", dependencies: ["TableProTrinoCore", "TableProSQLGrammar"], diff --git a/apple/Packages/TableProCore/Sources/TableProConnectionLibrary/SQLDDLFallbackPolicy.swift b/apple/Packages/TableProCore/Sources/TableProConnectionLibrary/SQLDDLFallbackPolicy.swift index d5bc57829a..e6f427c686 100644 --- a/apple/Packages/TableProCore/Sources/TableProConnectionLibrary/SQLDDLFallbackPolicy.swift +++ b/apple/Packages/TableProCore/Sources/TableProConnectionLibrary/SQLDDLFallbackPolicy.swift @@ -48,6 +48,7 @@ public enum SQLDDLFallbackPolicy { "Firebase Realtime Database", "Bigtable", "Neo4j", + "InfluxDB", ] /// True when `DROP`/`TRUNCATE` built by the app is something this engine could run. diff --git a/apple/Packages/TableProCore/Sources/TableProInfluxDBCore/FluxScript.swift b/apple/Packages/TableProCore/Sources/TableProInfluxDBCore/FluxScript.swift new file mode 100644 index 0000000000..5daddfb1c0 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProInfluxDBCore/FluxScript.swift @@ -0,0 +1,409 @@ +import Foundation + +// A Flux script reads only when every package it imports is a local, read-only one and it names no writing function. +// A blocklist of the packages that write or reach out goes stale with each Flux release; this list only grows by review. +public struct FluxScriptReading: Sendable, Equatable { + public let writes: Bool + public let reachesOutside: Bool + + public init(writes: Bool, reachesOutside: Bool) { + self.writes = writes + self.reachesOutside = reachesOutside + } + + public init(_ script: String) { + let (tokens, complete) = FluxLexer.lex(script) + // An unclosed string, regex or interpolation means this lexer and Flux may disagree on what is code. + guard complete else { + self.init(writes: true, reachesOutside: true) + return + } + let imports = FluxImports(tokens) + let unknownImport = imports.paths.contains { !Self.readOnlyPackages.contains($0) } + let packageEscapes = Self.usesPackageAsValue(tokens, packages: imports.names) + let outsideArgument = Self.passesOutsideArgument(tokens) + let writingName = Self.namesWritingFunction(tokens) + self.init( + writes: writingName || unknownImport || packageEscapes, + reachesOutside: unknownImport || packageEscapes || outsideArgument + ) + } + + static let readOnlyPackages: Set = [ + "array", "csv", "date", "date/boundaries", "dict", "experimental", "experimental/aggregate", + "experimental/array", "experimental/table", "generate", "influxdata/influxdb", "influxdata/influxdb/schema", + "influxdata/influxdb/v1", "interpolate", "join", "json", "math", "regexp", "runtime", "sampledata", "strings", + "system", "timezone", "types", + ] + + // `to` and `wideTo` are the writers in the read-only packages above: universe, `experimental` and `influxdb`. + static let writingNames: Set = ["to", "wideTo"] + + // `from`, `buckets` and `to` take `host` to reach another server; `csv.from` takes `file` to read the server's disk. + static let outsideLabels: Set = ["host", "file"] + + private static func namesWritingFunction(_ tokens: [FluxToken]) -> Bool { + tokens.indices.contains { index in + let token = tokens[index] + guard token.kind == .identifier || token.kind == .memberString, writingNames.contains(token.text) else { + return false + } + return !tokens.isSymbol(":", at: index + 1) + } + } + + // A call argument is `host: x`, `"host": x`, or the shorthand `host` for `host: host`. + private static func passesOutsideArgument(_ tokens: [FluxToken]) -> Bool { + tokens.indices.contains { index in + let token = tokens[index] + guard token.kind == .identifier || token.kind == .string, outsideLabels.contains(token.text), + token.enclosingBracket == "(" + else { return false } + if tokens.isSymbol(":", at: index + 1) { return true } + let opensArgument = tokens.isSymbol("(", at: index - 1) || tokens.isSymbol(",", at: index - 1) + let closesArgument = tokens.isSymbol(",", at: index + 1) || tokens.isSymbol(")", at: index + 1) + return token.kind == .identifier && opensArgument && closesArgument + } + } + + // A package bound to a name, passed on or indexed with `[...]` can reach any member, which the name check cannot follow. + private static func usesPackageAsValue(_ tokens: [FluxToken], packages: Set) -> Bool { + tokens.indices.contains { index in + let token = tokens[index] + guard token.kind == .identifier, packages.contains(token.text) else { return false } + if tokens.isSymbol(".", at: index - 1) || tokens.isIdentifier("import", at: index - 1) { return false } + return !tokens.isSymbol(".", at: index + 1) && !tokens.isSymbol(":", at: index + 1) + } + } +} + +extension [FluxToken] { + func isSymbol(_ text: String, at index: Int) -> Bool { + indices.contains(index) && self[index].kind == .symbol && self[index].text == text + } + + func isIdentifier(_ text: String, at index: Int) -> Bool { + indices.contains(index) && self[index].kind == .identifier && self[index].text == text + } +} + +struct FluxImports { + var paths: [String] = [] + var names: Set = [] + + init(_ tokens: [FluxToken]) { + var index = 0 + while index < tokens.count { + defer { index += 1 } + guard tokens[index].kind == .identifier, tokens[index].text == "import" else { continue } + var cursor = index + 1 + var alias: String? + if cursor < tokens.count, tokens[cursor].kind == .identifier { + alias = tokens[cursor].text + cursor += 1 + } + guard cursor < tokens.count, tokens[cursor].kind == .string else { continue } + let path = tokens[cursor].text + paths.append(path) + names.insert(alias ?? String(path.split(separator: "/").last ?? Substring(path))) + index = cursor + } + } +} + +struct FluxToken: Equatable { + enum Kind: Equatable { + case identifier + case string + // A string used as a member name, `pkg["name"]`, reported again as the name it reaches. + case memberString + case regex + case number + case symbol + } + + let kind: Kind + let text: String + let enclosingBracket: Character? +} + +// Comments, strings with `${...}` code inside, and regex literals, which Flux tells apart from division by what precedes. +enum FluxLexer { + static func lex(_ script: String) -> (tokens: [FluxToken], complete: Bool) { + var lexer = State(Array(script.unicodeScalars)) + lexer.run(untilClosingBrace: false) + return (lexer.tokens, lexer.complete) + } + + static func tokens(of script: String) -> [FluxToken] { + lex(script).tokens + } + + // Longest first, the way Flux matches `1mo` before `1m`. + private static let durationUnits = ["mo", "ms", "us", "µs", "ns", "y", "w", "d", "h", "m", "s"] + + private static let keywords: Set = ["and", "or", "not", "return", "if", "then", "else", "exists", "import"] + private static let twoCharacterSymbols: Set = ["|>", "=~", "!~", "==", "!=", "<=", ">=", "=>", "<-"] + + private struct State { + let scalars: [Unicode.Scalar] + var position = 0 + var tokens: [FluxToken] = [] + var brackets: [Character] = [] + var complete = true + + init(_ scalars: [Unicode.Scalar]) { + self.scalars = scalars + } + + var regexAllowed: Bool { + guard let last = tokens.last else { return true } + switch last.kind { + case .identifier: return keywords.contains(last.text) + case .string, .memberString, .regex, .number: return false + case .symbol: return ![")", "]", "}"].contains(last.text) + } + } + + func peek(_ offset: Int = 0) -> Unicode.Scalar? { + let index = position + offset + return index < scalars.count ? scalars[index] : nil + } + + mutating func emit(_ kind: FluxToken.Kind, _ text: String) { + tokens.append(FluxToken(kind: kind, text: text, enclosingBracket: brackets.last)) + } + + mutating func run(untilClosingBrace: Bool) { + var depth = 0 + while let scalar = peek() { + if scalar.properties.isWhitespace { + position += 1 + } else if scalar == "/", peek(1) == "/" { + while let next = peek(), next != "\n" { position += 1 } + } else if scalar == "/", regexAllowed { + readRegex() + } else if scalar == "\"" { + readString() + } else if scalar == "_" || scalar.properties.isAlphabetic { + readIdentifier() + } else if ("0" ... "9").contains(scalar) { + readNumber() + } else { + if untilClosingBrace { + if scalar == "{" { depth += 1 } + if scalar == "}" { + if depth == 0 { + position += 1 + return + } + depth -= 1 + } + } + readSymbol() + } + } + if untilClosingBrace { complete = false } + } + + mutating func readSymbol() { + let first = scalars[position] + if let second = peek(1) { + let pair = String(String.UnicodeScalarView([first, second])) + if twoCharacterSymbols.contains(pair) { + position += 2 + emit(.symbol, pair) + return + } + } + position += 1 + let text = String(Character(first)) + switch first { + case "(", "[", "{": + emit(.symbol, text) + brackets.append(Character(first)) + case ")", "]", "}": + if !brackets.isEmpty { brackets.removeLast() } + emit(.symbol, text) + default: + emit(.symbol, text) + } + } + + mutating func readIdentifier() { + var text = String.UnicodeScalarView() + while let scalar = peek(), scalar == "_" || scalar.properties.isAlphabetic || ("0" ... "9").contains(scalar) { + text.append(scalar) + position += 1 + } + emit(.identifier, String(text)) + } + + // `1h30m` is one duration and `1.` one float, but `1then` is the number 1 and the keyword `then`. + mutating func readNumber() { + skipDigits() + if peek() == "." { + position += 1 + skipDigits() + } + while let unit = durationUnits.first(where: matches) { + position += unit.unicodeScalars.count + guard let next = peek(), ("0" ... "9").contains(next) else { break } + skipDigits() + } + emit(.number, "") + } + + private mutating func skipDigits() { + while let scalar = peek(), ("0" ... "9").contains(scalar) { position += 1 } + } + + private func matches(_ unit: String) -> Bool { + unit.unicodeScalars.enumerated().allSatisfy { offset, scalar in peek(offset) == scalar } + } + + // A Flux regex may run over several lines, and `\/` is an escaped slash even right after another backslash. + mutating func readRegex() { + position += 1 + while let scalar = peek() { + position += 1 + if scalar == "\\", peek() == "/" { + position += 1 + } else if scalar == "/" { + emit(.regex, "") + return + } + } + complete = false + emit(.regex, "") + } + + mutating func readString() { + let isMemberName = tokens.isSymbol("[", at: tokens.count - 1) && tokens.dropLast().last.map { token in + token.kind == .identifier || token.kind == .string + || (token.kind == .symbol && [")", "]", "}"].contains(token.text)) + } == true + position += 1 + var text = String.UnicodeScalarView() + var closed = false + while let scalar = peek() { + position += 1 + if scalar == "\\" { + if let escaped = peek() { + text.append(escaped) + position += 1 + } + } else if scalar == "\"" { + closed = true + break + } else if scalar == "$", peek() == "{" { + position += 1 + var inner = State(scalars) + inner.position = position + inner.brackets = brackets + ["{"] + inner.run(untilClosingBrace: true) + tokens += inner.tokens + position = inner.position + complete = complete && inner.complete + } else { + text.append(scalar) + } + } + complete = complete && closed + emit(.string, String(text)) + if isMemberName { + emit(.memberString, String(text)) + } + } + } +} + +// The server's own parse of a script, from `POST /api/v2/query/ast`, read with the same rules as `FluxScriptReading`. +// The lexer above picks regex or division from the token before; Flux picks it from the parser state, so the driver +// runs a script only when this reading finds nothing the lexer missed. +public enum FluxAST { + public static func reading(_ data: Data) throws -> FluxScriptReading { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let ast = root["ast"] else { + throw InfluxError.malformedResponse(InfluxResponseDecoder.preview(of: data)) + } + var walker = Walker() + walker.collectImports(ast) + walker.walk(ast, asMemberObject: false) + return FluxScriptReading( + writes: walker.writes || walker.unknownImport || walker.packageEscapes, + reachesOutside: walker.unknownImport || walker.packageEscapes || walker.outsideArgument + ) + } + + private struct Walker { + var packages: Set = [] + var unknownImport = false + var packageEscapes = false + var outsideArgument = false + var writes = false + + mutating func collectImports(_ node: Any) { + if let object = node as? [String: Any] { + if object["type"] as? String == "ImportDeclaration" { + let path = (object["path"] as? [String: Any])?["value"] as? String ?? "" + let alias = (object["as"] as? [String: Any])?["name"] as? String + packages.insert(alias ?? String(path.split(separator: "/").last ?? Substring(path))) + if !FluxScriptReading.readOnlyPackages.contains(path) { unknownImport = true } + return + } + object.values.forEach { collectImports($0) } + } else if let array = node as? [Any] { + array.forEach { collectImports($0) } + } + } + + mutating func walk(_ node: Any?, asMemberObject: Bool) { + if let array = node as? [Any] { + array.forEach { walk($0, asMemberObject: false) } + return + } + guard let object = node as? [String: Any] else { return } + switch object["type"] as? String { + case "ImportDeclaration": + return + case "Identifier": + let name = object["name"] as? String ?? "" + if FluxScriptReading.writingNames.contains(name) { writes = true } + if packages.contains(name), !asMemberObject { packageEscapes = true } + case "MemberExpression": + let target = object["object"] as? [String: Any] + let property = object["property"] as? [String: Any] + let isPackage = target?["type"] as? String == "Identifier" && packages.contains(target?["name"] as? String ?? "") + if isPackage, property?["type"] as? String != "Identifier" { packageEscapes = true } + walk(target, asMemberObject: true) + let member = property?["name"] as? String ?? property?["value"] as? String ?? "" + if FluxScriptReading.writingNames.contains(member) { writes = true } + if property?["type"] as? String != "Identifier", property?["type"] as? String != "StringLiteral" { + walk(property, asMemberObject: false) + } + case "Property": + let key = object["key"] as? [String: Any] + if let value = object["value"], !(value is NSNull) { + walk(value, asMemberObject: false) + } else { + walk(key, asMemberObject: false) + } + case "CallExpression": + for argument in object["arguments"] as? [[String: Any]] ?? [] { + for property in argument["properties"] as? [[String: Any]] ?? [] { + let key = property["key"] as? [String: Any] + let name = key?["name"] as? String ?? key?["value"] as? String ?? "" + if FluxScriptReading.outsideLabels.contains(name) { outsideArgument = true } + } + } + for (key, value) in object where key != "type" { + walk(value, asMemberObject: false) + } + default: + for (key, value) in object where key != "type" { + walk(value, asMemberObject: false) + } + } + } + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProInfluxDBCore/InfluxCSV.swift b/apple/Packages/TableProCore/Sources/TableProInfluxDBCore/InfluxCSV.swift new file mode 100644 index 0000000000..2d96cde84c --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProInfluxDBCore/InfluxCSV.swift @@ -0,0 +1,77 @@ +import Foundation + +// RFC 4180 over the raw bytes. An empty line is one record with one empty field: the null of a one-column result, and +// the separator between Flux tables. Every delimiter is ASCII, so a field is decoded on its own and bad UTF-8 in one +// value does not empty the whole grid. +enum InfluxCSV { + static func records(_ data: Data) -> [[String]] { + var reader = Reader() + data.withUnsafeBytes { reader.read($0) } + return reader.finish() + } + + private struct Reader { + var records: [[String]] = [] + var record: [String] = [] + var field: [UInt8] = [] + var inQuotes = false + var lineHasContent = false + + mutating func read(_ bytes: UnsafeRawBufferPointer) { + var index = 0 + while index < bytes.count { + let byte = bytes[index] + index += 1 + if inQuotes { + if byte == Self.quote, index < bytes.count, bytes[index] == Self.quote { + field.append(Self.quote) + index += 1 + } else if byte == Self.quote { + inQuotes = false + } else { + field.append(byte) + } + continue + } + switch byte { + case Self.quote: + inQuotes = true + lineHasContent = true + case Self.comma: + endField() + lineHasContent = true + case Self.carriageReturn: + if index < bytes.count, bytes[index] == Self.lineFeed { index += 1 } + endRecord() + case Self.lineFeed: + endRecord() + default: + field.append(byte) + lineHasContent = true + } + } + } + + mutating func finish() -> [[String]] { + if lineHasContent || !record.isEmpty { endRecord() } + return records + } + + private mutating func endField() { + record.append(String(decoding: field, as: UTF8.self)) // swiftlint:disable:this optional_data_string_conversion + field.removeAll(keepingCapacity: true) + } + + private mutating func endRecord() { + endField() + records.append(record) + record = [] + lineHasContent = false + } + + static let quote = UInt8(ascii: "\"") + static let comma = UInt8(ascii: ",") + static let carriageReturn = UInt8(ascii: "\r") + static let lineFeed = UInt8(ascii: "\n") + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProInfluxDBCore/InfluxError.swift b/apple/Packages/TableProCore/Sources/TableProInfluxDBCore/InfluxError.swift new file mode 100644 index 0000000000..2bbe7c7312 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProInfluxDBCore/InfluxError.swift @@ -0,0 +1,62 @@ +import Foundation + +public enum InfluxError: Error, Equatable, LocalizedError { + case notConnected + case invalidURL(String) + case unauthorized(String) + case server(status: Int, message: String) + case query(String) + case malformedResponse(String) + case missingDatabase + case missingOrganization + case filterNotExpressible(InfluxQueryLanguage) + case multipleStatements + case fluxReadDiffers + case unknownVersion + case unsupportedVersion(String) + case unsupportedLanguage(InfluxQueryLanguage, generation: InfluxGeneration) + + public var errorDescription: String? { + switch self { + case .notConnected: + return String(localized: "Not connected to InfluxDB.") + case .invalidURL(let text): + return String(format: String(localized: "Invalid InfluxDB address: %@"), text) + case .unauthorized(let message): + return message.isEmpty + ? String(localized: "InfluxDB refused the token.") + : String(format: String(localized: "InfluxDB refused the token: %@"), message) + case .server(let status, let message): + return message.isEmpty ? "HTTP \(status)" : "HTTP \(status): \(message)" + case .query(let message): + return message + case .malformedResponse(let detail): + return String(format: String(localized: "Unexpected InfluxDB response: %@"), detail) + case .missingDatabase: + return String(localized: "Choose a database or bucket first.") + case .missingOrganization: + return String(localized: "InfluxDB 2 needs an organization. Set it in the connection settings.") + case .filterNotExpressible(let language): + return String( + format: String(localized: "%@ cannot run this filter on that column."), + language.displayName + ) + case .multipleStatements: + return String(localized: "Run one InfluxQL statement at a time, with no ; inside it.") + case .fluxReadDiffers: + return String( + localized: "InfluxDB reads this Flux script as one that writes or reaches outside the database, which TablePro's check did not see, so it was not run." + ) + case .unknownVersion: + return String(localized: "The server did not say which InfluxDB version it runs.") + case .unsupportedVersion(let version): + return String(format: String(localized: "InfluxDB %@ is not supported. Connect to InfluxDB 2 or 3."), version) + case .unsupportedLanguage(let language, let generation): + return String( + format: String(localized: "%@ is not available on this server. It accepts %@."), + language.displayName, + generation.languages.map(\.displayName).joined(separator: ", ") + ) + } + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProInfluxDBCore/InfluxGridQuery.swift b/apple/Packages/TableProCore/Sources/TableProInfluxDBCore/InfluxGridQuery.swift new file mode 100644 index 0000000000..cdfa81e9cd --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProInfluxDBCore/InfluxGridQuery.swift @@ -0,0 +1,285 @@ +import Foundation + +public enum InfluxColumnKind: Sendable, Equatable, Hashable { + case time + case tag + case text + case number + case boolean + + var holdsText: Bool { self == .tag || self == .text } +} + +public struct InfluxFilter: Sendable, Equatable, Hashable { + 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 struct InfluxSort: Sendable, Equatable, Hashable { + public let column: String + public let ascending: Bool + + public init(column: String, ascending: Bool) { + self.column = column + self.ascending = ascending + } +} + +// The data grid's page, count or export of one measurement, written in SQL on 3.x and InfluxQL on 1.x and 2.x. The +// statement opens with its language marker, so it routes the same when it is copied into the editor. +public struct InfluxGridQuery: Sendable, Equatable, Hashable { + public enum Shape: Sendable, Equatable, Hashable { + case page(limit: Int, offset: Int) + case count + case all + } + + public let measurement: String + public let projection: [String] + public let filters: [InfluxFilter] + public let matchesAll: Bool + public let sorts: [InfluxSort] + public let shape: Shape + + public init( + measurement: String, + projection: [String] = [], + filters: [InfluxFilter] = [], + matchesAll: Bool = true, + sorts: [InfluxSort] = [], + shape: Shape + ) { + self.measurement = measurement + self.projection = projection + self.filters = filters + self.matchesAll = matchesAll + self.sorts = sorts + self.shape = shape + } + + // Nil when a filter has no meaning in the language for that column, rather than a statement that answers wrong. + // Unmarked, the statement is routed by its text: a driver asked before it knows the server version writes the + // InfluxQL form, which a 3.x server also reads as SQL for a page without filters. + public func statement( + language: InfluxQueryLanguage, + kinds: [String: InfluxColumnKind], + marked: Bool = true + ) -> String? { + var conditions: [String] = [] + for filter in filters { + guard let condition = Self.condition(for: filter, kind: kinds[filter.column], language: language) else { + return nil + } + conditions.append(condition) + } + let table = Self.quoteIdentifier(measurement, language: language) + var statement: String + switch shape { + case .count: + statement = "SELECT COUNT(*) FROM \(table)" + case .page, .all: + let columns = language == .sql && !projection.isEmpty + ? projection.map { Self.quoteIdentifier($0, language: language) }.joined(separator: ", ") + : "*" + statement = "SELECT \(columns) FROM \(table)" + } + if !conditions.isEmpty { + statement += " WHERE " + conditions.joined(separator: matchesAll ? " AND " : " OR ") + } + if case .page(let limit, let offset) = shape { + statement += " ORDER BY " + orderBy(language: language) + statement += " LIMIT \(max(limit, 0)) OFFSET \(max(offset, 0))" + } + return marked ? InfluxStatement.marked(statement, language: language) : statement + } + + // InfluxQL answers `COUNT(*)` with one `count_` per field, the point count only when there is one field. + public static func rowCount(from table: InfluxResultTable) -> Int? { + guard let row = table.rows.first else { return 0 } + return zip(table.columns, row).compactMap { column, value -> Int? in + guard column.lowercased().hasPrefix("count"), let value else { return nil } + return Int(value) + }.max() + } + + // InfluxQL orders by `time` only; another column's sort cannot be expressed and the page stays newest first. + private func orderBy(language: InfluxQueryLanguage) -> String { + let newestFirst = "time DESC" + switch language { + case .sql: + guard !sorts.isEmpty else { return newestFirst } + return sorts + .map { "\(Self.quoteIdentifier($0.column, language: language)) \($0.ascending ? "ASC" : "DESC")" } + .joined(separator: ", ") + case .influxQL, .flux: + guard let time = sorts.first(where: { $0.column == InfluxTypeName.timeColumn }) else { return newestFirst } + return time.ascending ? "time ASC" : newestFirst + } + } + + static func condition(for filter: InfluxFilter, kind: InfluxColumnKind?, language: InfluxQueryLanguage) -> String? { + let column = quoteIdentifier(filter.column, language: language) + let op = filter.op.uppercased() + switch op { + case "=", "!=", "<>", "<", ">", "<=", ">=": + return "\(column) \(op == "<>" ? "!=" : op) \(literal(filter.value, kind: kind, language: language))" + case "CONTAINS", "NOT CONTAINS", "STARTS WITH", "ENDS WITH", "REGEX": + guard kind?.holdsText ?? true else { return nil } + let escaped = escapedRegex(filter.value) + let pattern: String + switch op { + case "STARTS WITH": pattern = "^" + escaped + case "ENDS WITH": pattern = escaped + "$" + case "REGEX": pattern = filter.value + default: pattern = escaped + } + return regex(column, pattern, filter: filter, language: language, negated: op == "NOT CONTAINS") + case "IN", "NOT IN": + let values = filter.value.split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + guard !values.isEmpty else { return nil } + let negated = op == "NOT IN" + let literals = values.map { literal($0, kind: kind, language: language) } + if language == .sql { + return "\(column) \(negated ? "NOT IN" : "IN") (\(literals.joined(separator: ", ")))" + } + let parts = literals.map { "\(column) \(negated ? "!=" : "=") \($0)" } + return "(" + parts.joined(separator: negated ? " AND " : " OR ") + ")" + case "BETWEEN": + guard let upper = filter.secondValue else { return nil } + let low = literal(filter.value, kind: kind, language: language) + let high = literal(upper, kind: kind, language: language) + return "(\(column) >= \(low) AND \(column) <= \(high))" + case "IS NULL", "IS NOT NULL": + let negated = op == "IS NOT NULL" + if language == .sql { return "\(column) \(op)" } + // InfluxQL matches a missing tag with `= ''` and has no test at all for a missing field. + guard kind == nil || kind == .tag else { return nil } + return "\(column) \(negated ? "!=" : "=") ''" + case "IS EMPTY", "IS NOT EMPTY": + guard kind?.holdsText ?? true else { return nil } + return "\(column) \(op == "IS EMPTY" ? "=" : "!=") ''" + default: + return nil + } + } + + // The metacharacters RE2 and the Rust regex crate share. NSRegularExpression's escaper also escapes `/`, which + // InfluxQL's `/.../` literal would then escape twice. + static func escapedRegex(_ text: String) -> String { + var escaped = String.UnicodeScalarView() + for scalar in text.unicodeScalars { + if "\\^$.|?*+()[]{}".unicodeScalars.contains(scalar) { escaped.append("\\") } + escaped.append(scalar) + } + return String(escaped) + } + + private static func regex( + _ column: String, + _ pattern: String, + filter: InfluxFilter, + language: InfluxQueryLanguage, + negated: Bool + ) -> String { + let flagged = filter.isCaseSensitive ? pattern : "(?i)" + pattern + switch language { + case .sql: + return "\(column) \(negated ? "!~" : "~") \(stringLiteral(flagged, language: language))" + case .influxQL, .flux: + return "\(column) \(negated ? "!~" : "=~") /\(influxQLRegexBody(flagged))/" + } + } + + // InfluxQL reads `\/` as an escaped slash even after another backslash, so the body holds no `/`, no newline and + // no backslash before either or at its end: those, and every non-ASCII code point, go out as hex escapes. + static func influxQLRegexBody(_ pattern: String) -> String { + var body = "" + var scalars = pattern.unicodeScalars[...] + while let scalar = scalars.popFirst() { + // Inside `\Q...\E` a hex escape would be literal text, so the span is written out as escaped literals. + if scalar == "\\", scalars.first == "Q" { + scalars.removeFirst() + var quoted = String.UnicodeScalarView() + while let next = scalars.popFirst() { + if next == "\\", scalars.first == "E" { + scalars.removeFirst() + break + } + quoted.append(next) + } + body += influxQLRegexBody(escapedRegex(String(quoted))) + } else if scalar == "\\", let next = scalars.first, next == "/" || next == "\\" { + scalars.removeFirst() + body += "\\x{" + String(next.value, radix: 16) + "}" + } else if scalar == "\\", let next = scalars.first, next.isASCII, next.value >= 0x20, next.value < 0x7F { + scalars.removeFirst() + body += "\\" + String(next) + } else if scalar.isASCII, scalar.value >= 0x20, scalar.value < 0x7F, scalar != "/", scalar != "\\" { + body.unicodeScalars.append(scalar) + } else { + body += "\\x{" + String(scalar.value, radix: 16) + "}" + } + } + return body + } + + static func literal(_ value: String, kind: InfluxColumnKind?, language: InfluxQueryLanguage) -> String { + switch kind { + case .time, .tag, .text: + return stringLiteral(value, language: language) + case .number: + return isNumber(value) ? value : stringLiteral(value, language: language) + case .boolean: + let lowered = value.lowercased() + return lowered == "true" || lowered == "false" ? lowered : stringLiteral(value, language: language) + case nil: + if isNumber(value) || value == "true" || value == "false" { return value } + return stringLiteral(value, language: language) + } + } + + // `Double("1\u{0};--")` is 1, so a value goes out unquoted only when it is plain ASCII digits. + private static func isNumber(_ value: String) -> Bool { + value.range(of: #"^[+-]?(?:[0-9]+\.?[0-9]*|\.[0-9]+)$"#, options: .regularExpression) != nil + } + + // Escaped per code point: a quote followed by a combining mark is one Character, which a Character-wise replace skips. + public static func quoteIdentifier(_ name: String, language: InfluxQueryLanguage) -> String { + switch language { + case .sql: return "\"" + escaping(name, ["\"": "\"\""]) + "\"" + case .influxQL, .flux: return "\"" + escaping(name, ["\\": "\\\\", "\"": "\\\""]) + "\"" + } + } + + public static func stringLiteral(_ value: String, language: InfluxQueryLanguage) -> String { + switch language { + case .sql: return "'" + escaping(value, ["'": "''"]) + "'" + case .influxQL, .flux: return "'" + escaping(value, ["\\": "\\\\", "'": "\\'"]) + "'" + } + } + + private static func escaping(_ text: String, _ replacements: [Unicode.Scalar: String]) -> String { + var escaped = "" + for scalar in text.unicodeScalars { + if let replacement = replacements[scalar] { + escaped += replacement + } else { + escaped.unicodeScalars.append(scalar) + } + } + return escaped + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProInfluxDBCore/InfluxQueryLanguage.swift b/apple/Packages/TableProCore/Sources/TableProInfluxDBCore/InfluxQueryLanguage.swift new file mode 100644 index 0000000000..1e8e094898 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProInfluxDBCore/InfluxQueryLanguage.swift @@ -0,0 +1,155 @@ +import Foundation + +public enum InfluxQueryLanguage: String, Sendable, Equatable, Hashable, CaseIterable { + case sql + case influxQL = "influxql" + case flux + + public var displayName: String { + switch self { + case .sql: return "SQL" + case .influxQL: return "InfluxQL" + case .flux: return "Flux" + } + } +} + +public struct InfluxStatement: Sendable, Equatable { + public let language: InfluxQueryLanguage + public let text: String + + public init(language: InfluxQueryLanguage, text: String) { + self.language = language + self.text = text + } + + // The marker is a TablePro directive, so it is blanked rather than removed: server error positions keep their line. + public static func route(_ statement: String, generation: InfluxGeneration) throws -> InfluxStatement { + let parsed = InfluxMarkedText(statement) + let language = parsed.marker ?? guess(parsed.body, generation: generation) + guard generation.languages.contains(language) else { + throw InfluxError.unsupportedLanguage(language, generation: generation) + } + if language == .influxQL, !InfluxQLText.isSingleStatement(parsed.body) { + throw InfluxError.multipleStatements + } + return InfluxStatement(language: language, text: parsed.body) + } + + public static func marked(_ statement: String, language: InfluxQueryLanguage) -> String { + "-- \(language.rawValue)\n" + statement + } + + public static func isFlux(_ statement: String) -> Bool { + let parsed = InfluxMarkedText(statement) + if let marker = parsed.marker { return marker == .flux } + return looksLikeFlux(parsed.body) + } + + public static func fluxBody(of statement: String) -> String { + InfluxMarkedText(statement).body + } + + static func guess(_ text: String, generation: InfluxGeneration) -> InfluxQueryLanguage { + if looksLikeFlux(text) { return .flux } + switch generation { + case .v2: return .influxQL + case .v3: return looksLikeInfluxQLOnly(text) ? .influxQL : .sql + } + } + + static func looksLikeFlux(_ text: String) -> Bool { + let code = InfluxNeutralCode.code(of: text) + if code.contains("|>") { return true } + return code.range(of: #"^(?:(?:from|buckets)\s*\(|(?:import|option)\s)"#, options: .regularExpression) != nil + } + + private static let influxQLOnlyPrefixes = [ + "SHOW MEASUREMENT", "SHOW TAG", "SHOW FIELD", "SHOW SERIES", "SHOW RETENTION", "SHOW DATABASES", + ] + + // DataFusion SQL rejects these; InfluxQL reads them. + static func looksLikeInfluxQLOnly(_ text: String) -> Bool { + let normalized = InfluxNeutralCode.code(of: text).uppercased() + .split(whereSeparator: \.isWhitespace) + .joined(separator: " ") + if influxQLOnlyPrefixes.contains(where: normalized.hasPrefix) { return true } + if normalized.range(of: #"GROUP BY .*\bTIME\s*\("#, options: .regularExpression) != nil { return true } + return normalized.range(of: #"NOW\(\)\s*[-+]\s*\d+(NS|U|µ|MS|S|M|H|D|W)\b"#, options: .regularExpression) != nil + } +} + +struct InfluxMarkedText { + let marker: InfluxQueryLanguage? + let body: String + + init(_ statement: String) { + var lines = statement.components(separatedBy: "\n") + guard let index = lines.firstIndex(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }), + let language = Self.marker(in: lines[index]) + else { + marker = nil + body = statement + return + } + lines[index] = "" + marker = language + body = lines.joined(separator: "\n") + } + + private static func marker(in line: String) -> InfluxQueryLanguage? { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("--") || trimmed.hasPrefix("//") else { return nil } + let word = trimmed.dropFirst(2).trimmingCharacters(in: .whitespaces).lowercased() + return InfluxQueryLanguage(rawValue: word) + } +} + +// Comments dropped and quoted text and regex literals blanked, so a `|>` or `SHOW` inside any of them routes nothing. +// A comment or a regex goes before quotes count, since `// user's range` or `/'/` would otherwise open one. +enum InfluxNeutralCode { + static func code(of text: String) -> String { + let characters = Array(text) + var output = "" + var index = 0 + var lastCode: Character? + while index < characters.count { + let character = characters[index] + let next = index + 1 < characters.count ? characters[index + 1] : nil + if character == "-" || character == "/", next == character { + while index < characters.count, characters[index] != "\n" { index += 1 } + continue + } + let opensRegex = character == "/" && (lastCode.map { "=~(,:[{>".contains($0) } ?? true) + guard character == "'" || character == "\"" || opensRegex else { + output.append(character) + if !character.isWhitespace { lastCode = character } + index += 1 + continue + } + output.append(character) + index += 1 + while index < characters.count, characters[index] != character, !(opensRegex && characters[index] == "\n") { + index += characters[index] == "\\" ? 2 : 1 + } + if index < characters.count, characters[index] == character { + output.append(character) + index += 1 + } + lastCode = character + } + return output.trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +// `/query` runs every statement in the text, and telling a separator from text needs InfluxQL's parser, which picks +// regex or division from its state. So any `;` before the end is refused, inside a literal or a comment too. +enum InfluxQLText { + static func isSingleStatement(_ text: String) -> Bool { + var scalars = text.unicodeScalars[...] + while let last = scalars.last, last == ";" || last.properties.isWhitespace { + scalars.removeLast() + } + return !scalars.contains(";") + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProInfluxDBCore/InfluxRequest.swift b/apple/Packages/TableProCore/Sources/TableProInfluxDBCore/InfluxRequest.swift new file mode 100644 index 0000000000..e70ee42d85 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProInfluxDBCore/InfluxRequest.swift @@ -0,0 +1,271 @@ +import Foundation + +public struct InfluxRequest: Sendable, Equatable { + public let method: String + public let path: String + public let query: [InfluxQueryItem] + public let headers: [String: String] + public let body: Data? + + public init( + method: String, + path: String, + query: [InfluxQueryItem] = [], + headers: [String: String] = [:], + body: Data? = nil + ) { + self.method = method + self.path = path + self.query = query + self.headers = headers + self.body = body + } + + public func url(base: URL) -> URL? { + guard var components = URLComponents(url: base, resolvingAgainstBaseURL: false) else { return nil } + let basePath = components.path.hasSuffix("/") ? String(components.path.dropLast()) : components.path + components.path = basePath + path + components.percentEncodedQuery = query.isEmpty ? nil : InfluxFormEncoding.encode(query) + return components.url + } +} + +public struct InfluxQueryItem: Sendable, Equatable { + public let name: String + public let value: String + + public init(_ name: String, _ value: String) { + self.name = name + self.value = value + } +} + +// Go's `url.ParseQuery`, which every InfluxDB line reads forms with, drops a pair holding a raw `;` and reads `+` as a +// space, and `URLComponents` leaves both as they are. Only RFC 3986's unreserved characters go out unencoded. +public enum InfluxFormEncoding { + private static let unreserved = CharacterSet( + charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + ) + + public static func encode(_ items: [InfluxQueryItem]) -> String { + items.map { escape($0.name) + "=" + escape($0.value) }.joined(separator: "&") + } + + static func escape(_ text: String) -> String { + text.addingPercentEncoding(withAllowedCharacters: unreserved) ?? "" + } +} + +public enum InfluxCredentials: Sendable, Equatable { + case none + case token(String) + + public init(token: String?) { + let token = (token ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + self = token.isEmpty ? .none : .token(token) + } + + // 2.x documents `Token`, 3.x documents `Bearer`; each accepts both, but a proxy in front passes the documented one. + func authorization(for generation: InfluxGeneration?) -> String? { + switch self { + case .none: return nil + case .token(let token): return generation == .v3 ? "Bearer \(token)" : "Token \(token)" + } + } +} + +public enum InfluxRequests { + public static let bucketPageSize = 100 + + // 3.x with auth on refuses an anonymous `/ping`. Before the version is known the token goes out as `Token`. + public static func ping(credentials: InfluxCredentials, generation: InfluxGeneration? = nil) -> InfluxRequest { + InfluxRequest(method: "GET", path: "/ping", headers: authHeaders(credentials, generation: generation)) + } + + // 2.x's `/ping` never checks a token; this is its cheapest request that does, and it proves the organization too. + public static func v2TokenCheck(organization: String, credentials: InfluxCredentials) -> InfluxRequest { + InfluxRequest( + method: "GET", + path: "/api/v2/buckets", + query: [InfluxQueryItem("org", organization), InfluxQueryItem("limit", "1")], + headers: authHeaders(credentials, generation: .v2) + ) + } + + public static func v3Databases(credentials: InfluxCredentials) -> InfluxRequest { + InfluxRequest( + method: "GET", + path: "/api/v3/configure/database", + query: [InfluxQueryItem("format", "json")], + headers: authHeaders(credentials, generation: .v3) + ) + } + + public static func v2Buckets(organization: String, credentials: InfluxCredentials, offset: Int) -> InfluxRequest { + InfluxRequest( + method: "GET", + path: "/api/v2/buckets", + query: [ + InfluxQueryItem("org", organization), + InfluxQueryItem("limit", String(bucketPageSize)), + InfluxQueryItem("offset", String(offset)), + ], + headers: authHeaders(credentials, generation: .v2) + ) + } + + public static func v2Bucket(named name: String, organization: String, credentials: InfluxCredentials) -> InfluxRequest { + InfluxRequest( + method: "GET", + path: "/api/v2/buckets", + query: [InfluxQueryItem("org", organization), InfluxQueryItem("name", name)], + headers: authHeaders(credentials, generation: .v2) + ) + } + + public static func v2DBRPs(bucketID: String, organization: String, credentials: InfluxCredentials) -> InfluxRequest { + InfluxRequest( + method: "GET", + path: "/api/v2/dbrps", + query: [InfluxQueryItem("org", organization), InfluxQueryItem("bucketID", bucketID)], + headers: authHeaders(credentials, generation: .v2) + ) + } + + public static func v2FluxAST(_ script: String, organization: String, credentials: InfluxCredentials) throws -> InfluxRequest { + var headers = authHeaders(credentials, generation: .v2) + headers["Content-Type"] = "application/json" + return InfluxRequest( + method: "POST", + path: "/api/v2/query/ast", + query: [InfluxQueryItem("org", organization)], + headers: headers, + body: try JSONSerialization.data(withJSONObject: ["query": script], options: [.sortedKeys]) + ) + } + + public static func v2Organizations(credentials: InfluxCredentials) -> InfluxRequest { + InfluxRequest(method: "GET", path: "/api/v2/orgs", headers: authHeaders(credentials, generation: .v2)) + } + + public static func query( + _ statement: InfluxStatement, + database: String, + retentionPolicy: String? = nil, + organization: String, + generation: InfluxGeneration, + credentials: InfluxCredentials + ) throws -> InfluxRequest { + var headers = authHeaders(credentials, generation: generation) + switch (generation, statement.language) { + case (.v3, .sql), (.v3, .influxQL): + headers["Content-Type"] = "application/json" + headers["Accept"] = "text/csv" + var payload = ["q": statement.text, "format": "csv"] + if !database.isEmpty { payload["db"] = database } + return InfluxRequest( + method: "POST", + path: statement.language == .sql ? "/api/v3/query_sql" : "/api/v3/query_influxql", + headers: headers, + body: try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) + ) + case (.v2, .flux): + headers["Content-Type"] = "application/json" + headers["Accept"] = "application/csv" + let payload: [String: Any] = [ + "query": statement.text, + "type": "flux", + "dialect": ["annotations": ["datatype"], "header": true], + ] + return InfluxRequest( + method: "POST", + path: "/api/v2/query", + query: [InfluxQueryItem("org", organization)], + headers: headers, + body: try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) + ) + case (.v2, .influxQL): + headers["Content-Type"] = "application/x-www-form-urlencoded" + headers["Accept"] = "application/json" + var form = [InfluxQueryItem("q", statement.text)] + if !database.isEmpty { form.append(InfluxQueryItem("db", database)) } + if let retentionPolicy, !retentionPolicy.isEmpty { form.append(InfluxQueryItem("rp", retentionPolicy)) } + return InfluxRequest( + method: "POST", + path: "/query", + headers: headers, + body: Data(InfluxFormEncoding.encode(form).utf8) + ) + case (.v3, .flux), (.v2, .sql): + throw InfluxError.unsupportedLanguage(statement.language, generation: generation) + } + } + + static func authHeaders(_ credentials: InfluxCredentials, generation: InfluxGeneration?) -> [String: String] { + guard let value = credentials.authorization(for: generation) else { return [:] } + return ["Authorization": value] + } +} + +public struct InfluxQLTarget: Sendable, Equatable { + public let database: String + public let retentionPolicy: String? + public let isDefault: Bool + + public init(database: String, retentionPolicy: String?, isDefault: Bool = true) { + self.database = database + self.retentionPolicy = retentionPolicy + self.isDefault = isDefault + } +} + +public enum InfluxCatalogDecoder { + public static let systemDatabaseNames = ["_internal", "_monitoring", "_tasks"] + + // `[{"iox::database": "metrics"}]` + public static func v3Databases(_ data: Data) throws -> [String] { + guard let rows = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { + throw InfluxError.malformedResponse(InfluxResponseDecoder.preview(of: data)) + } + return rows.compactMap { $0["iox::database"] as? String } + } + + public static func v2Organizations(_ data: Data) throws -> [String] { + try objects(in: data, key: "orgs").compactMap { $0["name"] as? String } + } + + public static func v2BucketNames(_ data: Data) throws -> [String] { + try objects(in: data, key: "buckets").compactMap { $0["name"] as? String } + } + + public static func v2BucketID(_ data: Data) throws -> String? { + try objects(in: data, key: "buckets").first?["id"] as? String + } + + // InfluxQL names a bucket by its DBRP mapping, not its name: `telegraf/weekly` is `db=telegraf&rp=weekly`, and a + // mapping someone made may name another database. A default mapping wins, then a made one over the virtual one. + public static func v2InfluxQLTarget(_ data: Data) throws -> InfluxQLTarget? { + func rank(_ mapping: [String: Any]) -> Int { + (mapping["default"] as? Bool == true ? 2 : 0) + (mapping["virtual"] as? Bool == true ? 0 : 1) + } + guard let best = try objects(in: data, key: "content") + .filter({ $0["database"] is String }) + .max(by: { rank($0) < rank($1) }), + let database = best["database"] as? String + else { return nil } + return InfluxQLTarget( + database: database, + retentionPolicy: best["retention_policy"] as? String, + isDefault: best["default"] as? Bool ?? false + ) + } + + private static func objects(in data: Data, key: String) throws -> [[String: Any]] { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let objects = root[key] as? [[String: Any]] + else { + throw InfluxError.malformedResponse(InfluxResponseDecoder.preview(of: data)) + } + return objects + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProInfluxDBCore/InfluxResponseDecoder.swift b/apple/Packages/TableProCore/Sources/TableProInfluxDBCore/InfluxResponseDecoder.swift new file mode 100644 index 0000000000..cf73e40e3f --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProInfluxDBCore/InfluxResponseDecoder.swift @@ -0,0 +1,221 @@ +import Foundation + +public enum InfluxResponseDecoder { + // 3.x CSV keeps the statement's column order; its JSON drops null columns. CSV has no null, so an empty cell is one. + public static func decodeV3CSV(_ data: Data) -> InfluxResultTable { + var records = InfluxCSV.records(data)[...] + guard let columns = records.popFirst() else { return .empty } + let rows: [[String?]] = records.map { record in + columns.indices.map { index in + guard index < record.count, !record[index].isEmpty else { return nil } + return record[index] + } + } + let typeNames = columns.indices.map { index in + InfluxTypeName.inferred(column: columns[index], values: rows.map { $0[index] }) + } + let timestampColumns = typeNames.indices.filter { typeNames[$0] == InfluxTypeName.timestamp } + let normalized = timestampColumns.isEmpty ? rows : rows.map { row in + var row = row + for index in timestampColumns { + row[index] = row[index].map(InfluxTypeName.normalizedTimestamp) + } + return row + } + return InfluxResultTable(columns: columns, typeNames: typeNames, rows: normalized) + } + + // InfluxQL over `/query` (1.x and the 2.x compatibility API). Every series of every statement lands in one grid: a + // GROUP BY tag set becomes leading tag columns, and a `name` column comes first when the series name varies. + public static func decodeV1JSON(_ data: Data) throws -> InfluxResultTable { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw InfluxError.malformedResponse(preview(of: data)) + } + if let error = root["error"] as? String { + throw InfluxError.query(error) + } + let results = root["results"] as? [[String: Any]] ?? [] + if let error = results.lazy.compactMap({ $0["error"] as? String }).first { + throw InfluxError.query(error) + } + let series = results.flatMap { $0["series"] as? [[String: Any]] ?? [] } + guard !series.isEmpty else { return .empty } + + let includesName = Set(series.compactMap { $0["name"] as? String }).count > 1 + var tagKeys: [String] = [] + var valueColumns: [String] = [] + var seen: Set = [] + for item in series { + for key in (item["tags"] as? [String: Any] ?? [:]).keys.sorted() where seen.insert("t:" + key).inserted { + tagKeys.append(key) + } + } + let tagSet = Set(tagKeys) + for item in series { + for column in item["columns"] as? [String] ?? [] where !tagSet.contains(column) { + if seen.insert("v:" + column).inserted { valueColumns.append(column) } + } + } + // `SHOW MEASUREMENTS; SHOW DATABASES` names both series and both value columns `name`. + let taken = tagSet.union(valueColumns) + let seriesColumn = ["name", "series"].first { !taken.contains($0) } ?? "series name" + let columns = (includesName ? [seriesColumn] : []) + tagKeys + valueColumns + let offset = includesName ? 1 : 0 + let position = Dictionary( + (tagKeys + valueColumns).enumerated().map { ($1, $0 + offset) }, + uniquingKeysWith: { first, _ in first } + ) + let tagRange = offset ..< offset + tagKeys.count + + var rows: [[String?]] = [] + var jsonTypes = [String?](repeating: nil, count: columns.count) + for item in series { + let targets = (item["columns"] as? [String] ?? []).map { position[$0] } + let tags = item["tags"] as? [String: Any] ?? [:] + let tagValues = tagKeys.map { text(tags[$0]) } + for values in item["values"] as? [[Any]] ?? [] { + var row = [String?](repeating: nil, count: columns.count) + if includesName { row[0] = item["name"] as? String } + for (index, value) in tagValues.enumerated() { + row[offset + index] = value + } + for (index, target) in targets.enumerated() where index < values.count { + guard let target else { continue } + row[target] = text(values[index]) + if jsonTypes[target] == nil { jsonTypes[target] = typeName(of: values[index]) } + } + rows.append(row) + } + } + let typeNames = columns.indices.map { index -> String in + if columns[index] == InfluxTypeName.timeColumn { return InfluxTypeName.timestamp } + if tagRange.contains(index) { return InfluxTypeName.tag } + return jsonTypes[index] ?? InfluxTypeName.string + } + return InfluxResultTable(columns: columns, typeNames: typeNames, rows: rows) + } + + // Flux annotated CSV: each table brings its own header. The grid gets the union of headers in first-seen order, + // without the empty annotation column and `result`. + public static func decodeFluxCSV(_ data: Data) throws -> InfluxResultTable { + var columns: [String] = [] + var typeNames: [String] = [] + var position: [String: Int] = [:] + var rows: [[String?]] = [] + + var datatypes: [String] = [] + var header: [String]? + var targets: [Int?] = [] + for record in InfluxCSV.records(data) { + if record == [""] { + datatypes = [] + header = nil + continue + } + if let first = record.first, first.hasPrefix("#") { + if first == "#datatype" { datatypes = record } + continue + } + guard let currentHeader = header else { + header = record + if isFluxErrorHeader(record) { + targets = [] + continue + } + targets = record.indices.map { index -> Int? in + let column = record[index] + guard keepsFluxColumn(column, index: index) else { return nil } + if let existing = position[column] { return existing } + position[column] = columns.count + columns.append(column) + typeNames.append(fluxTypeName(index < datatypes.count ? datatypes[index] : "")) + return columns.count - 1 + } + continue + } + if isFluxErrorHeader(currentHeader) { + throw InfluxError.query(record.count > 1 ? record[1] : "Flux error") + } + var row = [String?](repeating: nil, count: columns.count) + for (index, target) in targets.enumerated() where index < record.count { + guard let target else { continue } + row[target] = record[index].isEmpty ? nil : record[index] + } + rows.append(row) + } + for index in rows.indices where rows[index].count < columns.count { + rows[index] += [String?](repeating: nil, count: columns.count - rows[index].count) + } + return InfluxResultTable(columns: columns, typeNames: typeNames, rows: rows) + } + + private static func isFluxErrorHeader(_ header: [String]) -> Bool { + header.dropFirst().elementsEqual(["error", "reference"]) + } + + private static func keepsFluxColumn(_ column: String, index: Int) -> Bool { + !(index == 0 && column.isEmpty) && column != "result" + } + + static func fluxTypeName(_ annotation: String) -> String { + switch annotation { + case "dateTime:RFC3339", "dateTime:RFC3339Nano": return InfluxTypeName.timestamp + case "long": return InfluxTypeName.integer + case "unsignedLong": return InfluxTypeName.unsigned + case "double": return InfluxTypeName.float + case "boolean": return InfluxTypeName.boolean + case "duration": return InfluxTypeName.duration + case "base64Binary": return InfluxTypeName.binary + default: return InfluxTypeName.string + } + } + + // `{"message": ...}` on 2.x, `{"error": ...}` on 1.x and some 3.x paths, plain text on others. + public static func errorMessage(from data: Data) -> String { + if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + for key in ["message", "error", "data"] { + if let message = object[key] as? String, !message.isEmpty { return message } + } + } + let text = String(decoding: data.prefix(2_000), as: UTF8.self) // swiftlint:disable:this optional_data_string_conversion + return text.trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func preview(of data: Data) -> String { + String(decoding: data.prefix(200), as: UTF8.self) // swiftlint:disable:this optional_data_string_conversion + } + + static func text(_ value: Any?) -> String? { + switch value { + case nil, is NSNull: + return nil + case let string as String: + return string + case let number as NSNumber: + if CFGetTypeID(number) == CFBooleanGetTypeID() { + return number.boolValue ? "true" : "false" + } + return number.stringValue + case let other?: + if JSONSerialization.isValidJSONObject(other), + let data = try? JSONSerialization.data(withJSONObject: other, options: [.sortedKeys]) { + return String(bytes: data, encoding: .utf8) + } + return "\(other)" + } + } + + static func typeName(of value: Any) -> String? { + switch value { + case is NSNull: + return nil + case is String: + return InfluxTypeName.string + case let number as NSNumber: + if CFGetTypeID(number) == CFBooleanGetTypeID() { return InfluxTypeName.boolean } + return CFNumberIsFloatType(number) ? InfluxTypeName.float : InfluxTypeName.integer + default: + return InfluxTypeName.string + } + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProInfluxDBCore/InfluxResultTable.swift b/apple/Packages/TableProCore/Sources/TableProInfluxDBCore/InfluxResultTable.swift new file mode 100644 index 0000000000..b9881fe09b --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProInfluxDBCore/InfluxResultTable.swift @@ -0,0 +1,75 @@ +import Foundation + +public struct InfluxResultTable: Sendable, Equatable { + public var columns: [String] + public var typeNames: [String] + public var rows: [[String?]] + + public init(columns: [String], typeNames: [String], rows: [[String?]]) { + self.columns = columns + self.typeNames = typeNames + self.rows = rows + } + + public static let empty = InfluxResultTable(columns: [], typeNames: [], rows: []) + + public func column(named name: String) -> [String?] { + guard let index = columns.firstIndex(of: name) else { return [] } + return rows.map { index < $0.count ? $0[index] : nil } + } + + // A grid query's columns typed from the measurement's schema rather than from the values on one page. + public func typed(by schema: [InfluxColumn]) -> InfluxResultTable { + let byName = Dictionary(schema.map { ($0.name, $0.typeName) }, uniquingKeysWith: { first, _ in first }) + var table = self + table.typeNames = zip(columns, typeNames).map { column, inferred in byName[column] ?? inferred } + return table + } +} + +public enum InfluxTypeName { + public static let timestamp = "timestamp" + public static let tag = "tag" + public static let float = "float" + public static let integer = "integer" + public static let unsigned = "unsigned" + public static let string = "string" + public static let boolean = "boolean" + public static let duration = "duration" + public static let binary = "binary" + + public static let timeColumn = "time" + + // The SQL spelling the app classifies a column by, so a tag reads as text and a field as its number kind. + public static func classificationName(for typeName: String) -> String { + switch typeName { + case timestamp: return "TIMESTAMP" + case float: return "DOUBLE" + case integer: return "BIGINT" + case unsigned: return "UBIGINT" + case boolean: return "BOOLEAN" + case binary: return "BLOB" + default: return "TEXT" + } + } + + // The 3.x CSV carries no types, so a user query's columns are named by what every value parses as. + static func inferred(column: String, values: [String?]) -> String { + if column == timeColumn { return timestamp } + let present = values.compactMap { $0 } + guard !present.isEmpty else { return string } + if present.allSatisfy({ Int64($0) != nil }) { return integer } + if present.allSatisfy({ UInt64($0) != nil }) { return unsigned } + if present.allSatisfy({ Double($0) != nil }) { return float } + if present.allSatisfy({ $0 == "true" || $0 == "false" }) { return boolean } + return string + } + + // 3.x writes a UTC timestamp without a zone; the `Z` makes it match 2.x and parse back as RFC 3339 in a filter. + static func normalizedTimestamp(_ value: String) -> String { + guard value.count >= 19, value.dropFirst(10).first == "T" else { return value } + let time = value.dropFirst(11) + if time.contains("Z") || time.contains("+") || time.contains("-") { return value } + return value + "Z" + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProInfluxDBCore/InfluxSchema.swift b/apple/Packages/TableProCore/Sources/TableProInfluxDBCore/InfluxSchema.swift new file mode 100644 index 0000000000..e773188a50 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProInfluxDBCore/InfluxSchema.swift @@ -0,0 +1,190 @@ +import Foundation + +public struct InfluxColumn: Sendable, Equatable, Hashable { + public enum Role: Sendable, Equatable, Hashable { + case time + case tag + case field + } + + public let name: String + public let typeName: String + public let role: Role + + public init(name: String, typeName: String, role: Role) { + self.name = name + self.typeName = typeName + self.role = role + } + + public var kind: InfluxColumnKind { + switch role { + case .time: return .time + case .tag: return .tag + case .field: + switch typeName { + case InfluxTypeName.float, InfluxTypeName.integer, InfluxTypeName.unsigned: return .number + case InfluxTypeName.boolean: return .boolean + default: return .text + } + } + } +} + +// Columns come back with `time` first and the rest by name, the order InfluxQL's `SELECT *` uses. +public enum InfluxSchema { + // 2.x lists a mapping that is not its database's default only with `ON "db"."rp"`. + public static func measurementsStatement(generation: InfluxGeneration, target: InfluxQLTarget? = nil) -> InfluxStatement { + switch generation { + case .v3: + return InfluxStatement( + language: .sql, + text: "SELECT table_name FROM information_schema.tables WHERE table_schema = 'iox' ORDER BY table_name" + ) + case .v2: + guard let target, let policy = target.retentionPolicy, !policy.isEmpty else { + return InfluxStatement(language: .influxQL, text: "SHOW MEASUREMENTS") + } + return InfluxStatement( + language: .influxQL, + text: "SHOW MEASUREMENTS ON \(quoted(target.database)).\(quoted(policy))" + ) + } + } + + public static func measurements(from table: InfluxResultTable, generation: InfluxGeneration) -> [String] { + table.column(named: generation == .v3 ? "table_name" : "name").compactMap { $0 } + } + + public static func v3ColumnsStatement(measurement: String) -> InfluxStatement { + let literal = InfluxGridQuery.stringLiteral(measurement, language: .sql) + return InfluxStatement( + language: .sql, + text: "SELECT column_name, data_type FROM information_schema.columns " + + "WHERE table_schema = 'iox' AND table_name = \(literal)" + ) + } + + public static func v3Columns(from table: InfluxResultTable) -> [InfluxColumn] { + let columns = zip(table.column(named: "column_name"), table.column(named: "data_type")) + .compactMap { name, arrowType -> InfluxColumn? in + guard let name else { return nil } + return column(name: name, arrowType: arrowType ?? "") + } + return ordered(columns) + } + + // 2.x answers `SHOW TAG KEYS` only for a database with a default retention policy; for another mapping the tags + // are read off one point's columns instead. + public static func influxQLTagKeysStatement(measurement: String, target: InfluxQLTarget? = nil) -> InfluxStatement { + let source = qualified(measurement, target: target) + if let target, !target.isDefault { + return InfluxStatement(language: .influxQL, text: "SELECT * FROM \(source) LIMIT 1") + } + return InfluxStatement(language: .influxQL, text: "SHOW TAG KEYS FROM \(source)") + } + + public static func influxQLFieldKeysStatement(measurement: String, target: InfluxQLTarget? = nil) -> InfluxStatement { + InfluxStatement(language: .influxQL, text: "SHOW FIELD KEYS FROM \(qualified(measurement, target: target))") + } + + private static func qualified(_ measurement: String, target: InfluxQLTarget?) -> String { + guard let policy = target?.retentionPolicy, !policy.isEmpty else { return quoted(measurement) } + return quoted(policy) + "." + quoted(measurement) + } + + private static func quoted(_ name: String) -> String { + InfluxGridQuery.quoteIdentifier(name, language: .influxQL) + } + + // `tagKeys` is the answer to `influxQLTagKeysStatement`: `SHOW TAG KEYS` rows, or one point whose columns hold + // every tag and field. + public static func influxQLColumns(tagKeys: InfluxResultTable, fieldKeys: InfluxResultTable) -> [InfluxColumn] { + let fieldNames = Set(fieldKeys.column(named: "fieldKey").compactMap { $0 }) + let tags = tagKeys.columns == ["tagKey"] + ? tagKeys.column(named: "tagKey").compactMap { $0 } + : tagKeys.columns.filter { $0 != InfluxTypeName.timeColumn && !fieldNames.contains($0) } + var columns = [InfluxColumn(name: InfluxTypeName.timeColumn, typeName: InfluxTypeName.timestamp, role: .time)] + var names: Set = [InfluxTypeName.timeColumn] + for tag in tags where names.insert(tag).inserted { + columns.append(InfluxColumn(name: tag, typeName: InfluxTypeName.tag, role: .tag)) + } + for (name, type) in zip(fieldKeys.column(named: "fieldKey"), fieldKeys.column(named: "fieldType")) { + guard let name, names.insert(name).inserted else { continue } + columns.append(InfluxColumn(name: name, typeName: type ?? InfluxTypeName.string, role: .field)) + } + return ordered(columns) + } + + static func column(name: String, arrowType: String) -> InfluxColumn { + if arrowType.hasPrefix("Timestamp") { + return InfluxColumn(name: name, typeName: InfluxTypeName.timestamp, role: .time) + } + if arrowType.hasPrefix("Dictionary") { + return InfluxColumn(name: name, typeName: InfluxTypeName.tag, role: .tag) + } + let typeName: String + switch arrowType { + case "Float64", "Float32": typeName = InfluxTypeName.float + case "Int64", "Int32": typeName = InfluxTypeName.integer + case "UInt64", "UInt32": typeName = InfluxTypeName.unsigned + case "Boolean": typeName = InfluxTypeName.boolean + default: typeName = InfluxTypeName.string + } + return InfluxColumn(name: name, typeName: typeName, role: .field) + } + + static func ordered(_ columns: [InfluxColumn]) -> [InfluxColumn] { + let time = columns.filter { $0.name == InfluxTypeName.timeColumn } + let rest = columns.filter { $0.name != InfluxTypeName.timeColumn }.sorted { $0.name < $1.name } + return time + rest + } +} + +public enum InfluxConnectionFieldID { + public static let token = "influxToken" + public static let organization = "influxOrg" + public static let skipTLSVerify = "influxSkipTLSVerify" +} + +// Shared by the plugin and the app's built-in metadata, which the editor reads before the plugin loads. +public enum InfluxCompletions { + public static let entries: [(label: String, insertText: String)] = [ + ("SHOW MEASUREMENTS", "SHOW MEASUREMENTS"), + ("SHOW TAG KEYS", "SHOW TAG KEYS FROM \"measurement\""), + ("SHOW FIELD KEYS", "SHOW FIELD KEYS FROM \"measurement\""), + ("SQL: last hour", "SELECT * FROM \"measurement\" WHERE time >= now() - INTERVAL '1 hour' ORDER BY time DESC"), + ( + "SQL: 5 minute averages", + """ + SELECT date_bin(INTERVAL '5 minutes', time) AS bucket, avg("field") + FROM "measurement" + WHERE time >= now() - INTERVAL '1 hour' + GROUP BY bucket + ORDER BY bucket + """ + ), + ( + "InfluxQL: 5 minute averages", + "SELECT MEAN(\"field\") FROM \"measurement\" WHERE time > now() - 1h GROUP BY time(5m)" + ), + ( + "Flux: last hour", + """ + from(bucket: "bucket") + |> range(start: -1h) + |> filter(fn: (r) => r._measurement == "measurement") + |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value") + """ + ), + ] + + public static let columnTypesByCategory: [String: [String]] = [ + "Time": [InfluxTypeName.timestamp], + "Tag": [InfluxTypeName.tag], + "Field": [ + InfluxTypeName.float, InfluxTypeName.integer, InfluxTypeName.unsigned, + InfluxTypeName.string, InfluxTypeName.boolean, + ], + ] +} diff --git a/apple/Packages/TableProCore/Sources/TableProInfluxDBCore/InfluxServer.swift b/apple/Packages/TableProCore/Sources/TableProInfluxDBCore/InfluxServer.swift new file mode 100644 index 0000000000..0215c7c6db --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProInfluxDBCore/InfluxServer.swift @@ -0,0 +1,63 @@ +import Foundation + +public enum InfluxGeneration: Sendable, Equatable { + case v2 + case v3 + + public init?(version: String) { + switch Self.major(of: version) { + case 2: self = .v2 + case let major? where major >= 3: self = .v3 + default: return nil + } + } + + // `v2.9.1` on 2.x, `3.12.2` on 3.x, `1.11.8` on 1.x. + static func major(of version: String) -> Int? { + let trimmed = version.trimmingCharacters(in: .whitespaces) + let digits = trimmed.hasPrefix("v") ? trimmed.dropFirst() : Substring(trimmed) + return digits.split(separator: ".").first.flatMap { Int($0) } + } + + public var languages: [InfluxQueryLanguage] { + switch self { + case .v2: return [.flux, .influxQL] + case .v3: return [.sql, .influxQL] + } + } + + public var gridLanguage: InfluxQueryLanguage { + self == .v3 ? .sql : .influxQL + } +} + +public struct InfluxServerInfo: Sendable, Equatable { + public let generation: InfluxGeneration + public let version: String + + public init(generation: InfluxGeneration, version: String) { + self.generation = generation + self.version = version + } + + // A proxy may drop the `X-Influxdb-Version` header; 3.x also names its version in the `/ping` body. + public init(versionHeader: String?, pingBody: Data) throws { + let candidates = [versionHeader, Self.bodyVersion(pingBody)].compactMap { $0 } + for candidate in candidates { + let version = candidate.trimmingCharacters(in: .whitespaces) + if let generation = InfluxGeneration(version: version) { + self.init(generation: generation, version: version) + return + } + if InfluxGeneration.major(of: version) != nil { + throw InfluxError.unsupportedVersion(version) + } + } + throw InfluxError.unknownVersion + } + + private static func bodyVersion(_ data: Data) -> String? { + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + return object["version"] as? String + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift index e919e4c3ba..1ae428dc7c 100644 --- a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift +++ b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift @@ -214,6 +214,12 @@ public struct SQLLexicalProfile: Sendable, Hashable { static let sapHana: SQLLexicalGrammar = [.dollarAndHashInIdentifiers, .sqlScriptBlocks] + // InfluxQL escapes with a backslash in both quotes (measured on 2.9.1 and 3.12.2), Flux comments with `//`, and + // 3.x SQL is plain ANSI, the alternative a gate also reads. + static let influxDB: SQLLexicalGrammar = [ + .backslashEscapesInSingleQuotes, .backslashEscapesInDoubleQuotes, .doubleSlashLineComments, + ] + /// Engines whose statements are commands or JSON documents rather than SQL. Their splitting is what it has always /// been: a backslash escapes inside any quote, as it does in JSON and in `redis-cli`. static let commandLine: SQLLexicalGrammar = [ @@ -322,6 +328,11 @@ public struct SQLLexicalProfile: Sendable, Hashable { ] ), "Neo4j": SQLLexicalProfile(grammar: cypher, undetermined: [.carriageReturnEndsLineComments]), + "InfluxDB": SQLLexicalProfile( + grammar: influxDB, + alternatives: [.ansi], + undetermined: [.nestedBlockComments, .carriageReturnEndsLineComments] + ), "Redis": commandLineFamily, "MongoDB": commandLineFamily, "etcd": commandLineFamily, diff --git a/apple/Packages/TableProCore/Tests/TableProInfluxDBCoreTests/FluxScriptReadingTests.swift b/apple/Packages/TableProCore/Tests/TableProInfluxDBCoreTests/FluxScriptReadingTests.swift new file mode 100644 index 0000000000..4a55897789 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProInfluxDBCoreTests/FluxScriptReadingTests.swift @@ -0,0 +1,335 @@ +import Foundation +@testable import TableProInfluxDBCore +import Testing + +private let readsHere = FluxScriptReading(writes: false, reachesOutside: false) +private let writesHere = FluxScriptReading(writes: true, reachesOutside: false) +private let readsOutside = FluxScriptReading(writes: false, reachesOutside: true) +private let writesOutside = FluxScriptReading(writes: true, reachesOutside: true) + +struct FluxScriptReadingTests { + @Test(arguments: [ + #""" + from(bucket: "metrics") + |> range(start: -1h) + |> filter(fn: (r) => r._measurement == "cpu" and r["host"] == "a") + |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value") + """#, + #""" + import "strings" + from(bucket: "metrics") + |> range(start: -1h) + |> filter(fn: (r) => strings.hasPrefix(v: r.host, prefix: "a")) + """#, + #""" + import "csv" + csv.from(csv: "a,b\n1,2\n", mode: "raw") + """#, + ]) + func aScriptThatOnlyReadsNeitherWritesNorReachesOutside(script: String) { + #expect(FluxScriptReading(script) == readsHere) + } + + @Test(arguments: [ + #"from(bucket: "a") |> range(start: -1h) |> to(bucket: "b")"#, + #""" + import "influxdata/influxdb" + from(bucket: "a") |> range(start: -1h) |> influxdb.wideTo(bucket: "b") + """#, + #""" + import "experimental" + from(bucket: "a") |> range(start: -1h) |> experimental.to(bucket: "b") + """#, + #""" + w = to + from(bucket: "a") |> range(start: -1h) |> w(bucket: "b") + """#, + #"from(bucket: "a") |> range(start: -1h) |> filter(fn: (r) => r.p =~ /x\//) |> to(bucket: "b")"#, + #""" + t = from(bucket: "a") |> range(start: -1h) + s = "written: ${to(tables: t, bucket: "b")}" + """#, + ]) + func aScriptNamingAWriterWrites(script: String) { + #expect(FluxScriptReading(script) == writesHere) + } + + @Test(arguments: [ + #"from(bucket: "a") |> range(start: -1h) |> map(fn: (r) => ({r with to: "x"}))"#, + #""" + import "date" + from(bucket: "a") |> range(start: date.add(d: -1h, to: now())) + """#, + #"from(bucket: "a") |> range(start: -1h) |> filter(fn: (r) => r.note == "to(bucket: x)")"#, + #""" + // |> to(bucket: "b") + from(bucket: "a") |> range(start: -1h) + """#, + #"from(bucket: "a") |> range(start: -1h) // |> to(bucket: "b")"#, + ]) + func toAsALabelStringOrCommentDoesNotWrite(script: String) { + #expect(FluxScriptReading(script) == readsHere) + } + + @Test func indexingAPackageByAWriterNameWrites() { + let script = #""" + import "influxdata/influxdb" + import "array" + x = influxdb["wideTo"] + array.from(rows: [{a: 1}]) + """# + #expect(FluxScriptReading(script).writes) + } + + @Test(arguments: [ + #""" + import "influxdata/influxdb" + x = influxdb + from(bucket: "a") |> range(start: -1h) + """#, + #""" + import "influxdata/influxdb" + k = "wide" + "To" + f = influxdb[k] + """#, + ]) + func aPackageBoundOrIndexedWritesAndReachesOutside(script: String) { + #expect(FluxScriptReading(script) == writesOutside) + } + + @Test(arguments: [ + #""" + import "experimental/influxdb" + influxdb.api(method: "get", path: "/health") + """#, + #""" + import "influxdata/influxdb/monitor" + monitor.from(start: -1h) + """#, + #""" + import "http" + http.post(url: "http://x", data: bytes(v: "x")) + """#, + #""" + import "sql" + sql.from(driverName: "postgres", dataSourceName: "postgresql://u@h/db", query: "SELECT 1") + """#, + #""" + import "experimental/prometheus" + prometheus.scrape(url: "http://x/metrics") + """#, + #""" + import "contrib/anaisdg/statsmodels" + from(bucket: "a") |> range(start: -1h) + """#, + #""" + import "influxdata/influxdb/sample" + sample.data(set: "airSensor") + """#, + #""" + import "influxdata/influxdb/secrets" + t = secrets.get(key: "token") + """#, + #""" + import h "http" + h.post(url: "http://x", data: bytes(v: "x")) + """#, + ]) + func anUnknownImportWritesAndReachesOutside(script: String) { + #expect(FluxScriptReading(script) == writesOutside) + } + + @Test(arguments: [ + #"from(bucket: "b", host: "http://x") |> range(start: -1h)"#, + #""" + import "csv" + csv.from(file: "/etc/passwd") + """#, + #""" + import c "csv" + c.from(mode: "raw", file: "/x") + """#, + #"buckets("host": "http://remote:8086", org: "o", token: "t")"#, + #""" + host = "http://remote:8086" + from(bucket: "b", host) |> range(start: -1h) + """#, + #""" + import "csv" + file = "/etc/passwd" + csv.from(mode: "raw", file) + """#, + ]) + func aHostOrFileArgumentReachesOutsideWithoutWriting(script: String) { + #expect(FluxScriptReading(script) == readsOutside) + } + + @Test func aHostKeyInARecordDoesNotReachOutside() { + let script = #"from(bucket: "a") |> range(start: -1h) |> map(fn: (r) => ({r with host: "x"}))"# + #expect(FluxScriptReading(script) == readsHere) + } + + @Test(arguments: [ + "r = /x\n\"/\ny = \"z\" |> to(bucket: \"c\")", + "x = if true then 1else \"a\" |> to(bucket: \"c\")", + ]) + func theLexerAgreesWithFluxOnRegexLinesAndNumbers(script: String) { + #expect(FluxScriptReading(script).writes) + } + + @Test func aNumberStopsWhereADurationDoesNot() { + #expect(FluxLexer.tokens(of: "1then").map(\.kind) == [.number, .identifier]) + #expect(FluxLexer.tokens(of: "1h30m").map(\.kind) == [.number]) + #expect(FluxLexer.tokens(of: "1mo").map(\.kind) == [.number]) + } + + @Test(arguments: [ + "from(bucket: \"a", + "x = /abc", + "s = \"${1 + 1\"", + ]) + func anUnclosedLiteralFailsClosed(script: String) { + #expect(FluxScriptReading(script) == writesOutside) + } + + @Test func theServerASTOfHiddenTo() throws { + // import \"array\"\n/\"/\narray.from(rows: [{a: 1}]) |> to(bucket: \"x\")\n// \" + let ast = Data(#""" + {"ast":{"type":"Package","package":"main","files":[{"type":"File","package":null,"imports":[{"type":"ImportDeclaration", + "as":null,"path":{"type":"StringLiteral","value":"array"}}],"body":[{"type":"ExpressionStatement","expression":{"type":"RegexpLiteral", + "value":"\""}},{"type":"ExpressionStatement","expression":{"type":"PipeExpression","argument":{"type":"CallExpression", + "callee":{"type":"MemberExpression","object":{"type":"Identifier","name":"array"},"property":{"type":"Identifier", + "name":"from"}},"arguments":[{"type":"ObjectExpression","properties":[{"type":"Property","key":{"type":"Identifier", + "name":"rows"},"value":{"type":"ArrayExpression","elements":[{"type":"ObjectExpression","properties":[{"type":"Property", + "key":{"type":"Identifier","name":"a"},"value":{"type":"IntegerLiteral","value":"1"}}]}]}}]}]},"call":{"type":"CallExpression", + "callee":{"type":"Identifier","name":"to"},"arguments":[{"type":"ObjectExpression","properties":[{"type":"Property", + "key":{"type":"Identifier","name":"bucket"},"value":{"type":"StringLiteral","value":"x"}}]}]}}}],"eof":[{"text":"// \""}]}]}} + """#.utf8) + #expect(try FluxAST.reading(ast) == writesHere) + } + + @Test func theServerASTOfRead() throws { + // from(bucket: \"m\") |> range(start: -1h) |> filter(fn: (r) => r.host == \"a\") |> map(fn: (r) => ({r + let ast = Data(#""" + {"ast":{"type":"Package","package":"main","files":[{"type":"File","package":null,"imports":null,"body":[{"type":"ExpressionStatement", + "expression":{"type":"PipeExpression","argument":{"type":"PipeExpression","argument":{"type":"PipeExpression", + "argument":{"type":"CallExpression","callee":{"type":"Identifier","name":"from"},"arguments":[{"type":"ObjectExpression", + "properties":[{"type":"Property","key":{"type":"Identifier","name":"bucket"},"value":{"type":"StringLiteral","value":"m"}}]}]}, + "call":{"type":"CallExpression","callee":{"type":"Identifier","name":"range"},"arguments":[{"type":"ObjectExpression", + "properties":[{"type":"Property","key":{"type":"Identifier","name":"start"},"value":{"type":"UnaryExpression", + "operator":"-","argument":{"type":"DurationLiteral","values":[{"magnitude":1,"unit":"h"}]}}}]}]}},"call":{"type":"CallExpression", + "callee":{"type":"Identifier","name":"filter"},"arguments":[{"type":"ObjectExpression","properties":[{"type":"Property", + "key":{"type":"Identifier","name":"fn"},"value":{"type":"FunctionExpression","params":[{"type":"Property","key":{"type":"Identifier", + "name":"r"},"value":null}],"body":{"type":"BinaryExpression","operator":"==","left":{"type":"MemberExpression", + "object":{"type":"Identifier","name":"r"},"property":{"type":"Identifier","name":"host"}},"right":{"type":"StringLiteral", + "value":"a"}}}}]}]}},"call":{"type":"CallExpression","callee":{"type":"Identifier","name":"map"},"arguments":[{"type":"ObjectExpression", + "properties":[{"type":"Property","key":{"type":"Identifier","name":"fn"},"value":{"type":"FunctionExpression", + "params":[{"type":"Property","key":{"type":"Identifier","name":"r"},"value":null}],"body":{"type":"ParenExpression", + "expression":{"type":"ObjectExpression","with":{"type":"Identifier","name":"r"},"properties":[{"type":"Property", + "key":{"type":"Identifier","name":"host"},"value":{"type":"StringLiteral","value":"x"}},{"type":"Property","key":{"type":"Identifier", + "name":"to"},"value":{"type":"IntegerLiteral","value":"2"}}]}}}}]}]}}}]}]}} + """#.utf8) + #expect(try FluxAST.reading(ast) == readsHere) + } + + @Test func theServerASTOfAliasHttp() throws { + // import h \"http\"\nh.post(url: \"x\", data: bytes(v: \"a\")) + let ast = Data(#""" + {"ast":{"type":"Package","package":"main","files":[{"type":"File","package":null,"imports":[{"type":"ImportDeclaration", + "as":{"type":"Identifier","name":"h"},"path":{"type":"StringLiteral","value":"http"}}],"body":[{"type":"ExpressionStatement", + "expression":{"type":"CallExpression","callee":{"type":"MemberExpression","object":{"type":"Identifier","name":"h"}, + "property":{"type":"Identifier","name":"post"}},"arguments":[{"type":"ObjectExpression","properties":[{"type":"Property", + "key":{"type":"Identifier","name":"url"},"value":{"type":"StringLiteral","value":"x"}},{"type":"Property","key":{"type":"Identifier", + "name":"data"},"value":{"type":"CallExpression","callee":{"type":"Identifier","name":"bytes"},"arguments":[{"type":"ObjectExpression", + "properties":[{"type":"Property","key":{"type":"Identifier","name":"v"},"value":{"type":"StringLiteral","value":"a"}}]}]}}]}]}}]}]}} + """#.utf8) + #expect(try FluxAST.reading(ast) == writesOutside) + } + + @Test func theServerASTOfShorthandHost() throws { + // host = \"h\"\nbuckets(host) + let ast = Data(#""" + {"ast":{"type":"Package","package":"main","files":[{"type":"File","package":null,"imports":null,"body":[{"type":"VariableAssignment", + "id":{"type":"Identifier","name":"host"},"init":{"type":"StringLiteral","value":"h"}},{"type":"ExpressionStatement", + "expression":{"type":"CallExpression","callee":{"type":"Identifier","name":"buckets"},"arguments":[{"type":"ObjectExpression", + "properties":[{"type":"Property","key":{"type":"Identifier","name":"host"},"value":null}]}]}}]}]}} + """#.utf8) + #expect(try FluxAST.reading(ast) == readsOutside) + } + + @Test func theServerASTOfQuotedHost() throws { + // buckets(\"host\": \"h\") + let ast = Data(#""" + {"ast":{"type":"Package","package":"main","files":[{"type":"File","package":null,"imports":null,"body":[{"type":"ExpressionStatement", + "expression":{"type":"CallExpression","callee":{"type":"Identifier","name":"buckets"},"arguments":[{"type":"ObjectExpression", + "properties":[{"type":"Property","key":{"type":"StringLiteral","value":"host"},"value":{"type":"StringLiteral", + "value":"h"}}]}]}}]}]}} + """#.utf8) + #expect(try FluxAST.reading(ast) == readsOutside) + } + + @Test func theServerASTOfPackageValue() throws { + // import \"influxdata/influxdb\"\nx = influxdb\nbuckets() + let ast = Data(#""" + {"ast":{"type":"Package","package":"main","files":[{"type":"File","package":null,"imports":[{"type":"ImportDeclaration", + "as":null,"path":{"type":"StringLiteral","value":"influxdata/influxdb"}}],"body":[{"type":"VariableAssignment", + "id":{"type":"Identifier","name":"x"},"init":{"type":"Identifier","name":"influxdb"}},{"type":"ExpressionStatement", + "expression":{"type":"CallExpression","callee":{"type":"Identifier","name":"buckets"}}}]}]}} + """#.utf8) + #expect(try FluxAST.reading(ast) == writesOutside) + } + + @Test func theServerASTOfCsvFile() throws { + // import c \"csv\"\nc.from(mode: \"raw\", file: \"/x\") + let ast = Data(#""" + {"ast":{"type":"Package","package":"main","files":[{"type":"File","package":null,"imports":[{"type":"ImportDeclaration", + "as":{"type":"Identifier","name":"c"},"path":{"type":"StringLiteral","value":"csv"}}],"body":[{"type":"ExpressionStatement", + "expression":{"type":"CallExpression","callee":{"type":"MemberExpression","object":{"type":"Identifier","name":"c"}, + "property":{"type":"Identifier","name":"from"}},"arguments":[{"type":"ObjectExpression","properties":[{"type":"Property", + "key":{"type":"Identifier","name":"mode"},"value":{"type":"StringLiteral","value":"raw"}},{"type":"Property","key":{"type":"Identifier", + "name":"file"},"value":{"type":"StringLiteral","value":"/x"}}]}]}}]}]}} + """#.utf8) + #expect(try FluxAST.reading(ast) == readsOutside) + } + + @Test func theServerASTOfMemberString() throws { + // import \"influxdata/influxdb\"\nw = influxdb[\"wideTo\"]\nbuckets() + let ast = Data(#""" + {"ast":{"type":"Package","package":"main","files":[{"type":"File","package":null,"imports":[{"type":"ImportDeclaration", + "as":null,"path":{"type":"StringLiteral","value":"influxdata/influxdb"}}],"body":[{"type":"VariableAssignment", + "id":{"type":"Identifier","name":"w"},"init":{"type":"MemberExpression","object":{"type":"Identifier","name":"influxdb"}, + "property":{"type":"StringLiteral","value":"wideTo"}}},{"type":"ExpressionStatement","expression":{"type":"CallExpression", + "callee":{"type":"Identifier","name":"buckets"}}}]}]}} + """#.utf8) + #expect(try FluxAST.reading(ast) == writesOutside) + } + + @Test func theServerASTOfInterpolated() throws { + // import \"array\"\nx = \"${array.from(rows: [{a: 1}]) |> to(bucket: \"x\")}\"\nbuckets() + let ast = Data(#""" + {"ast":{"type":"Package","package":"main","files":[{"type":"File","package":null,"imports":[{"type":"ImportDeclaration", + "as":null,"path":{"type":"StringLiteral","value":"array"}}],"body":[{"type":"VariableAssignment","id":{"type":"Identifier", + "name":"x"},"init":{"type":"StringExpression","parts":[{"type":"InterpolatedPart","expression":{"type":"PipeExpression", + "argument":{"type":"CallExpression","callee":{"type":"MemberExpression","object":{"type":"Identifier","name":"array"}, + "property":{"type":"Identifier","name":"from"}},"arguments":[{"type":"ObjectExpression","properties":[{"type":"Property", + "key":{"type":"Identifier","name":"rows"},"value":{"type":"ArrayExpression","elements":[{"type":"ObjectExpression", + "properties":[{"type":"Property","key":{"type":"Identifier","name":"a"},"value":{"type":"IntegerLiteral","value":"1"}}]}]}}]}]}, + "call":{"type":"CallExpression","callee":{"type":"Identifier","name":"to"},"arguments":[{"type":"ObjectExpression", + "properties":[{"type":"Property","key":{"type":"Identifier","name":"bucket"},"value":{"type":"StringLiteral","value":"x"}}]}]}}}]}}, + {"type":"ExpressionStatement","expression":{"type":"CallExpression","callee":{"type":"Identifier","name":"buckets"}}}]}]}} + """#.utf8) + #expect(try FluxAST.reading(ast) == writesHere) + } + + @Test func anASTBodyThatIsNotJSONThrows() { + #expect(throws: InfluxError.self) { try FluxAST.reading(Data("nope".utf8)) } + } + + @Test(arguments: [ + "import \"array\"\nx = 1./ 2.0\narray.from(rows: [{a: 1}]) |> to(bucket: \"x\")\ny = 3.0 / 1.0", + "import \"array\"\nb = \"x\"\nr = /a\\\\/ \"/\narray.from(rows: [{a: 1}]) |> to(bucket: b)\n// \"", + ]) + func theLexerReadsFloatsAndRegexEscapesTheWayFluxDoes(script: String) { + #expect(FluxScriptReading(script).writes) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProInfluxDBCoreTests/InfluxGridQueryTests.swift b/apple/Packages/TableProCore/Tests/TableProInfluxDBCoreTests/InfluxGridQueryTests.swift new file mode 100644 index 0000000000..84b9e538b3 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProInfluxDBCoreTests/InfluxGridQueryTests.swift @@ -0,0 +1,309 @@ +import Foundation +@testable import TableProInfluxDBCore +import Testing + +private func whereClause( + _ filter: InfluxFilter, + kind: InfluxColumnKind? = nil, + language: InfluxQueryLanguage +) -> String? { + let kinds = kind.map { [filter.column: $0] } ?? [:] + let query = InfluxGridQuery(measurement: "cpu", filters: [filter], shape: .all) + guard let statement = query.statement(language: language, kinds: kinds) else { return nil } + let head = InfluxStatement.marked(#"SELECT * FROM "cpu" WHERE "#, language: language) + return statement.hasPrefix(head) ? String(statement.dropFirst(head.count)) : statement +} + +struct InfluxGridQueryTests { + private let page = InfluxGridQuery( + measurement: "cpu", + projection: ["time", "host", "usage"], + sorts: [InfluxSort(column: "usage", ascending: true), InfluxSort(column: "host", ascending: false)], + shape: .page(limit: 50, offset: 100) + ) + + @Test func anSQLPageProjectsNamedColumnsAndSortsByAnyColumn() { + #expect(page.statement(language: .sql, kinds: [:]) + == "-- sql\n" + #"SELECT "time", "host", "usage" FROM "cpu" ORDER BY "usage" ASC, "host" DESC LIMIT 50 OFFSET 100"#) + } + + @Test func anInfluxQLPageSelectsEverythingAndKeepsNewestFirstForAnotherColumnsSort() { + #expect(page.statement(language: .influxQL, kinds: [:]) + == "-- influxql\n" + #"SELECT * FROM "cpu" ORDER BY time DESC LIMIT 50 OFFSET 100"#) + } + + @Test func anInfluxQLPageSortsByTimeAscending() { + let query = InfluxGridQuery( + measurement: "cpu", + sorts: [InfluxSort(column: "time", ascending: true)], + shape: .page(limit: 10, offset: 0) + ) + #expect(query.statement(language: .influxQL, kinds: [:]) + == "-- influxql\n" + #"SELECT * FROM "cpu" ORDER BY time ASC LIMIT 10 OFFSET 0"#) + } + + @Test func anUnsortedSQLPageIsNewestFirst() { + let query = InfluxGridQuery(measurement: "cpu", shape: .page(limit: 10, offset: 0)) + #expect(query.statement(language: .sql, kinds: [:]) + == "-- sql\n" + #"SELECT * FROM "cpu" ORDER BY time DESC LIMIT 10 OFFSET 0"#) + } + + @Test func aCountKeepsTheFiltersAndDropsOrderAndLimit() { + let query = InfluxGridQuery( + measurement: "cpu", + projection: ["host"], + filters: [InfluxFilter(column: "host", op: "=", value: "a")], + sorts: [InfluxSort(column: "time", ascending: true)], + shape: .count + ) + #expect(query.statement(language: .sql, kinds: ["host": .tag]) + == "-- sql\n" + #"SELECT COUNT(*) FROM "cpu" WHERE "host" = 'a'"#) + #expect(query.statement(language: .influxQL, kinds: ["host": .tag]) + == "-- influxql\n" + #"SELECT COUNT(*) FROM "cpu" WHERE "host" = 'a'"#) + } + + @Test func allRowsHaveNeitherOrderNorLimit() { + let query = InfluxGridQuery( + measurement: "cpu", + projection: ["host"], + filters: [InfluxFilter(column: "host", op: "=", value: "a")], + sorts: [InfluxSort(column: "host", ascending: true)], + shape: .all + ) + #expect(query.statement(language: .sql, kinds: [:]) + == "-- sql\n" + #"SELECT "host" FROM "cpu" WHERE "host" = 'a'"#) + } + + @Test func matchingAnyFilterJoinsWithOr() { + let query = InfluxGridQuery( + measurement: "cpu", + filters: [ + InfluxFilter(column: "host", op: "=", value: "a"), + InfluxFilter(column: "usage", op: ">", value: "5"), + ], + matchesAll: false, + shape: .all + ) + #expect(query.statement(language: .influxQL, kinds: ["host": .tag, "usage": .number]) + == "-- influxql\n" + #"SELECT * FROM "cpu" WHERE "host" = 'a' OR "usage" > 5"#) + } + + @Test func aFilterWithNoMeaningMakesNoStatement() { + let query = InfluxGridQuery( + measurement: "cpu", + filters: [InfluxFilter(column: "usage", op: "CONTAINS", value: "7")], + shape: .page(limit: 10, offset: 0) + ) + #expect(query.statement(language: .sql, kinds: ["usage": .number]) == nil) + } + + @Test(arguments: [InfluxQueryLanguage.sql, .influxQL]) + func aTagQuotesANumericValue(language: InfluxQueryLanguage) { + let filter = InfluxFilter(column: "zone", op: "=", value: "1") + #expect(whereClause(filter, kind: .tag, language: language) == #""zone" = '1'"#) + } + + @Test(arguments: [InfluxQueryLanguage.sql, .influxQL]) + func aNumberLeavesANumericValueBare(language: InfluxQueryLanguage) { + let filter = InfluxFilter(column: "usage", op: "=", value: "7") + #expect(whereClause(filter, kind: .number, language: language) == #""usage" = 7"#) + } + + @Test(arguments: [("7", #""x" = 7"#), ("a", #""x" = 'a'"#), ("true", #""x" = true"#)]) + func anUnknownColumnGuessesFromTheValue(value: String, condition: String) { + let filter = InfluxFilter(column: "x", op: "=", value: value) + #expect(whereClause(filter, language: .influxQL) == condition) + } + + @Test func notEqualSpelledTheSQLWayBecomesBangEquals() { + let filter = InfluxFilter(column: "host", op: "<>", value: "a") + #expect(whereClause(filter, kind: .tag, language: .influxQL) == #""host" != 'a'"#) + } + + @Test func inIsAListInSQLAndAnOrChainInInfluxQL() { + let filter = InfluxFilter(column: "host", op: "IN", value: "a, b") + #expect(whereClause(filter, kind: .tag, language: .sql) == #""host" IN ('a', 'b')"#) + #expect(whereClause(filter, kind: .tag, language: .influxQL) == #"("host" = 'a' OR "host" = 'b')"#) + } + + @Test func notInIsAListInSQLAndAnAndChainInInfluxQL() { + let filter = InfluxFilter(column: "host", op: "NOT IN", value: "a, b") + #expect(whereClause(filter, kind: .tag, language: .sql) == #""host" NOT IN ('a', 'b')"#) + #expect(whereClause(filter, kind: .tag, language: .influxQL) == #"("host" != 'a' AND "host" != 'b')"#) + } + + @Test func anEmptyInListMakesNoStatement() { + let filter = InfluxFilter(column: "host", op: "IN", value: " , ") + #expect(whereClause(filter, kind: .tag, language: .sql) == nil) + } + + @Test(arguments: [InfluxQueryLanguage.sql, .influxQL]) + func betweenIsTwoBounds(language: InfluxQueryLanguage) { + let filter = InfluxFilter(column: "usage", op: "BETWEEN", value: "1", secondValue: "9") + #expect(whereClause(filter, kind: .number, language: language) == #"("usage" >= 1 AND "usage" <= 9)"#) + } + + @Test func betweenWithoutAnUpperBoundMakesNoStatement() { + let filter = InfluxFilter(column: "usage", op: "BETWEEN", value: "1") + #expect(whereClause(filter, kind: .number, language: .sql) == nil) + } + + @Test(arguments: [ + ("CONTAINS", "a.b(c)", #""note" ~ 'a\.b\(c\)'"#, #""note" =~ /a\.b\(c\)/"#), + ("STARTS WITH", "x+", #""note" ~ '^x\+'"#, #""note" =~ /^x\+/"#), + ("ENDS WITH", "x$", #""note" ~ 'x\$$'"#, #""note" =~ /x\$$/"#), + ("NOT CONTAINS", "[a]", #""note" !~ '\[a\]'"#, #""note" !~ /\[a\]/"#), + ("CONTAINS", "a/b", #""note" ~ 'a/b'"#, #""note" =~ /a\x{2f}b/"#), + ]) + func textMatchingEscapesRegexMetacharacters(op: String, value: String, sql: String, influxQL: String) { + let filter = InfluxFilter(column: "note", op: op, value: value) + #expect(whereClause(filter, kind: .text, language: .sql) == sql) + #expect(whereClause(filter, kind: .text, language: .influxQL) == influxQL) + } + + @Test func caseInsensitiveMatchingAddsTheFlag() { + let filter = InfluxFilter(column: "host", op: "CONTAINS", value: "A", isCaseSensitive: false) + #expect(whereClause(filter, kind: .tag, language: .sql) == #""host" ~ '(?i)A'"#) + #expect(whereClause(filter, kind: .tag, language: .influxQL) == #""host" =~ /(?i)A/"#) + } + + @Test(arguments: [ + ("CONTAINS", InfluxColumnKind.number), + ("STARTS WITH", .number), + ("ENDS WITH", .boolean), + ("REGEX", .number), + ("NOT CONTAINS", .boolean), + ]) + func textMatchingOnANumberOrBooleanMakesNoStatement(op: String, kind: InfluxColumnKind) { + let filter = InfluxFilter(column: "usage", op: op, value: "7") + #expect(whereClause(filter, kind: kind, language: .sql) == nil) + #expect(whereClause(filter, kind: kind, language: .influxQL) == nil) + } + + @Test(arguments: [nil, .tag, .text, .number, .boolean] as [InfluxColumnKind?]) + func nullTestsAreNativeInSQL(kind: InfluxColumnKind?) { + #expect(whereClause(InfluxFilter(column: "x", op: "IS NULL", value: ""), kind: kind, language: .sql) + == #""x" IS NULL"#) + #expect(whereClause(InfluxFilter(column: "x", op: "IS NOT NULL", value: ""), kind: kind, language: .sql) + == #""x" IS NOT NULL"#) + } + + @Test(arguments: [nil, .tag] as [InfluxColumnKind?]) + func nullTestsOnAnInfluxQLTagCompareWithEmpty(kind: InfluxColumnKind?) { + #expect(whereClause(InfluxFilter(column: "zone", op: "IS NULL", value: ""), kind: kind, language: .influxQL) + == #""zone" = ''"#) + #expect(whereClause(InfluxFilter(column: "zone", op: "IS NOT NULL", value: ""), kind: kind, language: .influxQL) + == #""zone" != ''"#) + } + + @Test(arguments: [InfluxColumnKind.text, .number, .boolean]) + func nullTestsOnAnInfluxQLFieldMakeNoStatement(kind: InfluxColumnKind) { + #expect(whereClause(InfluxFilter(column: "note", op: "IS NULL", value: ""), kind: kind, language: .influxQL) + == nil) + #expect(whereClause(InfluxFilter(column: "note", op: "IS NOT NULL", value: ""), kind: kind, language: .influxQL) + == nil) + } + + @Test func emptyTestsOnTextCompareWithEmpty() { + #expect(whereClause(InfluxFilter(column: "note", op: "IS EMPTY", value: ""), kind: .text, language: .sql) + == #""note" = ''"#) + #expect(whereClause(InfluxFilter(column: "note", op: "IS NOT EMPTY", value: ""), kind: .text, language: .influxQL) + == #""note" != ''"#) + } + + @Test func anEmptyTestOnANumberMakesNoStatement() { + let filter = InfluxFilter(column: "usage", op: "IS EMPTY", value: "") + #expect(whereClause(filter, kind: .number, language: .sql) == nil) + #expect(whereClause(filter, kind: .number, language: .influxQL) == nil) + } + + @Test func theRowCountIsTheLargestCountColumn() { + let table = InfluxResultTable( + columns: ["time", "count_idle", "count_note", "count_usage"], + typeNames: ["timestamp", "integer", "integer", "integer"], + rows: [["1970-01-01T00:00:00Z", "2", "5", "3"]] + ) + #expect(InfluxGridQuery.rowCount(from: table) == 5) + } + + @Test func theSQLRowCountIsItsOneCountColumn() { + let table = InfluxResultTable(columns: ["count(*)"], typeNames: ["integer"], rows: [["42"]]) + #expect(InfluxGridQuery.rowCount(from: table) == 42) + } + + @Test func noRowsCountsZero() { + let table = InfluxResultTable(columns: ["count(*)"], typeNames: ["integer"], rows: []) + #expect(InfluxGridQuery.rowCount(from: table) == 0) + #expect(InfluxGridQuery.rowCount(from: .empty) == 0) + } + + @Test func identifiersAreQuotedPerLanguage() { + #expect(InfluxGridQuery.quoteIdentifier(#"a"b"#, language: .sql) == #""a""b""#) + #expect(InfluxGridQuery.quoteIdentifier(#"a"b"#, language: .influxQL) == #""a\"b""#) + #expect(InfluxGridQuery.quoteIdentifier(#"a\b"#, language: .influxQL) == #""a\\b""#) + } + + @Test func stringsAreQuotedPerLanguage() { + #expect(InfluxGridQuery.stringLiteral("it's", language: .sql) == "'it''s'") + #expect(InfluxGridQuery.stringLiteral("it's", language: .influxQL) == #"'it\'s'"#) + #expect(InfluxGridQuery.stringLiteral(#"a\b"#, language: .influxQL) == #"'a\\b'"#) + } + + @Test func theMeasurementNameIsQuotedInTheStatement() { + let query = InfluxGridQuery(measurement: #"we"ird"#, shape: .count) + #expect(query.statement(language: .sql, kinds: [:]) == "-- sql\n" + #"SELECT COUNT(*) FROM "we""ird""#) + #expect(query.statement(language: .influxQL, kinds: [:]) == "-- influxql\n" + #"SELECT COUNT(*) FROM "we\"ird""#) + } + + @Test(arguments: [ + ("a/b", #"a\x{2f}b"#), + (#"a\"#, #"a\x{5c}"#), + (#"a\/b"#, #"a\x{2f}b"#), + (#"a\\b"#, #"a\x{5c}b"#), + (#"\d+"#, #"\d+"#), + ("\u{600}/", #"\x{600}\x{2f}"#), + ("\u{600}\\", #"\x{600}\x{5c}"#), + ("a\nb", #"a\x{a}b"#), + (#"x\Qé.*/\Ey"#, #"x\x{e9}\.\*\x{2f}y"#), + (#"\Qa+b"#, #"a\+b"#), + ]) + func anInfluxQLRegexBodyHoldsNoSlashAndNoTrailingBackslash(pattern: String, body: String) { + #expect(InfluxGridQuery.influxQLRegexBody(pattern) == body) + } + + @Test func filterValuesCannotCloseAnInfluxQLRegexEarly() throws { + let query = InfluxGridQuery( + measurement: "cpu", + filters: [ + InfluxFilter(column: "note", op: "CONTAINS", value: "\\"), + InfluxFilter(column: "note", op: "CONTAINS", value: "; DELETE FROM cpu --"), + InfluxFilter(column: "note", op: "REGEX", value: "\\/; DROP MEASUREMENT cpu --"), + ], + shape: .page(limit: 10, offset: 0) + ) + let text = try #require(query.statement(language: .influxQL, kinds: ["note": .text], marked: false)) + #expect(text.unicodeScalars.filter { $0 == "/" }.count == 6) + #expect(!text.contains("\\/")) + } + + @Test func aCombiningMarkCannotHideASlashOrAQuote() throws { + let value = "\u{0600}/ GROUP BY /\u{0301}'\u{0600}/; DROP MEASUREMENT cpu --'" + let body = InfluxGridQuery.influxQLRegexBody(InfluxGridQuery.escapedRegex(value)) + #expect(!body.unicodeScalars.contains("/")) + #expect(body.unicodeScalars.allSatisfy { $0.isASCII }) + #expect(InfluxGridQuery.stringLiteral("a'\u{301}", language: .influxQL) == "'a\\'\u{301}'") + #expect(InfluxGridQuery.stringLiteral("a'\u{301}", language: .sql) == "'a''\u{301}'") + #expect(InfluxGridQuery.quoteIdentifier("a\"\u{301}", language: .sql) == "\"a\"\"\u{301}\"") + } + + @Test func anUnmarkedPageIsTheInfluxQLForm() { + let query = InfluxGridQuery(measurement: "cpu", projection: ["time", "host"], shape: .page(limit: 10, offset: 0)) + #expect(query.statement(language: .influxQL, kinds: [:], marked: false) + == #"SELECT * FROM "cpu" ORDER BY time DESC LIMIT 10 OFFSET 0"#) + } + + @Test(arguments: [("-1", "-1"), (".5", ".5"), ("5.", "5."), ("1e5", "'1e5'"), ("1\u{0};--", "'1\u{0};--'"), ("٣", "'٣'")]) + func onlyPlainNumbersGoOutUnquoted(value: String, literal: String) { + #expect(InfluxGridQuery.literal(value, kind: .number, language: .influxQL) == literal) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProInfluxDBCoreTests/InfluxRequestTests.swift b/apple/Packages/TableProCore/Tests/TableProInfluxDBCoreTests/InfluxRequestTests.swift new file mode 100644 index 0000000000..6a032a6746 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProInfluxDBCoreTests/InfluxRequestTests.swift @@ -0,0 +1,308 @@ +import Foundation +@testable import TableProInfluxDBCore +import Testing + +private let base = URL(string: "http://127.0.0.1:28086")! +private let token = InfluxCredentials.token("tp-token") + +private func queryItems(ofForm body: Data?) -> [URLQueryItem]? { + guard let body else { return nil } + return URLComponents(string: "http://h/?" + (String(bytes: body, encoding: .utf8) ?? ""))?.queryItems +} + +struct InfluxRequestTests { + @Test func anInfluxQLFormBodyPercentEncodesTheSemicolon() throws { + let statement = InfluxStatement(language: .influxQL, text: "SELECT * FROM cpu WHERE note = 'a;b'") + let request = try InfluxRequests.query( + statement, database: "metrics", organization: "tp", generation: .v2, credentials: token + ) + #expect(request.method == "POST") + #expect(request.path == "/query") + #expect(request.headers["Content-Type"] == "application/x-www-form-urlencoded") + #expect(request.headers["Accept"] == "application/json") + let body = try #require(request.body) + #expect(String(bytes: body, encoding: .utf8) + == "q=SELECT%20%2A%20FROM%20cpu%20WHERE%20note%20%3D%20%27a%3Bb%27&db=metrics") + } + + @Test func anInfluxQLFormBodyRoundTripsEveryReservedCharacter() throws { + let text = "SHOW MEASUREMENTS; SELECT * FROM \"a+b\" WHERE t = 'x&y=z é'" + let request = try InfluxRequests.query( + InfluxStatement(language: .influxQL, text: text), + database: "my db", + organization: "", + generation: .v2, + credentials: token + ) + let data = try #require(request.body) + let body = try #require(String(bytes: data, encoding: .utf8)) + for raw in [";", "+", " ", "é", "'", "\""] { + #expect(!body.contains(raw), "\(raw) left raw in \(body)") + } + #expect(body.components(separatedBy: "&").count == 2) + #expect(queryItems(ofForm: request.body) == [URLQueryItem(name: "q", value: text), URLQueryItem(name: "db", value: "my db")]) + } + + @Test func anInfluxQLFormBodyOmitsAnEmptyDatabase() throws { + let request = try InfluxRequests.query( + InfluxStatement(language: .influxQL, text: "SHOW DATABASES"), + database: "", + organization: "", + generation: .v2, + credentials: .none + ) + #expect(queryItems(ofForm: request.body) == [URLQueryItem(name: "q", value: "SHOW DATABASES")]) + } + + @Test func urlQueryItemsArePercentEncodedTheSameWay() throws { + let request = InfluxRequest( + method: "GET", + path: "/query", + query: [ + InfluxQueryItem("q", "SHOW MEASUREMENTS; SHOW DATABASES"), + InfluxQueryItem("db", "a+b&c=d é"), + ] + ) + let url = try #require(request.url(base: base)) + #expect(url.absoluteString + == "http://127.0.0.1:28086/query?q=SHOW%20MEASUREMENTS%3B%20SHOW%20DATABASES&db=a%2Bb%26c%3Dd%20%C3%A9") + let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems + #expect(items == [ + URLQueryItem(name: "q", value: "SHOW MEASUREMENTS; SHOW DATABASES"), + URLQueryItem(name: "db", value: "a+b&c=d é"), + ]) + } + + @Test func theRequestPathJoinsABasePath() throws { + let request = InfluxRequest(method: "GET", path: "/ping") + let url = try #require(request.url(base: URL(string: "https://h.example/influx/")!)) + #expect(url.absoluteString == "https://h.example/influx/ping") + } + + @Test func aV3SQLQueryPostsJSONForCSV() throws { + let request = try InfluxRequests.query( + InfluxStatement(language: .sql, text: "SELECT note FROM cpu"), + database: "metrics", + organization: "", + generation: .v3, + credentials: token + ) + #expect(request.method == "POST") + #expect(request.path == "/api/v3/query_sql") + #expect(request.query.isEmpty) + #expect(request.headers["Content-Type"] == "application/json") + #expect(request.headers["Accept"] == "text/csv") + let fields = try JSONDecoder().decode([String: String].self, from: try #require(request.body)) + #expect(fields == ["db": "metrics", "q": "SELECT note FROM cpu", "format": "csv"]) + } + + @Test func aV3QueryOmitsAnEmptyDatabase() throws { + let request = try InfluxRequests.query( + InfluxStatement(language: .influxQL, text: "SHOW DATABASES"), + database: "", + organization: "", + generation: .v3, + credentials: token + ) + let fields = try JSONDecoder().decode([String: String].self, from: try #require(request.body)) + #expect(fields == ["q": "SHOW DATABASES", "format": "csv"]) + } + + @Test func aV3InfluxQLQueryGoesToTheInfluxQLEndpoint() throws { + let request = try InfluxRequests.query( + InfluxStatement(language: .influxQL, text: "SHOW MEASUREMENTS"), + database: "metrics", + organization: "", + generation: .v3, + credentials: token + ) + #expect(request.path == "/api/v3/query_influxql") + #expect(request.headers["Accept"] == "text/csv") + } + + @Test func aV2FluxQueryPostsToTheQueryAPIWithTheOrganizationInTheURL() throws { + let text = "from(bucket: \"metrics\") |> range(start: -1h)" + let request = try InfluxRequests.query( + InfluxStatement(language: .flux, text: text), + database: "metrics", + organization: "tp", + generation: .v2, + credentials: token + ) + #expect(request.method == "POST") + #expect(request.url(base: base)?.absoluteString == "http://127.0.0.1:28086/api/v2/query?org=tp") + #expect(request.headers["Content-Type"] == "application/json") + #expect(request.headers["Accept"] == "application/csv") + let body = try #require(request.body) + let object = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any]) + #expect(object["query"] as? String == text) + #expect(object["type"] as? String == "flux") + let dialect = try #require(object["dialect"] as? [String: Any]) + #expect(dialect["annotations"] as? [String] == ["datatype"]) + #expect(dialect["header"] as? Bool == true) + } + + @Test(arguments: [ + (InfluxGeneration.v3, InfluxQueryLanguage.flux), + (.v2, .sql), + ]) + func aLanguageTheGenerationLacksIsRefused(generation: InfluxGeneration, language: InfluxQueryLanguage) { + #expect(throws: InfluxError.unsupportedLanguage(language, generation: generation)) { + try InfluxRequests.query( + InfluxStatement(language: language, text: "x"), + database: "metrics", + organization: "tp", + generation: generation, + credentials: token + ) + } + } + + @Test func v3SendsBearer() throws { + #expect(InfluxRequests.ping(credentials: token, generation: .v3).headers["Authorization"] == "Bearer tp-token") + let request = try InfluxRequests.query( + InfluxStatement(language: .sql, text: "SELECT 1"), + database: "metrics", + organization: "", + generation: .v3, + credentials: token + ) + #expect(request.headers["Authorization"] == "Bearer tp-token") + #expect(InfluxRequests.v3Databases(credentials: token).headers["Authorization"] == "Bearer tp-token") + } + + @Test func v2SendsToken() throws { + #expect(InfluxRequests.ping(credentials: token, generation: .v2).headers["Authorization"] == "Token tp-token") + let request = try InfluxRequests.query( + InfluxStatement(language: .flux, text: "buckets()"), + database: "", + organization: "tp", + generation: .v2, + credentials: token + ) + #expect(request.headers["Authorization"] == "Token tp-token") + #expect(InfluxRequests.v2Organizations(credentials: token).headers["Authorization"] == "Token tp-token") + } + + @Test func beforeTheVersionIsKnownTheTokenGoesOutAsToken() { + #expect(InfluxRequests.ping(credentials: token).headers["Authorization"] == "Token tp-token") + } + + @Test(arguments: [nil, "", " \n"] as [String?]) + func aBlankTokenSendsNoAuthorization(text: String?) throws { + let credentials = InfluxCredentials(token: text) + #expect(credentials == .none) + #expect(InfluxRequests.ping(credentials: credentials, generation: .v3).headers["Authorization"] == nil) + let request = try InfluxRequests.query( + InfluxStatement(language: .sql, text: "SELECT 1"), + database: "metrics", + organization: "", + generation: .v3, + credentials: credentials + ) + #expect(request.headers["Authorization"] == nil) + } + + @Test func aTokenIsTrimmed() { + #expect(InfluxCredentials(token: " tp-token\n") == token) + } + + @Test func theV2TokenCheckListsOneBucketOfTheOrganization() { + let request = InfluxRequests.v2TokenCheck(organization: "tp", credentials: token) + #expect(request.method == "GET") + #expect(request.url(base: base)?.absoluteString == "http://127.0.0.1:28086/api/v2/buckets?org=tp&limit=1") + #expect(request.headers["Authorization"] == "Token tp-token") + } + + @Test func theV3DatabaseListAsksForJSON() { + let request = InfluxRequests.v3Databases(credentials: token) + #expect(request.url(base: base)?.absoluteString == "http://127.0.0.1:28086/api/v3/configure/database?format=json") + } + + @Test func v3DatabasesAreReadFromTheConfigureList() throws { + let body = Data(#"[{"iox::database":"_internal"},{"iox::database":"metrics"}]"#.utf8) + #expect(try InfluxCatalogDecoder.v3Databases(body) == ["_internal", "metrics"]) + } + + @Test func anInfluxQLFormBodyCarriesTheRetentionPolicy() throws { + let request = try InfluxRequests.query( + InfluxStatement(language: .influxQL, text: "SHOW MEASUREMENTS"), + database: "telegraf", + retentionPolicy: "weekly", + organization: "", + generation: .v2, + credentials: token + ) + #expect(queryItems(ofForm: request.body) == [ + URLQueryItem(name: "q", value: "SHOW MEASUREMENTS"), + URLQueryItem(name: "db", value: "telegraf"), + URLQueryItem(name: "rp", value: "weekly"), + ]) + } + + @Test func theDBRPLookupNamesTheBucketThenItsID() throws { + let base = try #require(URL(string: "http://h:8086")) + let bucket = InfluxRequests.v2Bucket(named: "telegraf/weekly", organization: "tp", credentials: token) + #expect(bucket.url(base: base)?.absoluteString == "http://h:8086/api/v2/buckets?org=tp&name=telegraf%2Fweekly") + let mappings = InfluxRequests.v2DBRPs(bucketID: "6213", organization: "tp", credentials: token) + #expect(mappings.url(base: base)?.absoluteString == "http://h:8086/api/v2/dbrps?org=tp&bucketID=6213") + let found = Data(#"{"buckets":[{"id":"6213","name":"telegraf/weekly"}]}"#.utf8) + #expect(try InfluxCatalogDecoder.v2BucketID(found) == "6213") + } + + @Test func aSlashBucketMapsToItsDatabaseAndRetentionPolicy() throws { + let body = Data(#""" + {"content": [{"id": "6213", "database": "telegraf", "retention_policy": "weekly", "default": false, "virtual": true, "bucketID": "6213"}]} + """#.utf8) + #expect(try InfluxCatalogDecoder.v2InfluxQLTarget(body) + == InfluxQLTarget(database: "telegraf", retentionPolicy: "weekly", isDefault: false)) + } + + @Test func aMadeDefaultMappingWinsOverTheVirtualOne() throws { + let body = Data(#""" + {"content": [ + {"id": "9624", "database": "renamed", "retention_policy": "autogen", "default": true, "virtual": true, "bucketID": "9624"}, + {"id": "1191", "database": "legacy_db", "retention_policy": "autogen", "default": true, "virtual": false, "bucketID": "9624"} + ]} + """#.utf8) + #expect(try InfluxCatalogDecoder.v2InfluxQLTarget(body) + == InfluxQLTarget(database: "legacy_db", retentionPolicy: "autogen")) + #expect(try InfluxCatalogDecoder.v2InfluxQLTarget(Data(#"{"content": []}"#.utf8)) == nil) + } + + @Test func v2OrganizationsAreReadByName() throws { + let body = Data(#""" + {"links": {"self": "/api/v2/orgs"}, + "orgs": [{"links": {"self": "/api/v2/orgs/c80dd54eb3945b1c"}, "id": "c80dd54eb3945b1c", "name": "tp", + "description": "", "createdAt": "2026-11-01T00:00:00Z", "updatedAt": "2026-11-01T00:00:00Z"}]} + """#.utf8) + #expect(try InfluxCatalogDecoder.v2Organizations(body) == ["tp"]) + } + + @Test func v2BucketsAreReadByNameFromTabIndentedJSON() throws { + let body = Data(""" + { + \t"links": {"self": "/api/v2/buckets?descending=false&limit=100&offset=0&org=tp"}, + \t"buckets": [ + \t\t{"id": "4d6d0e288bf0273e", "orgID": "c80dd54eb3945b1c", "type": "system", "name": "_monitoring", "labels": []}, + \t\t{"id": "4d6d0e288bf0273f", "orgID": "c80dd54eb3945b1c", "type": "system", "name": "_tasks", "labels": []}, + \t\t{"id": "4d6d0e288bf0273e", "orgID": "c80dd54eb3945b1c", "type": "user", "name": "metrics", + \t\t "retentionRules": [{"type": "expire", "everySeconds": 0, "shardGroupDurationSeconds": 604800}], "labels": []}, + \t\t{"id": "9624e5eea705cdda", "orgID": "c80dd54eb3945b1c", "type": "user", "name": "renamed", "labels": []} + \t] + } + """.utf8) + #expect(try InfluxCatalogDecoder.v2BucketNames(body) == ["_monitoring", "_tasks", "metrics", "renamed"]) + } + + @Test func junkCatalogBodiesThrowMalformedResponse() { + let junk = Data("not json".utf8) + #expect(throws: InfluxError.malformedResponse("not json")) { try InfluxCatalogDecoder.v3Databases(junk) } + #expect(throws: InfluxError.malformedResponse("not json")) { try InfluxCatalogDecoder.v2Organizations(junk) } + #expect(throws: InfluxError.malformedResponse("not json")) { try InfluxCatalogDecoder.v2BucketNames(junk) } + let text = #"{"code":"unauthorized","message":"unauthorized access"}"# + let wrongShape = Data(text.utf8) + #expect(throws: InfluxError.malformedResponse(text)) { try InfluxCatalogDecoder.v2BucketNames(wrongShape) } + #expect(throws: InfluxError.malformedResponse(text)) { try InfluxCatalogDecoder.v3Databases(wrongShape) } + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProInfluxDBCoreTests/InfluxResponseDecoderTests.swift b/apple/Packages/TableProCore/Tests/TableProInfluxDBCoreTests/InfluxResponseDecoderTests.swift new file mode 100644 index 0000000000..a1f636ec06 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProInfluxDBCoreTests/InfluxResponseDecoderTests.swift @@ -0,0 +1,332 @@ +import Foundation +@testable import TableProInfluxDBCore +import Testing + +private func bytes(_ text: String) -> Data { + Data(text.utf8) +} + +struct InfluxResponseDecoderTests { + @Test func aNullInAOneColumnV3ResultIsAQuotedEmptyCell() { + let table = InfluxResponseDecoder.decodeV3CSV(bytes("note\n\"\"\n\"hot, \"\"quoted\"\"\"\na;b\n")) + #expect(table == InfluxResultTable( + columns: ["note"], + typeNames: [InfluxTypeName.string], + rows: [[nil], ["hot, \"quoted\""], ["a;b"]] + )) + } + + @Test func aNullV3AggregateIsOneRowOfNil() { + let table = InfluxResponseDecoder.decodeV3CSV(bytes("max(cpu.usage)\n\"\"\n")) + #expect(table.columns == ["max(cpu.usage)"]) + #expect(table.rows == [[nil]]) + } + + @Test func anEmptyV3CellBesideOthersIsNil() { + let table = InfluxResponseDecoder.decodeV3CSV(bytes("host,note\na,\nb,\"hot, \"\"quoted\"\"\"\nc,a;b\n")) + #expect(table.columns == ["host", "note"]) + #expect(table.rows == [["a", nil], ["b", "hot, \"quoted\""], ["c", "a;b"]]) + } + + @Test func aZeroByteV3BodyIsEmpty() { + #expect(InfluxResponseDecoder.decodeV3CSV(Data()) == .empty) + } + + @Test func aV3TimestampWithoutAZoneGetsZ() { + let csv = "time,usage\n2024-10-27T03:33:20,12.5\n2024-10-27T03:34:20.500,50.1\n2024-10-27T03:35:20Z,7.0\n" + let table = InfluxResponseDecoder.decodeV3CSV(bytes(csv)) + #expect(table.typeNames == [InfluxTypeName.timestamp, InfluxTypeName.float]) + #expect(table.column(named: "time") == ["2024-10-27T03:33:20Z", "2024-10-27T03:34:20.500Z", "2024-10-27T03:35:20Z"]) + } + + @Test func v3ColumnTypesAreInferredFromEveryValue() { + let csv = "i,f,b,s,mixed,u\n-3,1.5,true,s,1,18446744073709551615\n4,2,false,t,2.5,\n" + let table = InfluxResponseDecoder.decodeV3CSV(bytes(csv)) + #expect(table.typeNames == [ + InfluxTypeName.integer, + InfluxTypeName.float, + InfluxTypeName.boolean, + InfluxTypeName.string, + InfluxTypeName.float, + InfluxTypeName.unsigned, + ]) + #expect(table.rows == [["-3", "1.5", "true", "s", "1", "18446744073709551615"], ["4", "2", "false", "t", "2.5", nil]]) + } + + @Test func invalidUTF8InOneV3CellKeepsTheOtherRows() { + var body = bytes("note\nok\n") + body.append(contentsOf: [0x61, 0xFF, 0x62, 0x0A]) + body.append(bytes("fine\n")) + let table = InfluxResponseDecoder.decodeV3CSV(body) + #expect(table.rows == [["ok"], ["a\u{FFFD}b"], ["fine"]]) + } + + @Test func v3CSVReadsCRLFLineEnds() { + let table = InfluxResponseDecoder.decodeV3CSV(bytes("host,usage\r\na,1\r\nb,2\r\n")) + #expect(table.columns == ["host", "usage"]) + #expect(table.rows == [["a", "1"], ["b", "2"]]) + } + + @Test func oneV1SeriesIsOneGrid() throws { + let body = bytes(#""" + {"results":[{"statement_id":0,"series":[{"name":"cpu", + "columns":["time","host","idle","note","region","usage","zone"], + "values":[["2024-10-27T03:35:20Z","c",10,"a;b","eu",7,"1"],["2024-10-27T03:34:20Z","b",40,"hot, \"quoted\"","us",50.1,null]]}]}]} + """#) + let table = try InfluxResponseDecoder.decodeV1JSON(body) + #expect(table.columns == ["time", "host", "idle", "note", "region", "usage", "zone"]) + #expect(table.rows == [ + ["2024-10-27T03:35:20Z", "c", "10", "a;b", "eu", "7", "1"], + ["2024-10-27T03:34:20Z", "b", "40", "hot, \"quoted\"", "us", "50.1", nil], + ]) + #expect(table.typeNames[0] == InfluxTypeName.timestamp) + #expect(table.typeNames[1] == InfluxTypeName.string) + #expect(table.typeNames[2] == InfluxTypeName.integer) + } + + @Test func v1GroupByTagsBecomeLeadingTagColumns() throws { + let body = bytes(#""" + {"results":[{"statement_id":0,"series":[ + {"name":"cpu","tags":{"host":"a","region":"eu"},"columns":["time","mean"],"values":[["2024-10-27T03:30:00Z",12.5]]}, + {"name":"cpu","tags":{"host":"b","region":"us"},"columns":["time","mean"],"values":[["2024-10-27T03:30:00Z",50.1]]} + ]}]} + """#) + let table = try InfluxResponseDecoder.decodeV1JSON(body) + #expect(table == InfluxResultTable( + columns: ["host", "region", "time", "mean"], + typeNames: [InfluxTypeName.tag, InfluxTypeName.tag, InfluxTypeName.timestamp, InfluxTypeName.float], + rows: [ + ["a", "eu", "2024-10-27T03:30:00Z", "12.5"], + ["b", "us", "2024-10-27T03:30:00Z", "50.1"], + ] + )) + } + + @Test func v1SeriesWithDifferentNamesAddANameColumnFirst() throws { + let body = bytes(#""" + {"results":[ + {"statement_id":0,"series":[{"name":"cpu","columns":["time","usage"],"values":[["2024-10-27T03:33:20Z",12.5]]}]}, + {"statement_id":1,"series":[{"name":"mem","columns":["time","used"],"values":[["2024-10-27T03:33:20Z",40]]}]}]} + """#) + let table = try InfluxResponseDecoder.decodeV1JSON(body) + #expect(table.columns == ["name", "time", "usage", "used"]) + #expect(table.rows == [ + ["cpu", "2024-10-27T03:33:20Z", "12.5", nil], + ["mem", "2024-10-27T03:33:20Z", nil, "40"], + ]) + } + + @Test func aV1SeriesNameDoesNotOverwriteAValueColumnCalledName() throws { + let body = bytes(#""" + {"results":[ + {"statement_id":0,"series":[{"name":"measurements","columns":["name"],"values":[["cpu"],["mem"]]}]}, + {"statement_id":1,"series":[{"name":"databases","columns":["name"], + "values":[["legacy_db"],["_monitoring"],["_tasks"],["metrics"],["renamed"]]}]}]} + """#) + let table = try InfluxResponseDecoder.decodeV1JSON(body) + #expect(table.columns.count == 2) + #expect(table.rows == [ + ["measurements", "cpu"], + ["measurements", "mem"], + ["databases", "legacy_db"], + ["databases", "_monitoring"], + ["databases", "_tasks"], + ["databases", "metrics"], + ["databases", "renamed"], + ]) + } + + @Test func aV1StatementErrorThrows() { + let body = bytes(#"{"results":[{"statement_id":0,"error":"database not found: nope"}]}"#) + #expect(throws: InfluxError.query("database not found: nope")) { + try InfluxResponseDecoder.decodeV1JSON(body) + } + } + + @Test func aV1TopLevelErrorThrows() { + let body = bytes(#"{"error":"query error: database not found: nope"}"#) + #expect(throws: InfluxError.query("query error: database not found: nope")) { + try InfluxResponseDecoder.decodeV1JSON(body) + } + } + + @Test(arguments: [#"{"results":[{"statement_id":0}]}"#, "{}\n"]) + func aV1BodyWithoutSeriesIsEmpty(body: String) throws { + #expect(try InfluxResponseDecoder.decodeV1JSON(bytes(body)) == .empty) + } + + @Test func aV1BodyThatIsNotJSONThrowsMalformedResponse() { + #expect(throws: InfluxError.malformedResponse("not json")) { + try InfluxResponseDecoder.decodeV1JSON(bytes("not json")) + } + } + + @Test func fluxTablesWithDifferentHeadersUnionTheirColumns() throws { + let body = bytes( + "#datatype,string,long,dateTime:RFC3339,string,double\r\n" + + ",result,table,_time,host,_value\r\n" + + ",_result,0,2024-10-27T03:33:20Z,a,12.5\r\n" + + ",_result,1,2024-10-27T03:34:20Z,b,50.1\r\n" + + "\r\n" + + "#datatype,string,long,dateTime:RFC3339,string,long\r\n" + + ",result,table,_time,region,count\r\n" + + ",_result,2,2024-10-27T03:33:20Z,eu,2\r\n" + + "\r\n" + ) + let table = try InfluxResponseDecoder.decodeFluxCSV(body) + #expect(table == InfluxResultTable( + columns: ["table", "_time", "host", "_value", "region", "count"], + typeNames: [ + InfluxTypeName.integer, + InfluxTypeName.timestamp, + InfluxTypeName.string, + InfluxTypeName.float, + InfluxTypeName.string, + InfluxTypeName.integer, + ], + rows: [ + ["0", "2024-10-27T03:33:20Z", "a", "12.5", nil, nil], + ["1", "2024-10-27T03:34:20Z", "b", "50.1", nil, nil], + ["2", "2024-10-27T03:33:20Z", nil, nil, "eu", "2"], + ] + )) + } + + @Test func aFluxErrorTableThrows() { + let body = bytes( + "#datatype,string,string\r\n" + + ",error,reference\r\n" + + ",\"runtime error @2:4-2:20: filter: type conflict\",\r\n" + + "\r\n" + ) + #expect(throws: InfluxError.query("runtime error @2:4-2:20: filter: type conflict")) { + try InfluxResponseDecoder.decodeFluxCSV(body) + } + } + + @Test func anEmptyFluxBodyIsEmpty() throws { + #expect(try InfluxResponseDecoder.decodeFluxCSV(bytes("\r\n")) == .empty) + } + + @Test func schemaTypesReplaceInferredTypesByColumnName() { + let table = InfluxResultTable( + columns: ["time", "host", "usage", "extra"], + typeNames: [InfluxTypeName.timestamp, InfluxTypeName.string, InfluxTypeName.integer, InfluxTypeName.string], + rows: [["2024-10-27T03:35:20Z", "c", "7", "x"]] + ) + let schema = [ + InfluxColumn(name: "usage", typeName: InfluxTypeName.float, role: .field), + InfluxColumn(name: "host", typeName: InfluxTypeName.tag, role: .tag), + InfluxColumn(name: "time", typeName: InfluxTypeName.timestamp, role: .time), + ] + let typed = table.typed(by: schema) + #expect(typed.typeNames == [InfluxTypeName.timestamp, InfluxTypeName.tag, InfluxTypeName.float, InfluxTypeName.string]) + #expect(typed.rows == table.rows) + } + + @Test(arguments: [ + (InfluxTypeName.tag, "TEXT"), + (InfluxTypeName.string, "TEXT"), + (InfluxTypeName.float, "DOUBLE"), + (InfluxTypeName.integer, "BIGINT"), + (InfluxTypeName.unsigned, "UBIGINT"), + (InfluxTypeName.boolean, "BOOLEAN"), + (InfluxTypeName.timestamp, "TIMESTAMP"), + ]) + func typeNamesClassifyAsTheirSQLSpelling(typeName: String, classification: String) { + #expect(InfluxTypeName.classificationName(for: typeName) == classification) + } + + @Test(arguments: [ + ("Dictionary(Int32, Utf8)", InfluxTypeName.tag, InfluxColumn.Role.tag), + ("Timestamp(Nanosecond, None)", InfluxTypeName.timestamp, .time), + ("Timestamp(ns)", InfluxTypeName.timestamp, .time), + ("Float64", InfluxTypeName.float, .field), + ("Int64", InfluxTypeName.integer, .field), + ("UInt64", InfluxTypeName.unsigned, .field), + ("Boolean", InfluxTypeName.boolean, .field), + ("Utf8", InfluxTypeName.string, .field), + ]) + func v3ArrowTypesMapToInfluxTypes(arrowType: String, typeName: String, role: InfluxColumn.Role) { + let table = InfluxResultTable( + columns: ["column_name", "data_type"], + typeNames: [InfluxTypeName.string, InfluxTypeName.string], + rows: [["c", arrowType]] + ) + #expect(InfluxSchema.v3Columns(from: table) == [InfluxColumn(name: "c", typeName: typeName, role: role)]) + } + + @Test func v3ColumnsPutTimeFirstAndTheRestByName() { + let csv = """ + column_name,data_type + b,Boolean + host,"Dictionary(Int32, Utf8)" + idle,Int64 + note,Utf8 + region,"Dictionary(Int32, Utf8)" + time,Timestamp(ns) + u,UInt64 + usage,Float64 + zone,"Dictionary(Int32, Utf8)" + + """ + let columns = InfluxSchema.v3Columns(from: InfluxResponseDecoder.decodeV3CSV(bytes(csv))) + #expect(columns == [ + InfluxColumn(name: "time", typeName: InfluxTypeName.timestamp, role: .time), + InfluxColumn(name: "b", typeName: InfluxTypeName.boolean, role: .field), + InfluxColumn(name: "host", typeName: InfluxTypeName.tag, role: .tag), + InfluxColumn(name: "idle", typeName: InfluxTypeName.integer, role: .field), + InfluxColumn(name: "note", typeName: InfluxTypeName.string, role: .field), + InfluxColumn(name: "region", typeName: InfluxTypeName.tag, role: .tag), + InfluxColumn(name: "u", typeName: InfluxTypeName.unsigned, role: .field), + InfluxColumn(name: "usage", typeName: InfluxTypeName.float, role: .field), + InfluxColumn(name: "zone", typeName: InfluxTypeName.tag, role: .tag), + ]) + } + + @Test func influxQLColumnsJoinTagAndFieldKeysWithTimeFirst() throws { + let tagKeys = try InfluxResponseDecoder.decodeV1JSON(bytes(#""" + {"results":[{"statement_id":0,"series":[{"name":"cpu","columns":["tagKey"],"values":[["zone"],["host"],["region"]]}]}]} + """#)) + let fieldKeys = try InfluxResponseDecoder.decodeV1JSON(bytes(#""" + {"results":[{"statement_id":0,"series":[{"name":"cpu","columns":["fieldKey","fieldType"],"values":[["usage","float"],["idle","integer"],["note","string"]]}]}]} + """#)) + #expect(InfluxSchema.influxQLColumns(tagKeys: tagKeys, fieldKeys: fieldKeys) == [ + InfluxColumn(name: "time", typeName: InfluxTypeName.timestamp, role: .time), + InfluxColumn(name: "host", typeName: InfluxTypeName.tag, role: .tag), + InfluxColumn(name: "idle", typeName: InfluxTypeName.integer, role: .field), + InfluxColumn(name: "note", typeName: InfluxTypeName.string, role: .field), + InfluxColumn(name: "region", typeName: InfluxTypeName.tag, role: .tag), + InfluxColumn(name: "usage", typeName: InfluxTypeName.float, role: .field), + InfluxColumn(name: "zone", typeName: InfluxTypeName.tag, role: .tag), + ]) + } + + @Test func aMappingThatIsNotTheDefaultReadsTagsOffOnePoint() throws { + let target = InfluxQLTarget(database: "telegraf", retentionPolicy: "weekly", isDefault: false) + #expect(InfluxSchema.measurementsStatement(generation: .v2, target: target).text + == #"SHOW MEASUREMENTS ON "telegraf"."weekly""#) + #expect(InfluxSchema.influxQLTagKeysStatement(measurement: "disk", target: target).text + == #"SELECT * FROM "weekly"."disk" LIMIT 1"#) + #expect(InfluxSchema.influxQLFieldKeysStatement(measurement: "disk", target: target).text + == #"SHOW FIELD KEYS FROM "weekly"."disk""#) + let point = try InfluxResponseDecoder.decodeV1JSON(bytes(#""" + {"results":[{"statement_id":0,"series":[{"name":"disk","columns":["time","host","used"],"values":[["2024-10-27T03:33:20Z","a",1]]}]}]} + """#)) + let fieldKeys = try InfluxResponseDecoder.decodeV1JSON(bytes(#""" + {"results":[{"statement_id":0,"series":[{"name":"disk","columns":["fieldKey","fieldType"],"values":[["used","integer"]]}]}]} + """#)) + #expect(InfluxSchema.influxQLColumns(tagKeys: point, fieldKeys: fieldKeys) == [ + InfluxColumn(name: "time", typeName: InfluxTypeName.timestamp, role: .time), + InfluxColumn(name: "host", typeName: InfluxTypeName.tag, role: .tag), + InfluxColumn(name: "used", typeName: InfluxTypeName.integer, role: .field), + ]) + } + + @Test func aDefaultMappingKeepsShowTagKeys() { + let target = InfluxQLTarget(database: "legacy_db", retentionPolicy: "autogen") + #expect(InfluxSchema.influxQLTagKeysStatement(measurement: "evt", target: target).text + == #"SHOW TAG KEYS FROM "autogen"."evt""#) + #expect(InfluxSchema.measurementsStatement(generation: .v2).text == "SHOW MEASUREMENTS") + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProInfluxDBCoreTests/InfluxRoutingTests.swift b/apple/Packages/TableProCore/Tests/TableProInfluxDBCoreTests/InfluxRoutingTests.swift new file mode 100644 index 0000000000..1d0e86e18c --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProInfluxDBCoreTests/InfluxRoutingTests.swift @@ -0,0 +1,173 @@ +import Foundation +@testable import TableProInfluxDBCore +import Testing + +private let markedStatements: [(String, InfluxGeneration, InfluxQueryLanguage, String)] = [ + ("-- sql\nSELECT 1", .v3, .sql, "\nSELECT 1"), + ("-- influxql\nSHOW MEASUREMENTS", .v2, .influxQL, "\nSHOW MEASUREMENTS"), + ("// flux\nfrom(bucket: \"b\")", .v2, .flux, "\nfrom(bucket: \"b\")"), + ("-- flux\nfrom(bucket: \"b\")", .v2, .flux, "\nfrom(bucket: \"b\")"), + ("// influxql\nSHOW MEASUREMENTS", .v3, .influxQL, "\nSHOW MEASUREMENTS"), + ("\n -- InfluxQL \nSELECT * FROM cpu\nLIMIT 1", .v3, .influxQL, "\n\nSELECT * FROM cpu\nLIMIT 1"), + ("-- influxql\nfrom(bucket: \"b\") |> range(start: -1h)", .v2, .influxQL, "\nfrom(bucket: \"b\") |> range(start: -1h)"), + ("// flux\nSELECT * FROM cpu", .v2, .flux, "\nSELECT * FROM cpu"), +] + +private let unmarkedV3Statements: [(String, InfluxQueryLanguage)] = [ + ("SELECT * FROM cpu", .sql), + ("SHOW TABLES", .sql), + ("SHOW MEASUREMENTS", .influxQL), + ("show tag keys from cpu", .influxQL), + ("SELECT mean(usage) FROM cpu GROUP BY time(5m)", .influxQL), + ("SELECT * FROM cpu WHERE time > now() - 1h", .influxQL), + ("SELECT * FROM cpu WHERE note = 'GROUP BY time(5m)'", .sql), + ("SELECT * FROM cpu WHERE note = 'now() - 1h'", .sql), + ("SELECT 1 AS \"GROUP BY time(5m)\"", .sql), +] + +private let unmarkedV2Statements: [(String, InfluxQueryLanguage)] = [ + ("from(bucket: \"metrics\")\n |> range(start: -1h)", .flux), + ("// last hour\nfrom(bucket: \"metrics\") |> range(start: -1h)", .flux), + ("from(bucket: \"metrics\")", .flux), + ("import \"strings\"\nstrings.toUpper(v: \"a\")", .flux), + ("SELECT * FROM cpu WHERE note = 'a |> b'", .influxQL), + ("SELECT * FROM cpu", .influxQL), + ("SHOW MEASUREMENTS", .influxQL), +] + +private let v2Statements: [String] = markedStatements.filter { $0.1 == .v2 }.map { $0.0 } + + unmarkedV2Statements.map { $0.0 } + +private let pingBody = Data( + #"{"product_name":"InfluxDB 3 Core","version":"3.12.2","revision":"cc9769917a","process_id":"189427e3-c06e-4d02-a9a5-70d920a9d41b"}"# + .utf8 +) + +struct InfluxRoutingTests { + @Test(arguments: markedStatements) + func aFirstLineMarkerPicksTheLanguageAndIsBlanked( + statement: String, + generation: InfluxGeneration, + language: InfluxQueryLanguage, + text: String + ) throws { + let routed = try InfluxStatement.route(statement, generation: generation) + #expect(routed == InfluxStatement(language: language, text: text)) + #expect(routed.text.components(separatedBy: "\n").count == statement.components(separatedBy: "\n").count) + } + + @Test func aMarkerAfterTheFirstLineIsAnOrdinaryComment() throws { + let statement = "SELECT 1\n-- influxql" + #expect(try InfluxStatement.route(statement, generation: .v3) == InfluxStatement(language: .sql, text: statement)) + } + + @Test(arguments: unmarkedV3Statements) + func unmarkedV3TextRoutesSQLUnlessItUsesInfluxQLOnlySyntax(statement: String, language: InfluxQueryLanguage) throws { + let routed = try InfluxStatement.route(statement, generation: .v3) + #expect(routed == InfluxStatement(language: language, text: statement)) + } + + @Test(arguments: unmarkedV2Statements) + func unmarkedV2TextRoutesFluxOnlyWhenItLooksLikeFlux(statement: String, language: InfluxQueryLanguage) throws { + let routed = try InfluxStatement.route(statement, generation: .v2) + #expect(routed == InfluxStatement(language: language, text: statement)) + } + + @Test(arguments: [ + ("-- sql\nSELECT 1", InfluxGeneration.v2, InfluxQueryLanguage.sql), + ("from(bucket: \"b\") |> range(start: -1h)", .v3, .flux), + ("// flux\nfrom(bucket: \"b\")", .v3, .flux), + ]) + func aLanguageTheGenerationLacksIsRefused( + statement: String, + generation: InfluxGeneration, + language: InfluxQueryLanguage + ) { + #expect(throws: InfluxError.unsupportedLanguage(language, generation: generation)) { + try InfluxStatement.route(statement, generation: generation) + } + } + + @Test(arguments: v2Statements) + func isFluxAgreesWithRouteOnV2(statement: String) throws { + let routed = try InfluxStatement.route(statement, generation: .v2) + #expect(InfluxStatement.isFlux(statement) == (routed.language == .flux)) + } + + @Test(arguments: InfluxQueryLanguage.allCases) + func markedTextRoutesBackToItsLanguage(language: InfluxQueryLanguage) throws { + let marked = InfluxStatement.marked("SELECT 1", language: language) + #expect(marked == "-- \(language.rawValue)\nSELECT 1") + let generation: InfluxGeneration = language == .flux ? .v2 : .v3 + #expect(try InfluxStatement.route(marked, generation: generation) == InfluxStatement( + language: language, + text: "\nSELECT 1" + )) + } + + @Test(arguments: [("v2.9.1", InfluxGeneration.v2), ("3.12.2", .v3), ("4.0.0", .v3)]) + func theGenerationComesFromTheVersionString(version: String, generation: InfluxGeneration) { + #expect(InfluxGeneration(version: version) == generation) + } + + @Test func aOneXServerIsRefusedByName() { + #expect(throws: InfluxError.unsupportedVersion("1.11.8")) { + try InfluxServerInfo(versionHeader: "1.11.8", pingBody: Data()) + } + } + + @Test(arguments: ["", "abc", "1.11.8"]) + func aVersionWithoutAMajorNumberNamesNoGeneration(version: String) { + #expect(InfluxGeneration(version: version) == nil) + } + + @Test func theVersionHeaderNamesTheServer() throws { + let info = try InfluxServerInfo(versionHeader: "v2.9.1", pingBody: Data()) + #expect(info == InfluxServerInfo(generation: .v2, version: "v2.9.1")) + } + + @Test(arguments: [nil, ""] as [String?]) + func withoutAVersionHeaderThePingBodyNamesTheServer(header: String?) throws { + let info = try InfluxServerInfo(versionHeader: header, pingBody: pingBody) + #expect(info == InfluxServerInfo(generation: .v3, version: "3.12.2")) + } + + @Test(arguments: [Data(), Data(#"{"error": "the request was not authenticated"}"#.utf8)]) + func neitherHeaderNorBodyNamingAVersionThrows(body: Data) { + #expect(throws: InfluxError.unknownVersion) { + try InfluxServerInfo(versionHeader: nil, pingBody: body) + } + } + + @Test(arguments: [ + "SELECT host FROM cpu WHERE host =~ /--/; DELETE FROM cpu", + "SELECT /'/ FROM cpu; DELETE FROM cpu -- '", + "SELECT * FROM cpu GROUP BY /'/; DROP MEASUREMENT cpu -- '", + "SELECT * FROM cpu WHERE host =~ /--/ AND value > 1./1; DELETE FROM cpu", + "SHOW MEASUREMENTS; SHOW DATABASES", + "SELECT * FROM cpu WHERE note = 'a;b'", + "/* note\n; still a comment\n*/\nSHOW MEASUREMENTS", + "SELECT * FROM cpu;\u{301} DELETE FROM cpu", + ]) + func influxQLWithASemicolonBeforeItsEndIsRefused(statement: String) { + #expect(throws: InfluxError.multipleStatements) { + try InfluxStatement.route(statement, generation: .v2) + } + } + + @Test(arguments: ["SELECT usage / 2 FROM cpu;", "SELECT * FROM cpu ; \n", "SELECT * FROM cpu WHERE host =~ /a|b/"]) + func influxQLEndingInASemicolonRoutes(statement: String) throws { + #expect(try InfluxStatement.route(statement, generation: .v2).language == .influxQL) + } + + @Test func aQuoteInsideAFluxCommentDoesNotHideThePipeline() throws { + let flux = "start = -1h // user's range\nfrom(bucket: \"metrics\") |> range(start: start)" + #expect(try InfluxStatement.route(flux, generation: .v2).language == .flux) + #expect(InfluxStatement.isFlux(flux)) + } + + @Test func aQuoteInsideAFluxRegexDoesNotHideThePipeline() throws { + let flux = "p = /'/\nfrom(bucket: \"metrics\") |> range(start: -1h) |> filter(fn: (r) => r.host =~ p)" + #expect(try InfluxStatement.route(flux, generation: .v2).language == .flux) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLLexicalReadingsTests.swift b/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLLexicalReadingsTests.swift index 5ccd8bf9df..5986f169b4 100644 --- a/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLLexicalReadingsTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLLexicalReadingsTests.swift @@ -11,7 +11,7 @@ struct SQLLexicalReadingsTests { "SQLite", "libSQL", "Turso", "Cloudflare D1", "DuckDB", "Oracle", "Dameng", "SQL Server", "ClickHouse", "Snowflake", "BigQuery", "Spanner", "Trino", "Presto", "Teradata", "SAP HANA", "Cassandra", "ScyllaDB", "DynamoDB", "SurrealDB", "Redis", "MongoDB", "etcd", "Elasticsearch", "Typesense", "Weaviate", "Kafka", - "Firestore", "Firebase Realtime Database", "Bigtable", + "Firestore", "Firebase Realtime Database", "Bigtable", "InfluxDB", ] let missing = shipped.filter { SQLLexicalProfile.curated(forDatabaseTypeId: $0) == nil } #expect(missing.isEmpty) @@ -112,6 +112,17 @@ struct SQLLexicalReadingsTests { #expect(counts.max() == 3) } + @Test("InfluxDB splits InfluxQL's backslash-escaped quote and Flux's `//` comment as one statement each") + func influxDBKeepsInfluxQLEscapesAndFluxComments() { + let readings = SQLLexicalReadings.resolve(databaseTypeId: "InfluxDB", declared: nil, session: nil) + let influxQL = #"SELECT * FROM cpu WHERE note = 'it\'s; x'; SHOW MEASUREMENTS"# + let flux = "// don't; split\nfrom(bucket: \"m\") |> range(start: -1h)" + + #expect(readings.all.contains(.ansi)) + #expect(SQLStatementScanner.executableStatements(in: influxQL, grammar: readings.execution).count == 2) + #expect(SQLStatementScanner.executableStatements(in: flux, grammar: readings.execution).count == 1) + } + @Test("SQL Server's `1E--` is read as a comment and as a signed exponent, and any other `--` once") func sqlServerReadsASignedEmptyExponentBothWays() { let readings = SQLLexicalReadings.resolve(databaseTypeId: "SQL Server", declared: nil, session: nil) diff --git a/apple/Plugins/InfluxDBDriverPlugin/InfluxDBConnection.swift b/apple/Plugins/InfluxDBDriverPlugin/InfluxDBConnection.swift new file mode 100644 index 0000000000..1f73cebab5 --- /dev/null +++ b/apple/Plugins/InfluxDBDriverPlugin/InfluxDBConnection.swift @@ -0,0 +1,134 @@ +import Foundation +import TableProInfluxDBCore +import TableProPluginKit + +internal struct InfluxDBResponse { + let statusCode: Int + let headers: [AnyHashable: Any] + let data: Data + + var isSuccess: Bool { (200 ..< 300).contains(statusCode) } + + func header(_ name: String) -> String? { + headers.first { ($0.key as? String)?.caseInsensitiveCompare(name) == .orderedSame }?.value as? String + } + + var error: InfluxError { + let message = InfluxResponseDecoder.errorMessage(from: data) + if statusCode == 401 || statusCode == 403 { return .unauthorized(message) } + return .server(status: statusCode, message: message) + } +} + +internal final class InfluxDBConnection: @unchecked Sendable { + let baseURL: URL + let credentials: InfluxCredentials + + private let lock = NSLock() + private var session: URLSession? + private var inFlight: [ObjectIdentifier: PluginURLSessionTaskBox] = [:] + private let queryTimeout: HttpQueryTimeoutBox + + init( + baseURL: URL, + credentials: InfluxCredentials, + skipTLSVerify: Bool, + connectTimeout: TimeInterval, + queryTimeout: HttpQueryTimeoutBox + ) { + self.baseURL = baseURL + self.credentials = credentials + self.queryTimeout = queryTimeout + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = connectTimeout + configuration.timeoutIntervalForResource = HttpQueryTimeout.sessionResourceTimeout + let delegate = skipTLSVerify ? InfluxDBTrustingDelegate() : PluginRedirectRefusingDelegate() + session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil) + } + + func close() { + cancelAll() + lock.withLock { + session?.invalidateAndCancel() + session = nil + } + } + + func cancelAll() { + let boxes = lock.withLock { + defer { inFlight.removeAll() } + return Array(inFlight.values) + } + boxes.forEach { $0.cancel() } + } + + func send(_ request: InfluxRequest, timeout: TimeInterval? = nil) async throws -> InfluxDBResponse { + guard let url = request.url(base: baseURL) else { + throw InfluxError.invalidURL(baseURL.absoluteString + request.path) + } + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = request.method + urlRequest.httpBody = request.body + urlRequest.timeoutInterval = timeout ?? queryTimeout.requestTimeoutInterval + for (name, value) in request.headers { + urlRequest.setValue(value, forHTTPHeaderField: name) + } + + let taskBox = PluginURLSessionTaskBox() + let key = ObjectIdentifier(taskBox) + lock.withLock { inFlight[key] = taskBox } + defer { lock.withLock { _ = inFlight.removeValue(forKey: key) } } + return try await withTaskCancellationHandler { + try await perform(urlRequest, taskBox: taskBox) + } onCancel: { + taskBox.cancel() + } + } + + private func perform(_ request: URLRequest, taskBox: PluginURLSessionTaskBox) async throws -> InfluxDBResponse { + try await withCheckedThrowingContinuation { continuation in + // Made under the lock `close()` invalidates with: a task asked of an invalidated session raises. + let task: URLSessionDataTask? = lock.withLock { + session?.dataTask(with: request) { data, response, error in + taskBox.finish() + if let error { + let cancelled = (error as? URLError)?.code == .cancelled + continuation.resume(throwing: cancelled ? CancellationError() : error) + return + } + guard let http = response as? HTTPURLResponse else { + continuation.resume(throwing: InfluxError.malformedResponse("Not an HTTP response")) + return + } + continuation.resume(returning: InfluxDBResponse( + statusCode: http.statusCode, + headers: http.allHeaderFields, + data: data ?? Data() + )) + } + } + guard let task else { + continuation.resume(throwing: InfluxError.notConnected) + return + } + taskBox.set(task) + task.resume() + } + } +} + +private final class InfluxDBTrustingDelegate: PluginRedirectRefusingDelegate, @unchecked Sendable { + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let trust = challenge.protectionSpace.serverTrust + else { + completionHandler(.performDefaultHandling, nil) + return + } + completionHandler(.useCredential, URLCredential(trust: trust)) + } +} diff --git a/apple/Plugins/InfluxDBDriverPlugin/InfluxDBPlugin.swift b/apple/Plugins/InfluxDBDriverPlugin/InfluxDBPlugin.swift new file mode 100644 index 0000000000..ace8b3f116 --- /dev/null +++ b/apple/Plugins/InfluxDBDriverPlugin/InfluxDBPlugin.swift @@ -0,0 +1,87 @@ +import Foundation +import TableProInfluxDBCore +import TableProPluginKit + +typealias InfluxDBFieldID = InfluxConnectionFieldID + +final class InfluxDBPlugin: NSObject, TableProPlugin, DriverPlugin { + static let pluginName = "InfluxDB Driver" + static let pluginVersion = "1.0.0" + static let pluginDescription = "InfluxDB 2 and 3 over the HTTP API, with SQL, InfluxQL and Flux" + static let capabilities: [PluginCapability] = [.databaseDriver] + + static let databaseTypeId = "InfluxDB" + static let databaseDisplayName = "InfluxDB" + static let iconName = "influxdb-icon" + static let defaultPort = 8_086 + static let isDownloadable = true + + static let navigationModel: NavigationModel = .standard + static let pathFieldRole: PathFieldRole = .database + static let requiresAuthentication = false + static let brandColorHex = "#22ADF6" + static let queryLanguageName = "SQL" + static let editorLanguage: EditorLanguage = .sql + static let supportsForeignKeys = false + static let supportsSchemaEditing = false + static let supportsDatabaseSwitching = true + static let requiresReconnectForDatabaseSwitch = false + static let supportsImport = false + static let supportsExport = true + static let supportsSSH = true + static let supportsSSL = true + static let supportsReadOnlyMode = true + static let supportsHealthMonitor = true + static let supportsForeignKeyDisable = false + static let supportsAddColumn = false + static let supportsModifyColumn = false + static let supportsDropColumn = false + static let supportsAddIndex = false + static let supportsDropIndex = false + static let supportsModifyPrimaryKey = false + static let databaseGroupingStrategy: GroupingStrategy = .flat + static let defaultGroupName = "default" + static let tableEntityName = "Measurements" + static let containerEntityName = "Database" + static let systemDatabaseNames: [String] = InfluxCatalogDecoder.systemDatabaseNames + static let structureColumnFields: [StructureColumnField] = [.name, .type, .nullable, .comment] + static let sqlDialect: SQLDialectDescriptor? = nil + + static let columnTypesByCategory: [String: [String]] = InfluxCompletions.columnTypesByCategory + static let additionalConnectionFields: [ConnectionField] = influxDBConnectionFields() + static var statementCompletions: [CompletionEntry] { + InfluxCompletions.entries.map { CompletionEntry(label: $0.label, insertText: $0.insertText) } + } + + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { + InfluxDBPluginDriver(config: config) + } +} + +// Kept identical to `influxDBConnectionFields()` in the app's built-in metadata, which the form shows before the +// plugin loads. +func influxDBConnectionFields() -> [ConnectionField] { + [ + ConnectionField( + id: InfluxDBFieldID.token, + label: String(localized: "Token"), + placeholder: "API token", + fieldType: .secure, + section: .authentication, + hidesPassword: true + ).withHidesUsername(true), + ConnectionField( + id: InfluxDBFieldID.organization, + label: String(localized: "Organization"), + placeholder: "InfluxDB 2 only, optional with one organization", + section: .connection + ), + ConnectionField( + id: InfluxDBFieldID.skipTLSVerify, + label: String(localized: "Skip TLS Verification"), + defaultValue: "false", + fieldType: .toggle, + section: .advanced + ), + ] +} diff --git a/apple/Plugins/InfluxDBDriverPlugin/InfluxDBPluginDriver+Metadata.swift b/apple/Plugins/InfluxDBDriverPlugin/InfluxDBPluginDriver+Metadata.swift new file mode 100644 index 0000000000..0d60b0688d --- /dev/null +++ b/apple/Plugins/InfluxDBDriverPlugin/InfluxDBPluginDriver+Metadata.swift @@ -0,0 +1,193 @@ +import Foundation +import TableProInfluxDBCore +import TableProPluginKit + +extension InfluxDBPluginDriver { + func fetchDatabases() async throws -> [String] { + let (connection, server) = try requireConnection() + switch server.generation { + case .v3: + let response = try await connection.send(InfluxRequests.v3Databases(credentials: connection.credentials)) + guard response.isSuccess else { throw response.error } + return try InfluxCatalogDecoder.v3Databases(response.data) + case .v2: + let organization = currentOrganization + var names: [String] = [] + while true { + let response = try await connection.send(InfluxRequests.v2Buckets( + organization: organization, credentials: connection.credentials, offset: names.count + )) + guard response.isSuccess else { throw response.error } + let page = try InfluxCatalogDecoder.v2BucketNames(response.data) + names += page + if page.count < InfluxRequests.bucketPageSize { break } + } + return names.sorted() + } + } + + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } + + // An external table is how the host learns a measurement takes no row edit, drop or truncate: InfluxDB has no + // UPDATE, and a point is keyed by its series and time, which the grid's SQL DELETE cannot name. + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { + guard currentDatabase != nil else { return [] } + let (_, server) = try requireConnection() + let target = try await influxQLTarget() + let table = try await run(InfluxSchema.measurementsStatement(generation: server.generation, target: target)) + return InfluxSchema.measurements(from: table, generation: server.generation) + .map { PluginTableInfo(name: $0, type: "EXTERNAL TABLE") } + } + + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { + try await loadColumns(of: table).map { column in + Self.columnInfo(name: column.name, typeName: column.typeName, role: column.role) + } + } + + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { + [] + } + + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { + [] + } + + // InfluxDB has no DDL: a measurement exists once a point is written to it. + func fetchTableDDL(table: String, schema: String?) async throws -> String { + let columns = try await loadColumns(of: table) + let width = columns.map(\.name.count).max() ?? 0 + let lines = columns.map { column -> String in + let role: String + switch column.role { + case .time: role = "time" + case .tag: role = "tag" + case .field: role = "field" + } + let padding = String(repeating: " ", count: width - column.name.count) + return "-- \(column.name)\(padding) \(role) \(column.typeName)" + } + return (["-- Measurement \(table)"] + lines).joined(separator: "\n") + } + + func fetchViewDefinition(view: String, schema: String?) async throws -> String { + throw InfluxError.query(String(localized: "InfluxDB has no views.")) + } + + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table, engine: "InfluxDB") + } + + // MARK: - Grid + + func buildBrowseQuery( + table: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? { + gridStatement( + InfluxGridQuery( + measurement: table, + projection: columns, + sorts: Self.sorts(sortColumns, columns: columns), + shape: .page(limit: limit, offset: offset) + ), + hostKinds: [:] + ) + } + + 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? { + gridStatement( + InfluxGridQuery( + measurement: table, + projection: columns, + filters: Self.filters(queryFilters), + matchesAll: logicMode.lowercased() != "or", + sorts: Self.sorts(sortColumns, columns: columns), + shape: .page(limit: limit, offset: offset) + ), + hostKinds: columnKinds + ) + } + + // InfluxQL counts per field, and a point may hold any subset of them, so only a one-field measurement's count is + // the number of points. + func fetchExactRowCount( + table: String, + schema: String?, + queryFilters: [PluginQueryFilter], + logicMode: String + ) async throws -> Int? { + let schema = try await columns(of: table) + let (_, server) = try requireConnection() + let language = server.generation.gridLanguage + if language == .influxQL, schema.filter({ $0.role == .field }).count != 1 { + return nil + } + let grid = InfluxGridQuery( + measurement: table, + filters: Self.filters(queryFilters), + matchesAll: logicMode.lowercased() != "or", + shape: .count + ) + guard let text = grid.statement(language: language, kinds: Self.kinds(of: schema), marked: false) else { + return nil + } + let result = try await run(InfluxStatement(language: language, text: text)) + return InfluxGridQuery.rowCount(from: result) + } + + func defaultExportQuery(table: String) -> String? { + gridStatement(InfluxGridQuery(measurement: table, shape: .all), hostKinds: [:]) + } + + // Declining every change is what makes the host refuse a save instead of writing its own SQL DML. + func generateRowWrites( + table: String, + schema: String?, + columns: [String], + primaryKeyColumns: [String], + changes: [PluginRowChange], + insertedRowData: [Int: [PluginCellValue]], + deletedRowIndices: Set, + insertedRowIndices: Set + ) throws -> [PluginRowWrite]? { + throw PluginRowWriteRefusal( + rowIndex: changes.first?.rowIndex ?? 0, + reason: String(localized: "InfluxDB points cannot be edited in TablePro.") + ) + } + + private static func sorts(_ sortColumns: [(columnIndex: Int, ascending: Bool)], columns: [String]) -> [InfluxSort] { + sortColumns.compactMap { sort in + guard columns.indices.contains(sort.columnIndex) else { return nil } + return InfluxSort(column: columns[sort.columnIndex], ascending: sort.ascending) + } + } + + private static func filters(_ queryFilters: [PluginQueryFilter]) -> [InfluxFilter] { + queryFilters.map { filter in + InfluxFilter( + column: filter.column, + op: filter.op, + value: filter.value, + secondValue: filter.secondValue, + isCaseSensitive: filter.isCaseSensitive + ) + } + } +} diff --git a/apple/Plugins/InfluxDBDriverPlugin/InfluxDBPluginDriver.swift b/apple/Plugins/InfluxDBDriverPlugin/InfluxDBPluginDriver.swift new file mode 100644 index 0000000000..f33e85c768 --- /dev/null +++ b/apple/Plugins/InfluxDBDriverPlugin/InfluxDBPluginDriver.swift @@ -0,0 +1,388 @@ +import Foundation +import TableProInfluxDBCore +import TableProPluginKit + +internal final class InfluxDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { + private let config: DriverConnectionConfig + private let lock = NSLock() + private var connection: InfluxDBConnection? + private var server: InfluxServerInfo? + private var organization: String + private var database: String + private var schemas: [SchemaKey: [InfluxColumn]] = [:] + private var influxQLTargets: [String: InfluxQLTarget] = [:] + private var gridQueries = GridQueryMemo() + let queryTimeout = HttpQueryTimeoutBox() + + private struct SchemaKey: Hashable { + let database: String + let measurement: String + } + + init(config: DriverConnectionConfig) { + self.config = config + self.organization = (config.additionalFields[InfluxDBFieldID.organization] ?? "") + .trimmingCharacters(in: .whitespaces) + self.database = config.database.trimmingCharacters(in: .whitespaces) + } + + var serverVersion: String? { lock.withLock { server?.version } } + var supportsTransactions: Bool { false } + var capabilities: PluginCapabilities { [.cancelQuery] } + var currentDatabase: String? { lock.withLock { database.isEmpty ? nil : database } } + + func beginTransaction() async throws {} + func commitTransaction() async throws {} + func rollbackTransaction() async throws {} + + // MARK: - Lifecycle + + func connect() async throws { + let scheme = config.ssl.isEnabled ? "https" : "http" + let host = config.host.isEmpty ? "localhost" : config.host + let port = config.port > 0 ? config.port : InfluxDBPlugin.defaultPort + let address = "\(scheme)://\(host.contains(":") ? "[\(host)]" : host):\(port)" + guard let baseURL = URL(string: address) else { throw InfluxError.invalidURL(address) } + + let credentials = InfluxCredentials(token: config.additionalFields[InfluxDBFieldID.token]) + let connectMilliseconds = PluginConnectTimeout.milliseconds( + in: config.additionalFields, + default: Int(HttpQueryTimeout.sessionBootstrapRequestTimeout * 1_000) + ) + let deadline = PluginConnectDeadline(milliseconds: connectMilliseconds) + let connection = InfluxDBConnection( + baseURL: baseURL, + credentials: credentials, + skipTLSVerify: config.additionalFields[InfluxDBFieldID.skipTLSVerify] == "true" + || (config.ssl.isEnabled && !config.ssl.verifiesCertificate), + connectTimeout: TimeInterval(connectMilliseconds) / 1_000, + queryTimeout: queryTimeout + ) + + do { + let ping = try await connection.send( + InfluxRequests.ping(credentials: credentials), + timeout: deadline.remainingSeconds() + ) + guard ping.isSuccess else { throw ping.error } + let server = try InfluxServerInfo(versionHeader: ping.header("X-Influxdb-Version"), pingBody: ping.data) + var organization = lock.withLock { self.organization } + if server.generation == .v2, organization.isEmpty { + organization = try await onlyOrganization(connection, timeout: deadline.remainingSeconds()) + } + try await checkToken(connection, server: server, organization: organization, timeout: deadline.remainingSeconds()) + lock.withLock { + self.connection = connection + self.server = server + self.organization = organization + schemas.removeAll() + influxQLTargets.removeAll() + gridQueries = GridQueryMemo() + } + } catch { + connection.close() + throw error + } + } + + func disconnect() { + let current = lock.withLock { + defer { + connection = nil + server = nil + schemas.removeAll() + influxQLTargets.removeAll() + gridQueries = GridQueryMemo() + } + return connection + } + current?.close() + } + + func ping() async throws { + let (connection, server) = try requireConnection() + try await checkToken(connection, server: server, organization: currentOrganization, timeout: nil) + } + + func cancelQuery() throws { + lock.withLock { connection }?.cancelAll() + } + + func applyQueryTimeout(_ seconds: Int) async throws { + queryTimeout.set(serverTimeoutSeconds: seconds) + } + + func switchDatabase(to database: String) async throws { + lock.withLock { self.database = database } + } + + // 3.x checks the token on `/ping` itself; 2.x answers `/ping` without one. + private func checkToken( + _ connection: InfluxDBConnection, + server: InfluxServerInfo, + organization: String, + timeout: TimeInterval? + ) async throws { + let request: InfluxRequest + switch server.generation { + case .v3: + request = InfluxRequests.ping(credentials: connection.credentials, generation: .v3) + case .v2: + request = InfluxRequests.v2TokenCheck(organization: organization, credentials: connection.credentials) + } + let response = try await connection.send(request, timeout: timeout) + guard response.isSuccess else { throw response.error } + } + + // 2.x scopes buckets and Flux to an organization; a token that sees exactly one needs no setting. + private func onlyOrganization(_ connection: InfluxDBConnection, timeout: TimeInterval) async throws -> String { + let response = try await connection.send( + InfluxRequests.v2Organizations(credentials: connection.credentials), + timeout: timeout + ) + guard response.isSuccess else { throw response.error } + let names = try InfluxCatalogDecoder.v2Organizations(response.data) + guard names.count == 1, let only = names.first else { throw InfluxError.missingOrganization } + return only + } + + // MARK: - Queries + + func execute(query: String) async throws -> PluginQueryResult { + let started = Date() + let (_, server) = try requireConnection() + let table: InfluxResultTable + if let remembered = lock.withLock({ gridQueries[query] }) { + table = try await runGrid(remembered.query, hostKinds: remembered.hostKinds, generation: server.generation) + } else { + table = try await run(InfluxStatement.route(query, generation: server.generation)) + } + return PluginQueryResult( + columns: table.columns, + columnTypeNames: table.typeNames, + rows: table.rows.map { row in row.map { $0.map(PluginCellValue.text) ?? .null } }, + rowsAffected: 0, + executionTime: Date().timeIntervalSince(started), + columnMeta: zip(table.columns, table.typeNames).map { name, typeName in + Self.columnInfo(name: name, typeName: typeName, role: nil) + } + ) + } + + // A grid statement built before the measurement's columns were known is written again from them here. + // A filter on a column the cached schema lacks means the measurement gained one since; the schema is read again. + private func runGrid( + _ grid: InfluxGridQuery, + hostKinds: [String: InfluxColumnKind], + generation: InfluxGeneration + ) async throws -> InfluxResultTable { + var schema = try await columns(of: grid.measurement) + let known = Set(schema.map(\.name)) + if grid.filters.contains(where: { !known.contains($0.column) }) { + schema = try await loadColumns(of: grid.measurement) + } + let kinds = hostKinds.merging(Self.kinds(of: schema)) { _, schema in schema } + let language = generation.gridLanguage + guard let text = grid.statement(language: language, kinds: kinds, marked: false) else { + throw InfluxError.filterNotExpressible(language) + } + var table = try await run(InfluxStatement(language: language, text: text)).typed(by: schema) + if table.columns.isEmpty { + let names = grid.projection.isEmpty ? schema.map(\.name) : grid.projection + let types = Dictionary(schema.map { ($0.name, $0.typeName) }, uniquingKeysWith: { first, _ in first }) + table = InfluxResultTable( + columns: names, + typeNames: names.map { types[$0] ?? InfluxTypeName.string }, + rows: [] + ) + } + return table + } + + func run(_ statement: InfluxStatement) async throws -> InfluxResultTable { + let (connection, server) = try requireConnection() + let (database, organization) = lock.withLock { (self.database, self.organization) } + if server.generation == .v3, statement.language == .sql, database.isEmpty { + throw InfluxError.missingDatabase + } + if statement.language == .flux, organization.isEmpty { + throw InfluxError.missingOrganization + } + if statement.language == .flux { + try await checkFluxReading(statement.text, organization: organization, connection: connection) + } + var target = InfluxQLTarget(database: database, retentionPolicy: nil) + if statement.language == .influxQL, let resolved = try await influxQLTarget() { + target = resolved + } + let request = try InfluxRequests.query( + statement, + database: target.database, + retentionPolicy: target.retentionPolicy, + organization: organization, + generation: server.generation, + credentials: connection.credentials + ) + let response = try await connection.send(request) + guard response.isSuccess else { throw response.error } + switch (server.generation, statement.language) { + case (.v3, _): + return InfluxResponseDecoder.decodeV3CSV(response.data) + case (_, .flux): + return try InfluxResponseDecoder.decodeFluxCSV(response.data) + default: + return try InfluxResponseDecoder.decodeV1JSON(response.data) + } + } + + // Safe Mode read this script with TablePro's lexer. The server parses it first, and a script it reads as writing or + // reaching outside where the lexer did not is refused, since the gate's verdict rested on the lexer. + private func checkFluxReading(_ script: String, organization: String, connection: InfluxDBConnection) async throws { + let response = try await connection.send( + InfluxRequests.v2FluxAST(script, organization: organization, credentials: connection.credentials) + ) + guard response.isSuccess else { throw response.error } + let server = try FluxAST.reading(response.data) + let local = FluxScriptReading(script) + if (server.writes && !local.writes) || (server.reachesOutside && !local.reachesOutside) { + throw InfluxError.fluxReadDiffers + } + } + + // A lookup that fails leaves the bucket name, which the virtual mapping of any bucket without a `/` still resolves. + func influxQLTarget() async throws -> InfluxQLTarget? { + guard let (connection, server) = try? requireConnection(), server.generation == .v2 else { return nil } + let (bucket, organization) = lock.withLock { (database, self.organization) } + guard !bucket.isEmpty else { return nil } + if let known = lock.withLock({ influxQLTargets[bucket] }) { return known } + let fallback = InfluxQLTarget(database: bucket, retentionPolicy: nil) + do { + let found = try await connection.send( + InfluxRequests.v2Bucket(named: bucket, organization: organization, credentials: connection.credentials) + ) + guard found.isSuccess, let id = try InfluxCatalogDecoder.v2BucketID(found.data) else { return fallback } + let mappings = try await connection.send( + InfluxRequests.v2DBRPs(bucketID: id, organization: organization, credentials: connection.credentials) + ) + guard mappings.isSuccess, let target = try InfluxCatalogDecoder.v2InfluxQLTarget(mappings.data) else { + return fallback + } + lock.withLock { influxQLTargets[bucket] = target } + return target + } catch is CancellationError { + throw CancellationError() + } catch { + return fallback + } + } + + func requireConnection() throws -> (InfluxDBConnection, InfluxServerInfo) { + guard let pair = lock.withLock({ connection.flatMap { conn in server.map { (conn, $0) } } }) else { + throw InfluxError.notConnected + } + return pair + } + + var currentOrganization: String { + lock.withLock { organization } + } + + var gridLanguage: InfluxQueryLanguage? { + lock.withLock { server?.generation.gridLanguage } + } + + // MARK: - Schema and grid statements + + func columns(of measurement: String) async throws -> [InfluxColumn] { + let key = lock.withLock { SchemaKey(database: database, measurement: measurement) } + if let cached = lock.withLock({ schemas[key] }) { return cached } + return try await loadColumns(of: measurement) + } + + func loadColumns(of measurement: String) async throws -> [InfluxColumn] { + let (_, server) = try requireConnection() + let key = lock.withLock { SchemaKey(database: database, measurement: measurement) } + let columns: [InfluxColumn] + switch server.generation { + case .v3: + columns = InfluxSchema.v3Columns(from: try await run(InfluxSchema.v3ColumnsStatement(measurement: measurement))) + case .v2: + let target = try await influxQLTarget() + async let tags = run(InfluxSchema.influxQLTagKeysStatement(measurement: measurement, target: target)) + async let fields = run(InfluxSchema.influxQLFieldKeysStatement(measurement: measurement, target: target)) + columns = InfluxSchema.influxQLColumns(tagKeys: try await tags, fieldKeys: try await fields) + } + lock.withLock { schemas[key] = columns } + return columns + } + + func gridStatement(_ grid: InfluxGridQuery, hostKinds: [String: PluginColumnKind]) -> String? { + let key = lock.withLock { SchemaKey(database: database, measurement: grid.measurement) } + let fromHost = hostKinds.compactMapValues(Self.kind(of:)) + var kinds = fromHost + if let schema = lock.withLock({ schemas[key] }) { + kinds.merge(Self.kinds(of: schema)) { _, schema in schema } + } + let language = gridLanguage + guard let statement = grid.statement(language: language ?? .influxQL, kinds: kinds, marked: language != nil) + else { return nil } + lock.withLock { gridQueries[statement] = RememberedGridQuery(query: grid, hostKinds: fromHost) } + return statement + } + + static func kinds(of schema: [InfluxColumn]) -> [String: InfluxColumnKind] { + Dictionary(schema.map { ($0.name, $0.kind) }, uniquingKeysWith: { first, _ in first }) + } + + private static func kind(of hostKind: PluginColumnKind) -> InfluxColumnKind? { + switch hostKind { + case .text: return .text + case .integer, .decimal: return .number + case .boolean: return .boolean + case .other: return nil + @unknown default: return nil + } + } + + static func columnInfo(name: String, typeName: String, role: InfluxColumn.Role?) -> PluginColumnInfo { + PluginColumnInfo( + name: name, + dataType: typeName, + isNullable: role != .time, + comment: role == .tag ? "tag" : nil, + generationExpression: nil, + generationKind: nil, + ddlSpelling: nil, + ddlDefault: nil, + ddlGenerationExpression: nil, + ddlCollation: nil, + classificationTypeName: InfluxTypeName.classificationName(for: typeName), + columnCheck: nil + ) + } +} + +// The statements the driver built for its grid, so `execute` runs them knowing the measurement they read. +private struct RememberedGridQuery { + let query: InfluxGridQuery + let hostKinds: [String: InfluxColumnKind] +} + +private struct GridQueryMemo { + private var queries: [String: RememberedGridQuery] = [:] + private var order: [String] = [] + private static let capacity = 64 + + subscript(statement: String) -> RememberedGridQuery? { + get { queries[statement] } + set { + guard let newValue else { return } + if queries.updateValue(newValue, forKey: statement) == nil { + order.append(statement) + } + if order.count > Self.capacity { + queries.removeValue(forKey: order.removeFirst()) + } + } + } +} diff --git a/apple/Plugins/InfluxDBDriverPlugin/Info.plist b/apple/Plugins/InfluxDBDriverPlugin/Info.plist new file mode 100644 index 0000000000..a472fa3961 --- /dev/null +++ b/apple/Plugins/InfluxDBDriverPlugin/Info.plist @@ -0,0 +1,12 @@ + + + + + TableProPluginKitVersion + TABLEPRO_PLUGINKIT_VERSION + TableProProvidesDatabaseTypeIds + + InfluxDB + + + diff --git a/apple/TablePro/Assets.xcassets/influxdb-icon.imageset/Contents.json b/apple/TablePro/Assets.xcassets/influxdb-icon.imageset/Contents.json new file mode 100644 index 0000000000..178aadb462 --- /dev/null +++ b/apple/TablePro/Assets.xcassets/influxdb-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "influxdb.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/apple/TablePro/Assets.xcassets/influxdb-icon.imageset/influxdb.svg b/apple/TablePro/Assets.xcassets/influxdb-icon.imageset/influxdb.svg new file mode 100644 index 0000000000..152eaa8286 --- /dev/null +++ b/apple/TablePro/Assets.xcassets/influxdb-icon.imageset/influxdb.svg @@ -0,0 +1 @@ +InfluxDB \ No newline at end of file diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+InfluxDBDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+InfluxDBDefaults.swift new file mode 100644 index 0000000000..0d01da28eb --- /dev/null +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+InfluxDBDefaults.swift @@ -0,0 +1,95 @@ +import Foundation +import TableProInfluxDBCore +import TableProPluginKit + +extension PluginMetadataRegistry { + func influxDBPluginDefaults() -> [(typeId: String, snapshot: PluginMetadataSnapshot)] { + [ + ("InfluxDB", PluginMetadataSnapshot( + displayName: "InfluxDB", iconName: "influxdb-icon", defaultPort: 8_086, + requiresAuthentication: false, supportsForeignKeys: false, supportsSchemaEditing: false, + isDownloadable: true, primaryUrlScheme: "", parameterStyle: .questionMark, + navigationModel: .standard, explainVariants: [], pathFieldRole: .database, + supportsHealthMonitor: true, urlSchemes: [], postConnectActions: [], + brandColorHex: "#22ADF6", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .network, supportsDatabaseSwitching: true, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: false, + supportsImport: false, + supportsExport: true, + supportsSSH: true, + supportsSSL: true, + supportsCascadeDrop: false, + supportsForeignKeyDisable: false, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: false, + supportsAddColumn: false, + supportsModifyColumn: false, + supportsDropColumn: false, + supportsAddIndex: false, + supportsDropIndex: false, + supportsModifyPrimaryKey: false, + supportsOpportunisticTLS: false, + supportsPrincipalConnectionLimit: false + ), + schema: PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "", + defaultGroupName: "default", + tableEntityName: "Measurements", + containerEntityName: "Database", + defaultPrimaryKeyColumn: nil, + immutableColumns: [], + systemDatabaseNames: InfluxCatalogDecoder.systemDatabaseNames, + systemSchemaNames: [], + fileExtensions: [], + databaseGroupingStrategy: .flat, + structureColumnFields: [.name, .type, .nullable, .comment] + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: nil, + statementCompletions: InfluxCompletions.entries.map { + CompletionEntry(label: $0.label, insertText: $0.insertText) + }, + columnTypesByCategory: InfluxCompletions.columnTypesByCategory + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: influxDBConnectionFields(), + category: .analytical, + tagline: String(localized: "Time series database"), + hidesBuiltInPassword: true, + hidesBuiltInDatabase: false + ) + )) + ] + } +} + +// Kept identical to `influxDBConnectionFields()` in the plugin. +private func influxDBConnectionFields() -> [ConnectionField] { + [ + ConnectionField( + id: InfluxConnectionFieldID.token, + label: String(localized: "Token"), + placeholder: "API token", + fieldType: .secure, + section: .authentication, + hidesPassword: true + ).withHidesUsername(true), + ConnectionField( + id: InfluxConnectionFieldID.organization, + label: String(localized: "Organization"), + placeholder: "InfluxDB 2 only, optional with one organization", + section: .connection + ), + ConnectionField( + id: InfluxConnectionFieldID.skipTLSVerify, + label: String(localized: "Skip TLS Verification"), + defaultValue: "false", + fieldType: .toggle, + section: .advanced + ) + ] +} diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 5097398613..8a29704f3f 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -1047,7 +1047,7 @@ extension PluginMetadataRegistry { + kafkaPluginDefaults() + typesensePluginDefaults() + r2SQLPluginDefaults() + weaviatePluginDefaults() + hanaPluginDefaults() + firebasePluginDefaults() + bigtablePluginDefaults() + neo4jPluginDefaults() - + trinoPluginDefaults() + memcachedPluginDefaults() + + trinoPluginDefaults() + memcachedPluginDefaults() + influxDBPluginDefaults() } // swiftlint:enable function_body_length } diff --git a/apple/TablePro/Core/Utilities/SQL/CatalogChangeClassifier.swift b/apple/TablePro/Core/Utilities/SQL/CatalogChangeClassifier.swift index 8e9583ab44..23526d70ea 100644 --- a/apple/TablePro/Core/Utilities/SQL/CatalogChangeClassifier.swift +++ b/apple/TablePro/Core/Utilities/SQL/CatalogChangeClassifier.swift @@ -51,7 +51,9 @@ enum CatalogChangeClassifier { return action.changesCatalog ? SQLCatalogEffect(kinds: .tables, endsTransaction: false) : .none } + // InfluxDB has no DDL: a write is what creates a measurement, so any statement that is not a read may change the list. private static let documentStoreTypes: Set = [ - .mongodb, .elasticsearch, .typesense, .weaviate, .etcd, .firestore, .firebaseRealtimeDatabase, .bigtable, .neo4j + .mongodb, .elasticsearch, .typesense, .weaviate, .etcd, .firestore, .firebaseRealtimeDatabase, .bigtable, .neo4j, + .influxdb, ] } diff --git a/apple/TablePro/Core/Utilities/SQL/QueryClassifier+Flux.swift b/apple/TablePro/Core/Utilities/SQL/QueryClassifier+Flux.swift new file mode 100644 index 0000000000..1d459c56ca --- /dev/null +++ b/apple/TablePro/Core/Utilities/SQL/QueryClassifier+Flux.swift @@ -0,0 +1,15 @@ +import Foundation +import TableProInfluxDBCore + +// The SQL reading tiers a Flux read as an unknown statement, a write, so it would refuse every Flux read on a read-only +// connection. Each statement is read as the driver receives it, through the driver's own routing and Flux reading. +extension QueryClassifier { + static func fluxClassification(_ statement: String) -> QueryClassification? { + guard InfluxStatement.isFlux(statement) else { return nil } + let reading = FluxScriptReading(InfluxStatement.fluxBody(of: statement)) + return QueryClassification( + tier: reading.writes ? .write : .safe, + reachesFilesystemOrExecutesCode: reading.reachesOutside + ) + } +} diff --git a/apple/TablePro/Core/Utilities/SQL/QueryClassifier.swift b/apple/TablePro/Core/Utilities/SQL/QueryClassifier.swift index 07843be136..cb19c087d0 100644 --- a/apple/TablePro/Core/Utilities/SQL/QueryClassifier.swift +++ b/apple/TablePro/Core/Utilities/SQL/QueryClassifier.swift @@ -175,6 +175,9 @@ enum QueryClassifier { if let request = dynamoDBClassification(statement, grammar: grammar, databaseType: databaseType) { return request } + if databaseType == .influxdb, let flux = fluxClassification(statement) { + return flux + } if let batch = CQLBatch.statements(in: statement, grammar: grammar) { return batch.reduce(cqlBatchWrites) { worst, inner in worst.escalated(with: statementClassification(inner, grammar: grammar, databaseType: databaseType)) diff --git a/apple/TablePro/Models/Connection/DatabaseType.swift b/apple/TablePro/Models/Connection/DatabaseType.swift index ef72bbc5e8..63c0488f6d 100644 --- a/apple/TablePro/Models/Connection/DatabaseType.swift +++ b/apple/TablePro/Models/Connection/DatabaseType.swift @@ -55,6 +55,7 @@ extension DatabaseType { static let presto = DatabaseType(rawValue: "Presto") static let weaviate = DatabaseType(rawValue: "Weaviate") static let memcached = DatabaseType(rawValue: "Memcached") + static let influxdb = DatabaseType(rawValue: "InfluxDB") static let bigtable = DatabaseType(rawValue: "Bigtable") static let firestore = DatabaseType(rawValue: "Firestore") static let firebaseRealtimeDatabase = DatabaseType(rawValue: "Firebase Realtime Database") diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index 2fa693df9d..8aef509209 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -3022,6 +3022,10 @@ } } }, + "%@ cannot run this filter on that column." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "%@ cannot start with a dash or contain spaces" : { "localizations" : { "fr" : { @@ -4036,6 +4040,10 @@ }, "%@ is not a Vault address. Use a URL such as https://vault.example.com:8200." : { + }, + "%@ is not available on this server. It accepts %@." : { + "extractionState" : "manual", + "generatesSymbol" : false }, "%@ is read-only, so nothing can be written to it." : { "localizations" : { @@ -37486,6 +37494,10 @@ } } }, + "Choose a database or bucket first." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Choose a folder containing .sql files" : { "localizations" : { "fr" : { @@ -93902,6 +93914,34 @@ "extractionState" : "manual", "generatesSymbol" : false }, + "InfluxDB %@ is not supported. Connect to InfluxDB 2 or 3." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "InfluxDB 2 needs an organization. Set it in the connection settings." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "InfluxDB has no views." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "InfluxDB points cannot be edited in TablePro." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "InfluxDB reads this Flux script as one that writes or reaches outside the database, which TablePro's check did not see, so it was not run." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "InfluxDB refused the token: %@" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "InfluxDB refused the token." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Info" : { "extractionState" : "stale", "localizations" : { @@ -96072,6 +96112,10 @@ } } }, + "Invalid InfluxDB address: %@" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Invalid JSON" : { "localizations" : { "fr" : { @@ -121781,6 +121825,10 @@ "extractionState" : "manual", "generatesSymbol" : false }, + "Not connected to InfluxDB." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Not connected to Memcached" : { "extractionState" : "manual", "generatesSymbol" : false @@ -129401,6 +129449,10 @@ } } }, + "Organization" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Other" : { "localizations" : { "fr" : { @@ -154132,6 +154184,10 @@ } } }, + "Run one InfluxQL statement at a time, with no ; inside it." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Run one statement, or on SQL Server a whole script. Reads need tools:read; anything that writes needs tools:write and is subject to the connection's Safe Mode, which asks the user to approve it. DROP and TRUNCATE go through confirm_destructive_operation instead. Statements that read or write files, or run server-side code, are refused. Send one statement per call, except on SQL Server, where a script runs batch by batch at its GO lines and result_sets lists every result set when there is more than one." : { "localizations" : { "fr" : { @@ -190731,6 +190787,10 @@ } } }, + "The server did not say which InfluxDB version it runs." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The server does not accept encrypted connections but TablePro is configured to require TLS." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -200720,6 +200780,9 @@ } } } + }, + "Time series database" : { + }, "Time spent waiting" : { "localizations" : { @@ -205180,6 +205243,10 @@ } } }, + "Unexpected InfluxDB response: %@" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Unexpected reply from Memcached: %@" : { "extractionState" : "manual", "generatesSymbol" : false diff --git a/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift index 9aea24bb71..7a756f81ed 100644 --- a/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift +++ b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift @@ -11,7 +11,7 @@ struct PluginMetadataRegistryTypeCountTests { "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", "StarRocks", "Firestore", "Firebase Realtime Database", "Bigtable", - "Neo4j", "Memcached" + "Neo4j", "Memcached", "InfluxDB" ] private static func builtInTypeIds() -> Set { diff --git a/apple/TableProTests/Core/Utilities/SQL/CatalogChangeClassifierTests.swift b/apple/TableProTests/Core/Utilities/SQL/CatalogChangeClassifierTests.swift index 5eaefccf1a..c8dc63dc56 100644 --- a/apple/TableProTests/Core/Utilities/SQL/CatalogChangeClassifierTests.swift +++ b/apple/TableProTests/Core/Utilities/SQL/CatalogChangeClassifierTests.swift @@ -173,6 +173,15 @@ struct CatalogChangeClassifierTests { #expect(kinds("PUT events/k {\"cf:new\": \"1\"}", .bigtable) == .everything) } + @Test("An InfluxDB write refreshes the sidebar, since writing a point is what creates a measurement") + func influxDBWritesRefresh() { + #expect(kinds("-- sql\nSELECT * FROM \"cpu\" ORDER BY time DESC LIMIT 100 OFFSET 0", .influxdb).isEmpty) + #expect(kinds("// flux\nfrom(bucket: \"m\") |> range(start: -1h)", .influxdb).isEmpty) + #expect(kinds("from(bucket: \"m\") |> range(start: -1h) |> to(bucket: \"copy\")", .influxdb) == .everything) + #expect(kinds("SELECT * INTO \"copy\" FROM \"cpu\"", .influxdb) == .everything) + #expect(kinds("DROP MEASUREMENT \"cpu\"", .influxdb) == .everything) + } + @Test("A Firebase write refreshes the sidebar, since it can create a collection or a node") func firebaseWritesRefresh() { for type in [DatabaseType.firestore, .firebaseRealtimeDatabase] { diff --git a/apple/TableProTests/Core/Utilities/SQL/QueryClassifierFluxTests.swift b/apple/TableProTests/Core/Utilities/SQL/QueryClassifierFluxTests.swift new file mode 100644 index 0000000000..6bddd53269 --- /dev/null +++ b/apple/TableProTests/Core/Utilities/SQL/QueryClassifierFluxTests.swift @@ -0,0 +1,97 @@ +import Foundation +@testable import TablePro +import Testing + +struct QueryClassifierFluxTests { + @Test func aFluxReadIsSafe() { + let flux = """ + // last hour + from(bucket: "metrics") + |> range(start: -1h) + |> filter(fn: (r) => r._measurement == "cpu" and r.host =~ /^web-\\//) + """ + #expect(QueryClassifier.classifyTier(flux, databaseType: .influxdb) == .safe) + #expect(!QueryClassifier.reachesFilesystemOrExecutesCode(flux, databaseType: .influxdb)) + #expect(!QueryClassifier.isWriteQuery("buckets()", databaseType: .influxdb)) + } + + @Test func toAndWideToWrite() { + let copy = "from(bucket: \"a\") |> range(start: -1h) |> to(bucket: \"b\")" + #expect(QueryClassifier.classifyTier(copy, databaseType: .influxdb) == .write) + let wide = "import \"influxdata/influxdb\"\nfrom(bucket: \"a\") |> range(start: -1h) |> influxdb.wideTo(bucket: \"b\")" + #expect(QueryClassifier.isWriteQuery(wide, databaseType: .influxdb)) + let indexed = "import \"influxdata/influxdb\"\nw = influxdb[\"wideTo\"]\nfrom(bucket: \"a\") |> range(start: -1h) |> w(bucket: \"b\")" + #expect(QueryClassifier.isWriteQuery(indexed, databaseType: .influxdb)) + } + + @Test func aRegexEndingInAnEscapedSlashDoesNotHideAWrite() { + let flux = "from(bucket:\"a\") |> range(start:-1h) |> filter(fn:(r)=> r.p =~ /x\\//) |> to(bucket:\"b\")" + #expect(QueryClassifier.classifyTier(flux, databaseType: .influxdb) == .write) + } + + @Test func eitherMarkerStyleDecidesTheReading() { + let influxQL = "-- influxql\nDELETE FROM \"cpu\" WHERE \"host\" = 'a |> b'" + #expect(QueryClassifier.classifyTier(influxQL, databaseType: .influxdb) != .safe) + for marker in ["// flux", "-- flux"] { + let flux = "\(marker)\nfrom(bucket: \"a\") |> range(start: -1h) |> to(bucket: \"b\")" + #expect(QueryClassifier.classifyTier(flux, databaseType: .influxdb) == .write) + } + } + + @Test func theGridsOwnStatementsReadAsSQL() { + let grid = "-- sql\nSELECT * FROM \"cpu\" WHERE \"note\" ~ 'a |> b' ORDER BY time DESC LIMIT 100 OFFSET 0" + #expect(QueryClassifier.classifyTier(grid, databaseType: .influxdb) == .safe) + let influxQL = "-- influxql\nSELECT * FROM \"cpu\" WHERE \"note\" = 'from(x)' ORDER BY time DESC LIMIT 100 OFFSET 0" + #expect(QueryClassifier.classifyTier(influxQL, databaseType: .influxdb) == .safe) + } + + @Test func anAliasedWriteStillWrites() { + let flux = "write = to\nfrom(bucket: \"a\") |> range(start: -1h) |> write(bucket: \"b\")" + #expect(QueryClassifier.classifyTier(flux, databaseType: .influxdb) == .write) + let field = "from(bucket: \"a\") |> range(start: -1h) |> map(fn: (r) => ({r with to: 1}))" + #expect(QueryClassifier.classifyTier(field, databaseType: .influxdb) == .safe) + } + + @Test func aStringOrCommentThatSpellsToDoesNotWrite() { + let flux = "from(bucket: \"to(\") // |> to(bucket: \"x\")\n |> range(start: -1h)" + #expect(QueryClassifier.classifyTier(flux, databaseType: .influxdb) == .safe) + } + + @Test(arguments: [ + "import h \"http\"\nfrom(bucket: \"a\") |> range(start: -1h) |> map(fn: (r) => ({r with s: h.get(url: \"x\")}))", + "import \"experimental/influxdb\"\ninfluxdb.api(method: \"post\", path: \"/api/v2/delete\", host: \"http://localhost:8086\")", + "import \"influxdata/influxdb/monitor\"\nfrom(bucket: \"a\") |> range(start: -1h) |> monitor.check(data: {}, messageFn: (r) => \"\")", + "import \"experimental/prometheus\"\nprometheus.scrape(url: \"http://169.254.169.254/\")", + "import \"influxdata/influxdb/sample\"\nsample.data(set: \"airSensor\")", + ]) + func anUnlistedPackageWritesAndReachesOutside(_ flux: String) { + #expect(QueryClassifier.classifyTier(flux, databaseType: .influxdb) == .write) + #expect(QueryClassifier.reachesFilesystemOrExecutesCode(flux, databaseType: .influxdb)) + } + + @Test func aRemoteHostOrServerFileReachesOutside() { + let remote = "from(bucket: \"a\", host: \"http://10.0.0.1:8086\", token: \"t\") |> range(start: -1h)" + #expect(QueryClassifier.reachesFilesystemOrExecutesCode(remote, databaseType: .influxdb)) + let file = "import c \"csv\"\nc.from(mode: \"raw\", file: \"/etc/passwd\")" + #expect(QueryClassifier.reachesFilesystemOrExecutesCode(file, databaseType: .influxdb)) + let renamedTag = "from(bucket: \"a\") |> range(start: -1h) |> map(fn: (r) => ({r with host: \"x\"}))" + #expect(!QueryClassifier.reachesFilesystemOrExecutesCode(renamedTag, databaseType: .influxdb)) + } + + @Test func influxQLAndSQLKeepTheSQLReading() { + #expect(QueryClassifier.classifyTier("SELECT * FROM \"cpu\" LIMIT 10", databaseType: .influxdb) == .safe) + #expect(QueryClassifier.classifyTier("SHOW MEASUREMENTS", databaseType: .influxdb) == .safe) + #expect(QueryClassifier.classifyTier("DROP MEASUREMENT \"cpu\"", databaseType: .influxdb) == .destructive) + #expect(QueryClassifier.isWriteQuery("SELECT * INTO \"copy\" FROM \"cpu\"", databaseType: .influxdb)) + } + + @Test func eachStatementIsReadAsTheDriverReceivesIt() { + let hidden = "-- flux\n;\nDELETE FROM cpu" + #expect(QueryClassifier.classifyTier(hidden, databaseType: .influxdb) == .write) + #expect(QueryClassifier.isDangerousQuery(hidden, databaseType: .influxdb)) + let control = "\u{1}\n-- flux\nSELECT = 1\nto(tables: from(bucket: \"metrics\"), bucket: \"target\")" + #expect(QueryClassifier.isWriteQuery(control, databaseType: .influxdb)) + let mixed = "SHOW MEASUREMENTS;\nfrom(bucket: \"a\") |> range(start: -1h) |> to(bucket: \"b\")" + #expect(QueryClassifier.classifyTier(mixed, databaseType: .influxdb) == .write) + } +} diff --git a/apple/changelog.d/3976.added.md b/apple/changelog.d/3976.added.md new file mode 100644 index 0000000000..f4a1d88c1a --- /dev/null +++ b/apple/changelog.d/3976.added.md @@ -0,0 +1 @@ +InfluxDB 2 and 3 connections, with SQL, InfluxQL and Flux in the editor. diff --git a/apple/project.yml b/apple/project.yml index ba993e528f..a7b429a9da 100644 --- a/apple/project.yml +++ b/apple/project.yml @@ -408,7 +408,7 @@ targets: - package: Sparkle - package: Yams - package: TableProCore - products: [TableProAnalytics, TableProAutocomplete, TableProConnectionLibrary, TableProGeometry, TableProGoogleCloud, TableProImport, TableProLogRedaction, TableProNumberFormatting, TableProRedisCore, TableProSQLGrammar, TableProSQLVocabulary, TableProSSHTransport, TableProSyncTransport, TableProTabular, TableProTabularIO, TableProTheme] + products: [TableProAnalytics, TableProAutocomplete, TableProConnectionLibrary, TableProGeometry, TableProGoogleCloud, TableProImport, TableProInfluxDBCore, TableProLogRedaction, TableProNumberFormatting, TableProRedisCore, TableProSQLGrammar, TableProSQLVocabulary, TableProSSHTransport, TableProSyncTransport, TableProTabular, TableProTabularIO, TableProTheme] - package: TableProMacCore products: [TableProMCP, TableProSSHConfig, TableProUtilities] # Writes nothing for a version CHANGELOG.md has no section for, so a 0.0.0-dev build ships no notes. @@ -1307,6 +1307,15 @@ targets: - package: TableProCore product: TableProWeaviateCore + InfluxDBDriverPlugin: + templates: [DriverPlugin] + templateAttributes: + folder: InfluxDBDriverPlugin + principalClass: InfluxDBPlugin + dependencies: + - package: TableProCore + product: TableProInfluxDBCore + # Every plugin is in exactly one of BundledPlugins (embedded in the app) and RegistryPlugins (each # release publishes it); scripts/ci/check-plugin-manifest.py enforces it. AllPlugins compiles both. aggregateTargets: @@ -1344,6 +1353,7 @@ aggregateTargets: - EtcdDriverPlugin - FirebaseDriverPlugin - HanaDriver + - InfluxDBDriverPlugin - KafkaDriverPlugin - KingbaseESDriverPlugin - LibSQLDriverPlugin diff --git a/apple/scripts/probes/check-influxdb.sh b/apple/scripts/probes/check-influxdb.sh new file mode 100755 index 0000000000..b9eba6d516 --- /dev/null +++ b/apple/scripts/probes/check-influxdb.sh @@ -0,0 +1,378 @@ +#!/usr/bin/env bash +# +# Check the InfluxDB driver against real 2.x and 3.x servers. +# +# The plugin is registry-only, so no test target compiles it. This builds PluginKit, TableProInfluxDBCore, Plugins/Shared +# and the plugin's own sources with a harness and drives the driver the way the app does: connect, the sidebar catalog, +# the grid's browse, filter and count queries, and typed SQL, InfluxQL and Flux. Both servers must hold the points the +# usage below writes; the probe adds a 2,500-point `load_