From 93e44f8d6a368a56d3bf3e401dc724e868f38749 Mon Sep 17 00:00:00 2001 From: Sergey Khruschak Date: Mon, 17 Nov 2025 19:01:19 +0200 Subject: [PATCH 1/2] Added tests --- .github/workflows/build-test.yaml | 2 +- .github/workflows/release.yml | 2 +- .vscode/launch.json | 10 +- Package.resolved | 6 +- Package.swift | 10 +- Sources/table/Expressions.swift | 203 ++++++- Sources/table/Extensions.swift | 24 +- Sources/table/Filter.swift | 26 +- Sources/table/MainApp.swift | 15 +- Sources/table/Row.swift | 2 +- Tests/table-Tests/FilterTests.swift | 283 ++++++++++ Tests/table-Tests/FormatTests.swift | 299 +++++++++++ .../NewColumnsTableViewTests.swift | 306 +++++++++++ Tests/table-Tests/SortTests.swift | 500 ++++++++++++++++++ Tests/table-Tests/TableJoinTests.swift | 343 ++++++++++++ Tests/table-Tests/TableParserTests.swift | 370 +++++++++++++ 16 files changed, 2363 insertions(+), 38 deletions(-) create mode 100644 Tests/table-Tests/NewColumnsTableViewTests.swift create mode 100644 Tests/table-Tests/SortTests.swift create mode 100644 Tests/table-Tests/TableJoinTests.swift diff --git a/.github/workflows/build-test.yaml b/.github/workflows/build-test.yaml index d4db5ef..01bfd3a 100644 --- a/.github/workflows/build-test.yaml +++ b/.github/workflows/build-test.yaml @@ -9,7 +9,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest] - swift: ["6.1"] + swift: ["6.2"] steps: - uses: actions/checkout@v4 - uses: swift-actions/setup-swift@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 101d7e0..91dbb89 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest ] - swift: ["6.1"] + swift: ["6.2"] steps: - uses: actions/checkout@v4 - uses: swift-actions/setup-swift@v2 diff --git a/.vscode/launch.json b/.vscode/launch.json index d0b3b2a..be274fe 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,8 +6,9 @@ "args": [], "cwd": "${workspaceFolder:table-cli}", "name": "Debug table", - "program": "${workspaceFolder:table-cli}/.build/debug/table", - "preLaunchTask": "swift: Build Debug table" + "preLaunchTask": "swift: Build Debug table", + "target": "table", + "configuration": "debug" }, { "type": "swift", @@ -15,8 +16,9 @@ "args": [], "cwd": "${workspaceFolder:table-cli}", "name": "Release table", - "program": "${workspaceFolder:table-cli}/.build/release/table", - "preLaunchTask": "swift: Build Release table" + "preLaunchTask": "swift: Build Release table", + "target": "table", + "configuration": "release" } ] } \ No newline at end of file diff --git a/Package.resolved b/Package.resolved index 947204d..729467f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "84a9b1698a9c385c559d9f7dafb10494b6e8006ab6e2051965b29e02f3f15759", + "originHash" : "2e73ff0cdbcf4b36d651aab43596efc91897bd749bbcc7ae7a983512f379e8cf", "pins" : [ { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser.git", "state" : { - "revision" : "309a47b2b1d9b5e991f36961c983ecec72275be3", - "version" : "1.6.1" + "revision" : "cdd0ef3755280949551dc26dee5de9ddeda89f54", + "version" : "1.6.2" } } ], diff --git a/Package.swift b/Package.swift index b0b30f2..6c68cb1 100644 --- a/Package.swift +++ b/Package.swift @@ -1,12 +1,18 @@ -// swift-tools-version: 6.1 +// swift-tools-version: 6.2.1 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "table", + platforms: [ + .macOS(.v10_15), + .iOS(.v13), + .tvOS(.v13), + .watchOS(.v6) + ], dependencies: [ - .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.6.1") + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.6.2") ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. diff --git a/Sources/table/Expressions.swift b/Sources/table/Expressions.swift index 3ab884a..02e80fb 100644 --- a/Sources/table/Expressions.swift +++ b/Sources/table/Expressions.swift @@ -139,9 +139,14 @@ class Functions { Uuid(), Random(), RandomChoice(), + RandomDate(), Prefix(), Array(), - Distinct() + Distinct(), + Sum(), + Max(), + Min(), + Replace() ] static func find(name: String) -> (any InternalFunction)? { @@ -263,6 +268,54 @@ class Functions { } } + class RandomDate: InternalFunction { + var name: String { "randomDate" } + + func validate(header: Header?, arguments: [any FormatExpr]) throws { + if arguments.count < 2 { + throw RuntimeError("Function \(name) requires 2 arguments: start and end dates in the format YYYY-MM-DD or YYYY-MM-DD HH:MM:SS") + } + + if !(try arguments.prefix(2).map { try $0.fill(row: Row.empty()).isDate }.allSatisfy({ $0 })) { + throw RuntimeError("Function \(name) requires date arguments in the format YYYY-MM-DD or YYYY-MM-DD HH:MM:SS, got \(arguments.map { try! $0.fill(row: Row.empty()) })") + } + } + + func apply(row: Row, arguments: [any FormatExpr]) throws -> String { + let interval = try arguments.prefix(2).map { try $0.fill(row: row).asDate! } + let randomTimeInterval = TimeInterval.random(in: interval[0].timeIntervalSince1970...interval[1].timeIntervalSince1970) + let randomDate = Date(timeIntervalSince1970: randomTimeInterval) + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = arguments.count == 3 ? try arguments[2].fill(row: row) : "yyyy-MM-dd HH:mm:ss" + return dateFormatter.string(from: randomDate) + } + + var description: String { + return "randomDate(from, to, format?) – returns a random date from the given interval. Optionally accepts format for the output, with default 'yyyy-MM-dd HH:mm:ss'" + } + } + + class Replace: InternalFunction { + var name: String { "replace" } + + func validate(header: Header?, arguments: [any FormatExpr]) throws { + if arguments.count != 3 { + throw RuntimeError("Function \(name) requires 3 arguments: a string to modify, string to replace and a replacement string, got \(arguments.count): \(arguments)") + } + } + + func apply(row: Row, arguments: [any FormatExpr]) throws -> String { + let str = try arguments[0].fill(row: row) + let target = try arguments[1].fill(row: row).trimmingCharacters(in: CharacterSet.init(charactersIn: "\"'")) + let replacement = try arguments[2].fill(row: row).trimmingCharacters(in: CharacterSet.init(charactersIn: "\"'")) + return str.replacingOccurrences(of: target, with: replacement) + } + + var description: String { + return "replace(str,what,replacement) – returns a string with all occurrences of 'what' replaced with 'replacement'" + } + } + class Prefix: InternalFunction { var name: String { "prefix" } @@ -299,12 +352,9 @@ class Functions { } func apply(row: Row, arguments: [any FormatExpr]) throws -> String { - let arguments = try arguments.map { try $0.fill(row: row) } - let elements = arguments.count > 1 ? arguments : arguments[0].split(separator: Character(",")).map { String($0).trimmingCharacters(in: .whitespaces) } - + let elements = try Functions.arrayArg(row: row, arguments: arguments) let quoted = !elements.allSatisfy { $0.isNumber || $0.isBoolean || $0.caseInsensitiveCompare("null") == .orderedSame } - - return "[" + elements.map { quoted ? "'\($0)'" : $0 }.joined(separator: ", ") + "]" + return elements.map { quoted ? "'\($0)'" : $0 }.joined(separator: ", ") } var description: String { @@ -322,14 +372,147 @@ class Functions { } func apply(row: Row, arguments: [any FormatExpr]) throws -> String { - let arguments = try arguments.map { try $0.fill(row: row) } - let elements = arguments.count > 1 ? arguments : arguments[0].split(separator: Character(",")).map { String($0).trimmingCharacters(in: .whitespaces) } - - return Set(elements).joined(separator: ",") + let elements = try Functions.arrayArg(row: row, arguments: arguments) + + // Preserve order by keeping first occurrence of each element + var seen = Set() + var ordered: [String] = [] + for element in elements { + if !seen.contains(element) { + seen.insert(element) + ordered.append(element) + } + } + return ordered.joined(separator: ",") } var description: String { return "distinct(str) – returns a distinct element from a comma separated list of elements. Requires a single argument that will be split by commas" } } + + class Sum: InternalFunction { + var name: String { "sum" } + + func validate(header: Header?, arguments: [any FormatExpr]) throws { + if arguments.isEmpty { + throw RuntimeError("Function \(name) requires at least one argument") + } + } + + func apply(row: Row, arguments: [any FormatExpr]) throws -> String { + let elements = try Functions.arrayArg(row: row, arguments: arguments) + + var sum: Double = 0.0 + for element in elements { + if let value = Double(element) { + sum += value + } else { + throw RuntimeError("Function \(name) requires numeric values, got non-numeric value: \(element)") + } + } + + // Return as integer if it's a whole number, otherwise as decimal + if sum.truncatingRemainder(dividingBy: 1) == 0 { + return String(Int(sum)) + } else { + return String(sum) + } + } + + var description: String { + return "sum(...) – returns the sum of numeric values. Accepts multiple arguments or a comma-separated list of numbers" + } + } + + class Max: InternalFunction { + var name: String { "max" } + + func validate(header: Header?, arguments: [any FormatExpr]) throws { + if arguments.isEmpty { + throw RuntimeError("Function \(name) requires at least one argument") + } + } + + func apply(row: Row, arguments: [any FormatExpr]) throws -> String { + let elements = try Functions.arrayArg(row: row, arguments: arguments) + + var maxValue: Double? + for element in elements { + if let value = Double(element) { + if let currentMax = maxValue { + maxValue = max(currentMax, value) + } else { + maxValue = value + } + } else { + throw RuntimeError("Function \(name) requires numeric values, got non-numeric value: \(element)") + } + } + + guard let result = maxValue else { + throw RuntimeError("Function \(name) requires at least one numeric value") + } + + // Return as integer if it's a whole number, otherwise as decimal + if result.truncatingRemainder(dividingBy: 1) == 0 { + return String(Int(result)) + } else { + return String(result) + } + } + + var description: String { + return "max(...) – returns the maximum of numeric values. Accepts multiple arguments or a comma-separated list of numbers" + } + } + + class Min: InternalFunction { + var name: String { "min" } + + func validate(header: Header?, arguments: [any FormatExpr]) throws { + if arguments.isEmpty { + throw RuntimeError("Function \(name) requires at least one argument") + } + } + + func apply(row: Row, arguments: [any FormatExpr]) throws -> String { + let elements = try Functions.arrayArg(row: row, arguments: arguments) + + var minValue: Double? + for element in elements { + if let value = Double(element) { + if let currentMin = minValue { + minValue = min(currentMin, value) + } else { + minValue = value + } + } else { + throw RuntimeError("Function \(name) requires numeric values, got non-numeric value: \(element)") + } + } + + guard let result = minValue else { + throw RuntimeError("Function \(name) requires at least one numeric value") + } + + // Return as integer if it's a whole number, otherwise as decimal + if result.truncatingRemainder(dividingBy: 1) == 0 { + return String(Int(result)) + } else { + return String(result) + } + } + + var description: String { + return "min(...) – returns the minimum of numeric values. Accepts multiple arguments or a comma-separated list of numbers" + } + } + + // Utility function to get elements from a comma-separated list of arguments + static func arrayArg(row: Row, arguments: [any FormatExpr]) throws -> [String] { + let arguments = try arguments.map { try $0.fill(row: row) } + return arguments.count > 1 ? arguments : arguments[0].split(separator: Character(",")).map { String($0).trimmingCharacters(in: .whitespaces) } + } + } \ No newline at end of file diff --git a/Sources/table/Extensions.swift b/Sources/table/Extensions.swift index 696074b..765d2ef 100644 --- a/Sources/table/Extensions.swift +++ b/Sources/table/Extensions.swift @@ -24,9 +24,7 @@ extension String { } var isDate: Bool { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" // Adjust as needed for your date format - return dateFormatter.date(from: self.replacingOccurrences(of: "T", with: " ")) != nil + asDate != nil } var isBoolean: Bool { @@ -36,6 +34,26 @@ extension String { var boolValue: Bool { return self.caseInsensitiveCompare("true") == .orderedSame } + + var asDate: Date? { + let formats = ["yyyy-MM-dd", "yyyy-MM-dd HH:mm:ss"] + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = .current + + for format in formats { + formatter.dateFormat = format + if let date = formatter.date(from: + self.replacingOccurrences(of: "T", with: " ") + .replacingOccurrences(of: "'", with: "") + .replacingOccurrences(of: "\"", with: "")) { + return date + } + } + + return nil + } } extension Array { diff --git a/Sources/table/Filter.swift b/Sources/table/Filter.swift index a354002..2bd7508 100644 --- a/Sources/table/Filter.swift +++ b/Sources/table/Filter.swift @@ -17,29 +17,34 @@ class Filter { let op: Operator let value: String let numberValue: Int? + let invert: Bool - init(column: Int, op: Operator, value: String) { + init(column: Int, op: Operator, value: String, invert: Bool = false) { self.column = column self.op = op self.value = value self.numberValue = Int(value) + self.invert = invert } - static let regex = try! NSRegularExpression(pattern: "([A-Za-z_0-9]+)\\s?([><=!^~\\$]=?)\\s?(.*)", options: []) + static let regex = try! NSRegularExpression(pattern: "(!)?([A-Za-z_0-9]+)\\s?([><=!^~\\$]=?)\\s?(.*)", options: []) func apply(row: Row) -> Bool { let rowVal = row[column] + var result: Bool = false if (numberValue != nil) { let row = Int(rowVal) if (row != nil) { - return Filter.compare(v1: row!, v2: self.numberValue!, operation: self.op) + result = Filter.compare(v1: row!, v2: self.numberValue!, operation: self.op) } else { - return Filter.compare(v1: rowVal, v2: value, operation: op) + result = Filter.compare(v1: rowVal, v2: value, operation: op) } } else { - return Filter.compare(v1: rowVal, v2: value, operation: op) + result = Filter.compare(v1: rowVal, v2: value, operation: op) } + + return invert ? !result : result } static func compare(v1: T, v2: T, operation: Operator) -> Bool { @@ -63,19 +68,22 @@ class Filter { if !matches.isEmpty { let groups = matches[0] - - let colName = String(filter[Range(groups.range(at: 1), in: filter)!]).trimmingCharacters(in: .whitespacesAndNewlines) + + let invert = groups.range(at: 1).location != NSNotFound + + let colName = String(filter[Range(groups.range(at: 2), in: filter)!]).trimmingCharacters(in: .whitespacesAndNewlines) let col = try header.index(ofColumn: colName).orThrow(RuntimeError("Filter: unknown column '\(colName)'. Available columns: \(header.columnsStr())")) - let opStr = String(filter[Range(groups.range(at: 2), in: filter)!]) + let opStr = String(filter[Range(groups.range(at: 3), in: filter)!]) let op = try Operator(rawValue: opStr).orThrow(RuntimeError("Filter: unsupported comparison operation '\(opStr)' should be one of =, !=, <, <=, >, >=, ^= (starts with), $= (ends with), ~= (contains)")) return Filter( column: col, op: op, - value: String(filter[Range(groups.range(at: 3), in: filter)!]).trimmingCharacters(in: .whitespacesAndNewlines) + value: String(filter[Range(groups.range(at: 4), in: filter)!]).trimmingCharacters(in: .whitespacesAndNewlines), + invert: invert ) } else { throw RuntimeError("Filter: Invalid filter format '\(filter)'. Should be ") diff --git a/Sources/table/MainApp.swift b/Sources/table/MainApp.swift index 84f2931..826ee74 100644 --- a/Sources/table/MainApp.swift +++ b/Sources/table/MainApp.swift @@ -64,10 +64,10 @@ struct MainApp: AsyncParsableCommand { Filter rows and display only specified columns: table in.csv --filter 'available>5' --columns 'item,available'. - Some options like --add or --print supports expressions that can be used to substitute column values or execute commands: + Some options like --add or --print support expressions that can be used to substitute column values or execute commands. Commands and functions also support nesting of expressions. - ${column_name} - substitutes column value. Example: ${name} will be substituted with the value of the 'name' column. - #{command} - executes bash command and substitutes its output. Example: #{echo "hello ${name}"} - - %{function} - executes internal functions. Supported functions: + - %{function} - executes internal functions. Example %{distinct(${items})} Supported functions: \(Functions.all.map { "\($0.description)" }.joined(separator: "\n\t")) """, version: appVersion @@ -111,7 +111,14 @@ struct MainApp: AsyncParsableCommand { @Option(name: [.customLong("as")], help: "Prints output in the specified format. Supported formats: table (default) or csv.") var asFormat: String? - @Option(name: [.customShort("f"), .customLong("filter")], help: "Filter rows by a single value criteria. Example: country=UA or size>10. Supported comparison operations: '=' - equal,'!=' - not equal, < - smaller, <= - smaller or equal, > - bigger, >= - bigger or equal, '^=' - starts with, '$=' - ends with, '~=' - contains.") + @Option(name: [.customShort("f"), .customLong("filter")], + help: ArgumentHelp( + "Filter rows by a single value criteria. Example: --filter 'country=UA', --filter 'size>10'", + discussion: """ + Supported comparison operations: '=' - equal,'!=' - not equal, < - smaller, <= - smaller or equal, > - bigger, >= - bigger or equal, '^=' - starts with, '$=' - ends with, '~=' - contains. + To invert filter place ! before the filter expression, e.g. --filter '!country=UA' will return all rows where country is not equal to UA. + """) + ) var filters: [String] = [] @Option( @@ -125,7 +132,7 @@ struct MainApp: AsyncParsableCommand { @Option(name: .customLong("distinct"), help: "Returns only distinct values for the specified column set. Example: --distinct name,city_id.") var distinctColumns: [String] = [] - @Option(name: .customLong("duplicate"), help: "Outputs only duplicate rows by the specified columns. Example: --duplicates name,city_id will find duplicates by both name and city_id columns.") + @Option(name: .customLong("duplicate"), help: "Outputs only duplicate rows by the specified columns. Example: --duplicate name,city_id will find duplicates by both name and city_id columns.") var duplicateColumns: [String] = [] @Option(name: .customLong("group-by"), help: "Groups rows by the specified columns. Example: --group-by city_id,region.") diff --git a/Sources/table/Row.swift b/Sources/table/Row.swift index 109db53..6c4b4e1 100644 --- a/Sources/table/Row.swift +++ b/Sources/table/Row.swift @@ -32,7 +32,7 @@ class Row { self.components = cells } - static func empty(header: Header?) -> Row { + static func empty(header: Header? = nil) -> Row { let h = header ?? Header.auto(size: 0) return Row(header: h, index: 0, components: h.components().map { _ in "" } ) } diff --git a/Tests/table-Tests/FilterTests.swift b/Tests/table-Tests/FilterTests.swift index b2e23a2..a50894b 100644 --- a/Tests/table-Tests/FilterTests.swift +++ b/Tests/table-Tests/FilterTests.swift @@ -27,5 +27,288 @@ class FilterTests: XCTestCase { XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["Test", "4"]))) XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["test", "4"]))) + } + + func testEqualityOperator() throws { + let filter = try Filter.compile(filter: "col1 = value", header: header) + + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["value", "other"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["Value", "other"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["different", "other"]))) + } + + func testEqualityWithNumbers() throws { + let filter = try Filter.compile(filter: "col1 = 42", header: header) + + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["42", "other"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["43", "other"]))) + } + + func testEqualityWithEmptyString() throws { + let filter = try Filter.compile(filter: "col1 = ", header: header) + + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["", "other"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["value", "other"]))) + } + + func testNotEqualOperator() throws { + let filter = try Filter.compile(filter: "col1 != value", header: header) + + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["value", "other"]))) + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["Value", "other"]))) + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["different", "other"]))) + } + + func testNotEqualWithNumbers() throws { + let filter = try Filter.compile(filter: "col1 != 42", header: header) + + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["42", "other"]))) + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["43", "other"]))) + } + + func testLessThanOperator() throws { + let filter = try Filter.compile(filter: "col1 < 10", header: header) + + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["5", "other"]))) + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["9", "other"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["10", "other"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["15", "other"]))) + } + + func testLessThanWithNegativeNumbers() throws { + let filter = try Filter.compile(filter: "col1 < 0", header: header) + + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["-5", "other"]))) + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["-1", "other"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["0", "other"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["5", "other"]))) + } + + func testLessThanWithStrings() throws { + let filter = try Filter.compile(filter: "col1 < zebra", header: header) + + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["apple", "other"]))) + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["banana", "other"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["zebra", "other"]))) + } + + func testLessThanOrEqualOperator() throws { + let filter = try Filter.compile(filter: "col1 <= 10", header: header) + + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["5", "other"]))) + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["10", "other"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["11", "other"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["15", "other"]))) + } + + func testGreaterThanOperator() throws { + let filter = try Filter.compile(filter: "col1 > 10", header: header) + + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["5", "other"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["10", "other"]))) + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["11", "other"]))) + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["15", "other"]))) + } + + func testGreaterThanWithZero() throws { + let filter = try Filter.compile(filter: "col1 > 0", header: header) + + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["0", "other"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["-5", "other"]))) + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["1", "other"]))) + } + + func testGreaterThanOrEqualOperator() throws { + let filter = try Filter.compile(filter: "col1 >= 10", header: header) + + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["5", "other"]))) + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["10", "other"]))) + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["11", "other"]))) + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["15", "other"]))) + } + + func testContainsOperator() throws { + let filter = try Filter.compile(filter: "col1 ~= test", header: header) + + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["test", "other"]))) + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["testing", "other"]))) + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["mytest", "other"]))) + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["my test value", "other"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["Test", "other"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["different", "other"]))) + } + + func testContainsWithSpecialCharacters() throws { + let filter = try Filter.compile(filter: "col1 ~= $100", header: header) + + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["Price: $100", "other"]))) + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["$100", "other"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["Price: 100", "other"]))) + } + + func testStartsWithOperator() throws { + let filter = try Filter.compile(filter: "col1 ^= prefix", header: header) + + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["prefix", "other"]))) + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["prefixsuffix", "other"]))) + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["prefix value", "other"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["myprefix", "other"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["Prefix", "other"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["different", "other"]))) + } + + func testStartsWithWithEmptyString() throws { + let filter = try Filter.compile(filter: "col1 ^= ", header: header) + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["any", "other"]))) + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["", "other"]))) + } + + func testEndsWithOperator() throws { + let filter = try Filter.compile(filter: "col1 $= suffix", header: header) + + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["suffix", "other"]))) + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["mysuffix", "other"]))) + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["value suffix", "other"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["suffixmy", "other"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["Suffix", "other"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["different", "other"]))) + } + + func testEndsWithWithEmptyString() throws { + let filter = try Filter.compile(filter: "col1 $= ", header: header) + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["any", "other"]))) + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["", "other"]))) + } + + func testInvertedFilter() throws { + let filter = try Filter.compile(filter: "!col1 = value", header: header) + + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["value", "other"]))) + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["Value", "other"]))) + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["different", "other"]))) + } + + func testInvertedGreaterThan() throws { + let filter = try Filter.compile(filter: "!col1 > 10", header: header) + + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["5", "other"]))) + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["10", "other"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["11", "other"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["15", "other"]))) + } + + func testInvertedContains() throws { + let filter = try Filter.compile(filter: "!col1 ~= test", header: header) + + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["test", "other"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["testing", "other"]))) + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["different", "other"]))) + } + + func testFilterWithWhitespace() throws { + let filter = try Filter.compile(filter: "col1 = test value", header: header) + + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["test value", "other"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["testvalue", "other"]))) + } + + func testFilterWithLeadingTrailingWhitespace() throws { + let filter = try Filter.compile(filter: "col1 = value ", header: header) + + // Filter trims whitespace from value + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["value", "other"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: [" value ", "other"]))) + } + + func testNumberComparisonWithNonNumericString() throws { + let filter = try Filter.compile(filter: "col1 > 10", header: header) + + // When row value is not numeric, falls back to string comparison + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["abc", "other"]))) + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["1a", "other"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["5", "other"]))) + } + + func testStringComparisonWithNumericValue() throws { + let filter = try Filter.compile(filter: "col1 = 42", header: header) + + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["42", "other"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["43", "other"]))) + } + + func testInvalidFilterFormat() throws { + XCTAssertThrowsError(try Filter.compile(filter: "invalid format", header: header)) { error in + let errorMessage = String(describing: error) + XCTAssertTrue(errorMessage.contains("Invalid filter format")) + } + } + + func testUnknownColumn() throws { + XCTAssertThrowsError(try Filter.compile(filter: "unknown_col = value", header: header)) { error in + let errorMessage = String(describing: error) + XCTAssertTrue(errorMessage.contains("unknown column")) + XCTAssertTrue(errorMessage.contains("unknown_col")) + } + } + + func testUnsupportedOperator() throws { + // Note: This might not throw if the regex matches but operator is invalid + // Let's test with a clearly invalid operator + XCTAssertThrowsError(try Filter.compile(filter: "col1 == value", header: header)) { error in + let errorMessage = String(describing: error) + XCTAssertTrue(errorMessage.contains("unsupported comparison operation") || + errorMessage.contains("Invalid filter format")) + } + } + + func testFilterWithZero() throws { + let filter = try Filter.compile(filter: "col1 = 0", header: header) + + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["0", "other"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["1", "other"]))) + } + + func testFilterWithLargeNumbers() throws { + let filter = try Filter.compile(filter: "col1 > 1000000", header: header) + + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["2000000", "other"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["500000", "other"]))) + } + + func testFilterWithColumnNamesContainingNumbers() throws { + let headerWithNumbers = Header(components: ["col1", "col2", "col_123"], types: [.string, .string, .string]) + let filter = try Filter.compile(filter: "col_123 = test", header: headerWithNumbers) + + XCTAssertTrue(filter.apply(row: Row(header: headerWithNumbers, index: 0, components: ["value", "other", "test"]))) + XCTAssertFalse(filter.apply(row: Row(header: headerWithNumbers, index: 0, components: ["value", "other", "different"]))) + } + + func testFilterWithSpecialCharactersInValue() throws { + let filter = try Filter.compile(filter: "col1 = test@example.com", header: header) + + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["test@example.com", "other"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["test@example", "other"]))) + } + + func testFilterWithUnicodeCharacters() throws { + let filter = try Filter.compile(filter: "col1 = café", header: header) + + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["café", "other"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["cafe", "other"]))) + } + + func testFilterOnSecondColumn() throws { + let filter = try Filter.compile(filter: "col2 = value", header: header) + + XCTAssertTrue(filter.apply(row: Row(header: header, index: 0, components: ["other", "value"]))) + XCTAssertFalse(filter.apply(row: Row(header: header, index: 0, components: ["other", "different"]))) + } + + func testFilterWithDifferentColumnTypes() throws { + let mixedHeader = Header(components: ["name", "age", "active"], types: [.string, .number, .boolean]) + let filter = try Filter.compile(filter: "age > 18", header: mixedHeader) + + XCTAssertTrue(filter.apply(row: Row(header: mixedHeader, index: 0, components: ["Alice", "25", "true"]))) + XCTAssertFalse(filter.apply(row: Row(header: mixedHeader, index: 0, components: ["Bob", "15", "true"]))) } } diff --git a/Tests/table-Tests/FormatTests.swift b/Tests/table-Tests/FormatTests.swift index 377d066..db946f2 100644 --- a/Tests/table-Tests/FormatTests.swift +++ b/Tests/table-Tests/FormatTests.swift @@ -48,4 +48,303 @@ class FormatTests: XCTestCase { let format = try Format(format: "Result: #{echo \"${num1} + ${num2}\" | bc} and a var ${str1}").validated(header: row.header) XCTAssertEqual(format.fill(row: row), "Result: 350 and a var val1") } + + func testDistinctOrderPreservation() throws { + let testRow = Row( + header: Header(components: ["tags"], types: [.string]), + index: 0, + components: ["red,blue,green,red,yellow,blue"] + ) + let format = try Format(format: "%{distinct(${tags})}").validated(header: testRow.header) + let result = format.fill(row: testRow) + XCTAssertEqual(result, "red,blue,green,yellow") + } + + func testDistinctWithSingleElement() throws { + let distinctFunc = Functions.Distinct() + let textExpr = TextExpr("a") + let result = try distinctFunc.apply(row: row, arguments: [textExpr]) + XCTAssertEqual(result, "a") + } + + func testDistinctWithAllUnique() throws { + let distinctFunc = Functions.Distinct() + let textExpr = TextExpr("a,b,c") + let result = try distinctFunc.apply(row: row, arguments: [textExpr]) + XCTAssertEqual(result, "a,b,c") + } + + func testDistinctWithAllSame() throws { + let distinctFunc = Functions.Distinct() + let textExpr = TextExpr("a,a,a,a") + let result = try distinctFunc.apply(row: row, arguments: [textExpr]) + XCTAssertEqual(result, "a") + } + + func testDistinctWithNumbers() throws { + let distinctFunc = Functions.Distinct() + let textExpr = TextExpr("1,2,3,1,4,2,5") + let result = try distinctFunc.apply(row: row, arguments: [textExpr]) + XCTAssertEqual(result, "1,2,3,4,5") + } + + func testSumWithMultipleArguments() throws { + let format = try Format(format: "Sum: %{sum(1,2,3,4,5)}").validated(header: row.header) + XCTAssertEqual(format.fill(row: row), "Sum: 15") + } + + func testSumWithCommaSeparatedList() throws { + let format = try Format(format: "Sum: %{sum(10,20,30)}").validated(header: row.header) + XCTAssertEqual(format.fill(row: row), "Sum: 60") + } + + func testSumWithColumnVariables() throws { + let format = try Format(format: "Sum: %{sum(${num1},${num2})}").validated(header: row.header) + XCTAssertEqual(format.fill(row: row), "Sum: 350") + } + + func testSumWithDecimals() throws { + let format = try Format(format: "Sum: %{sum(10.5,20.3,30.2)}").validated(header: row.header) + let result = format.fill(row: row) + // Result should be 61.0 (as a decimal) + XCTAssertTrue(result == "Sum: 61.0" || result == "Sum: 61", "Expected 'Sum: 61.0' or 'Sum: 61', got '\(result)'") + } + + func testSumWithMixedIntegersAndDecimals() throws { + let format = try Format(format: "Sum: %{sum(10,20.5,30)}").validated(header: row.header) + XCTAssertEqual(format.fill(row: row), "Sum: 60.5") + } + + func testSumReturnsIntegerWhenWhole() throws { + let format = try Format(format: "%{sum(10.0,20.0,30.0)}").validated(header: row.header) + let result = format.fill(row: row) + XCTAssertEqual(result, "60") + } + + func testSumReturnsDecimalWhenNeeded() throws { + let format = try Format(format: "%{sum(10.5,20.3)}").validated(header: row.header) + let result = format.fill(row: row) + XCTAssertEqual(result, "30.8") + } + + func testSumWithSingleArgument() throws { + let format = try Format(format: "Sum: %{sum(42)}").validated(header: row.header) + XCTAssertEqual(format.fill(row: row), "Sum: 42") + } + + func testSumWithZero() throws { + let format = try Format(format: "Sum: %{sum(0,0,0)}").validated(header: row.header) + XCTAssertEqual(format.fill(row: row), "Sum: 0") + } + + func testSumWithNegativeNumbers() throws { + let format = try Format(format: "Sum: %{sum(10,-5,3)}").validated(header: row.header) + XCTAssertEqual(format.fill(row: row), "Sum: 8") + } + + func testSumWithLargeNumbers() throws { + let format = try Format(format: "Sum: %{sum(1000,2000,3000)}").validated(header: row.header) + XCTAssertEqual(format.fill(row: row), "Sum: 6000") + } + + func testSumValidationRequiresAtLeastOneArgument() throws { + // This should fail validation + XCTAssertThrowsError(try Format(format: "%{sum()}").validated(header: row.header)) { error in + let errorMessage = String(describing: error) + XCTAssertTrue(errorMessage.contains("requires at least one argument"), "Error message should contain 'requires at least one argument', got: \(errorMessage)") + } + } + + func testSumThrowsErrorOnNonNumericValue() throws { + // Test the Sum function directly since Format.fill uses try! which causes fatal errors + let sumFunc = Functions.Sum() + let textExpr = TextExpr("abc") + let numExpr1 = TextExpr("10") + let numExpr2 = TextExpr("20") + + XCTAssertThrowsError(try sumFunc.apply(row: row, arguments: [numExpr1, textExpr, numExpr2])) { error in + let errorMessage = String(describing: error) + XCTAssertTrue(errorMessage.contains("requires numeric values"), "Error message should contain 'requires numeric values', got: \(errorMessage)") + } + } + + func testSumWithStringColumnThrowsError() throws { + // Test the Sum function directly since Format.fill uses try! which causes fatal errors + let sumFunc = Functions.Sum() + let strExpr = VarExpr("str1") // "val1" which is not numeric + let numExpr = VarExpr("num1") // "150" which is numeric + + XCTAssertThrowsError(try sumFunc.apply(row: row, arguments: [strExpr, numExpr])) { error in + let errorMessage = String(describing: error) + XCTAssertTrue(errorMessage.contains("requires numeric values"), "Error message should contain 'requires numeric values', got: \(errorMessage)") + } + } + + func testSumInComplexExpression() throws { + let format = try Format(format: "Total: %{sum(${num1},${num2})} and text: ${str1}").validated(header: row.header) + XCTAssertEqual(format.fill(row: row), "Total: 350 and text: val1") + } + + func testSumWithNestedExpressions() throws { + // Sum with nested sum (if that makes sense) + let format = try Format(format: "Sum: %{sum(10,%{sum(20,30)})}").validated(header: row.header) + XCTAssertEqual(format.fill(row: row), "Sum: 60") + } + + func testMaxWithMultipleArguments() throws { + let format = try Format(format: "Max: %{max(1,5,3,9,2)}").validated(header: row.header) + XCTAssertEqual(format.fill(row: row), "Max: 9") + } + + func testMaxWithCommaSeparatedList() throws { + let format = try Format(format: "Max: %{max(10,20,30,15)}").validated(header: row.header) + XCTAssertEqual(format.fill(row: row), "Max: 30") + } + + func testMaxWithColumnVariables() throws { + let format = try Format(format: "Max: %{max(${num1},${num2})}").validated(header: row.header) + XCTAssertEqual(format.fill(row: row), "Max: 200") + } + + func testMaxWithDecimals() throws { + let format = try Format(format: "Max: %{max(10.5,20.3,30.2,25.7)}").validated(header: row.header) + XCTAssertEqual(format.fill(row: row), "Max: 30.2") + } + + func testMaxWithMixedIntegersAndDecimals() throws { + let format = try Format(format: "Max: %{max(10,20.5,30,15.8)}").validated(header: row.header) + XCTAssertEqual(format.fill(row: row), "Max: 30") + } + + func testMaxReturnsIntegerWhenWhole() throws { + let format = try Format(format: "%{max(10.0,20.0,30.0)}").validated(header: row.header) + let result = format.fill(row: row) + XCTAssertEqual(result, "30") + } + + func testMaxReturnsDecimalWhenNeeded() throws { + let format = try Format(format: "%{max(10.5,20.3)}").validated(header: row.header) + let result = format.fill(row: row) + XCTAssertEqual(result, "20.3") + } + + func testMaxWithSingleArgument() throws { + let format = try Format(format: "Max: %{max(42)}").validated(header: row.header) + XCTAssertEqual(format.fill(row: row), "Max: 42") + } + + func testMaxWithNegativeNumbers() throws { + let format = try Format(format: "Max: %{max(-10,-5,-3)}").validated(header: row.header) + XCTAssertEqual(format.fill(row: row), "Max: -3") + } + + func testMaxWithMixedPositiveAndNegative() throws { + let format = try Format(format: "Max: %{max(-10,5,-3,0)}").validated(header: row.header) + XCTAssertEqual(format.fill(row: row), "Max: 5") + } + + func testMaxValidationRequiresAtLeastOneArgument() throws { + XCTAssertThrowsError(try Format(format: "%{max()}").validated(header: row.header)) { error in + let errorMessage = String(describing: error) + XCTAssertTrue(errorMessage.contains("requires at least one argument"), "Error message should contain 'requires at least one argument', got: \(errorMessage)") + } + } + + func testMaxThrowsErrorOnNonNumericValue() throws { + let maxFunc = Functions.Max() + let textExpr = TextExpr("abc") + let numExpr1 = TextExpr("10") + let numExpr2 = TextExpr("20") + + XCTAssertThrowsError(try maxFunc.apply(row: row, arguments: [numExpr1, textExpr, numExpr2])) { error in + let errorMessage = String(describing: error) + XCTAssertTrue(errorMessage.contains("requires numeric values"), "Error message should contain 'requires numeric values', got: \(errorMessage)") + } + } + + func testMaxInComplexExpression() throws { + let format = try Format(format: "Maximum: %{max(${num1},${num2})} and text: ${str1}").validated(header: row.header) + XCTAssertEqual(format.fill(row: row), "Maximum: 200 and text: val1") + } + + func testMinWithMultipleArguments() throws { + let format = try Format(format: "Min: %{min(1,5,3,9,2)}").validated(header: row.header) + XCTAssertEqual(format.fill(row: row), "Min: 1") + } + + func testMinWithCommaSeparatedList() throws { + let format = try Format(format: "Min: %{min(10,20,30,15)}").validated(header: row.header) + XCTAssertEqual(format.fill(row: row), "Min: 10") + } + + func testMinWithColumnVariables() throws { + let format = try Format(format: "Min: %{min(${num1},${num2})}").validated(header: row.header) + XCTAssertEqual(format.fill(row: row), "Min: 150") + } + + func testMinWithDecimals() throws { + let format = try Format(format: "Min: %{min(10.5,20.3,30.2,25.7)}").validated(header: row.header) + XCTAssertEqual(format.fill(row: row), "Min: 10.5") + } + + func testMinWithMixedIntegersAndDecimals() throws { + let format = try Format(format: "Min: %{min(10,20.5,30,15.8)}").validated(header: row.header) + XCTAssertEqual(format.fill(row: row), "Min: 10") + } + + func testMinReturnsIntegerWhenWhole() throws { + let format = try Format(format: "%{min(10.0,20.0,30.0)}").validated(header: row.header) + let result = format.fill(row: row) + XCTAssertEqual(result, "10") + } + + func testMinReturnsDecimalWhenNeeded() throws { + let format = try Format(format: "%{min(10.5,20.3)}").validated(header: row.header) + let result = format.fill(row: row) + XCTAssertEqual(result, "10.5") + } + + func testMinWithSingleArgument() throws { + let format = try Format(format: "Min: %{min(42)}").validated(header: row.header) + XCTAssertEqual(format.fill(row: row), "Min: 42") + } + + func testMinWithNegativeNumbers() throws { + let format = try Format(format: "Min: %{min(-10,-5,-3)}").validated(header: row.header) + XCTAssertEqual(format.fill(row: row), "Min: -10") + } + + func testMinWithMixedPositiveAndNegative() throws { + let format = try Format(format: "Min: %{min(-10,5,-3,0)}").validated(header: row.header) + XCTAssertEqual(format.fill(row: row), "Min: -10") + } + + func testMinValidationRequiresAtLeastOneArgument() throws { + XCTAssertThrowsError(try Format(format: "%{min()}").validated(header: row.header)) { error in + let errorMessage = String(describing: error) + XCTAssertTrue(errorMessage.contains("requires at least one argument"), "Error message should contain 'requires at least one argument', got: \(errorMessage)") + } + } + + func testMinThrowsErrorOnNonNumericValue() throws { + let minFunc = Functions.Min() + let textExpr = TextExpr("abc") + let numExpr1 = TextExpr("10") + let numExpr2 = TextExpr("20") + + XCTAssertThrowsError(try minFunc.apply(row: row, arguments: [numExpr1, textExpr, numExpr2])) { error in + let errorMessage = String(describing: error) + XCTAssertTrue(errorMessage.contains("requires numeric values"), "Error message should contain 'requires numeric values', got: \(errorMessage)") + } + } + + func testMinInComplexExpression() throws { + let format = try Format(format: "Minimum: %{min(${num1},${num2})} and text: ${str1}").validated(header: row.header) + XCTAssertEqual(format.fill(row: row), "Minimum: 150 and text: val1") + } + + func testMinAndMaxTogether() throws { + let format = try Format(format: "Range: %{min(10,20,30)} to %{max(10,20,30)}").validated(header: row.header) + XCTAssertEqual(format.fill(row: row), "Range: 10 to 30") + } } diff --git a/Tests/table-Tests/NewColumnsTableViewTests.swift b/Tests/table-Tests/NewColumnsTableViewTests.swift new file mode 100644 index 0000000..fe19f38 --- /dev/null +++ b/Tests/table-Tests/NewColumnsTableViewTests.swift @@ -0,0 +1,306 @@ +import XCTest +@testable import table + +class NewColumnsTableViewTests: XCTestCase { + + func testAddSingleStaticColumn() throws { + let table = ParsedTable.fromArray([ + ["Alice", "30"], + ["Bob", "25"] + ], header: ["name", "age"]) + + let format = try Format(format: "StaticValue").validated(header: nil) + var newColumnsTable = NewColumnsTableView( + table: table, + additionalColumns: [("status", format)] + ) + + XCTAssertEqual(newColumnsTable.header.columnsStr(), "name,age,status") + + let row1 = try newColumnsTable.next()! + XCTAssertEqual(row1["name"], "Alice") + XCTAssertEqual(row1["age"], "30") + XCTAssertEqual(row1["status"], "StaticValue") + + let row2 = try newColumnsTable.next()! + XCTAssertEqual(row2["name"], "Bob") + XCTAssertEqual(row2["age"], "25") + XCTAssertEqual(row2["status"], "StaticValue") + + XCTAssertNil(try newColumnsTable.next()) + } + + func testAddMultipleStaticColumns() throws { + let table = ParsedTable.fromArray([ + ["Alice", "30"] + ], header: ["name", "age"]) + + let format1 = try Format(format: "Active").validated(header: nil) + let format2 = try Format(format: "Premium").validated(header: nil) + + var newColumnsTable = NewColumnsTableView( + table: table, + additionalColumns: [ + ("status", format1), + ("tier", format2) + ] + ) + + XCTAssertEqual(newColumnsTable.header.columnsStr(), "name,age,status,tier") + + let row = try newColumnsTable.next()! + XCTAssertEqual(row["name"], "Alice") + XCTAssertEqual(row["age"], "30") + XCTAssertEqual(row["status"], "Active") + XCTAssertEqual(row["tier"], "Premium") + + XCTAssertNil(try newColumnsTable.next()) + } + + func testAddColumnWithVariableSubstitution() throws { + let table = ParsedTable.fromArray([ + ["Alice", "30"], + ["Bob", "25"] + ], header: ["name", "age"]) + + let format = try Format(format: "Name: ${name}, Age: ${age}").validated(header: table.header) + var newColumnsTable = NewColumnsTableView( + table: table, + additionalColumns: [("info", format)] + ) + + XCTAssertEqual(newColumnsTable.header.columnsStr(), "name,age,info") + + + let row1 = try newColumnsTable.next()! + XCTAssertEqual(row1["name"], "Alice") + XCTAssertEqual(row1["age"], "30") + XCTAssertEqual(row1["info"], "Name: Alice, Age: 30") + + let row2 = try newColumnsTable.next()! + XCTAssertEqual(row2["name"], "Bob") + XCTAssertEqual(row2["age"], "25") + XCTAssertEqual(row2["info"], "Name: Bob, Age: 25") + + XCTAssertNil(try newColumnsTable.next()) + } + + func testAddColumnWithFunction() throws { + let table = ParsedTable.fromArray([ + ["Alice", "30"], + ["Bob", "25"] + ], header: ["name", "age"]) + + let format = try Format(format: "Header: %{header()}").validated(header: table.header) + var newColumnsTable = NewColumnsTableView( + table: table, + additionalColumns: [("header_info", format)] + ) + + let row1 = try newColumnsTable.next()! + XCTAssertEqual(row1["header_info"], "Header: name,age") + + let row2 = try newColumnsTable.next()! + XCTAssertEqual(row2["header_info"], "Header: name,age") + + XCTAssertNil(try newColumnsTable.next()) + } + + func testAddColumnWithComplexExpression() throws { + let table = ParsedTable.fromArray([ + ["Alice", "30", "100"], + ["Bob", "25", "200"] + ], header: ["name", "age", "score"]) + + let format = try Format(format: "Sum: %{sum(${age},${score})}").validated(header: table.header) + var newColumnsTable = NewColumnsTableView( + table: table, + additionalColumns: [("total", format)] + ) + + let row1 = try newColumnsTable.next()! + XCTAssertEqual(row1["total"], "Sum: 130") + + let row2 = try newColumnsTable.next()! + XCTAssertEqual(row2["total"], "Sum: 225") + + XCTAssertNil(try newColumnsTable.next()) + } + + func testEmptyTable() throws { + let table = ParsedTable.empty() + + let format = try Format(format: "NewColumn").validated(header: nil) + var newColumnsTable = NewColumnsTableView( + table: table, + additionalColumns: [("new_col", format)] + ) + + XCTAssertEqual(newColumnsTable.header.columnsStr(), "new_col") + XCTAssertNil(try newColumnsTable.next()) + } + + func testPreservesRowIndex() throws { + let table = ParsedTable.fromArray([ + ["Alice"], + ["Bob"], + ["Charlie"] + ], header: ["name"]) + + let format = try Format(format: "Extra").validated(header: nil) + var newColumnsTable = NewColumnsTableView( + table: table, + additionalColumns: [("extra", format)] + ) + + let row1 = try newColumnsTable.next()! + XCTAssertEqual(row1.index, 0) + + let row2 = try newColumnsTable.next()! + XCTAssertEqual(row2.index, 1) + + let row3 = try newColumnsTable.next()! + XCTAssertEqual(row3.index, 2) + + XCTAssertNil(try newColumnsTable.next()) + } + + func testMultipleColumnsWithDifferentFormats() throws { + let table = ParsedTable.fromArray([ + ["Alice", "30"] + ], header: ["name", "age"]) + + let staticFormat = try Format(format: "Static").validated(header: nil) + let varFormat = try Format(format: "${name}").validated(header: table.header) + let funcFormat = try Format(format: "%{values()}").validated(header: table.header) + + var newColumnsTable = NewColumnsTableView( + table: table, + additionalColumns: [ + ("static_col", staticFormat), + ("name_copy", varFormat), + ("all_values", funcFormat) + ] + ) + + XCTAssertEqual(newColumnsTable.header.columnsStr(), "name,age,static_col,name_copy,all_values") + + let row = try newColumnsTable.next()! + XCTAssertEqual(row["static_col"], "Static") + XCTAssertEqual(row["name_copy"], "Alice") + XCTAssertEqual(row["all_values"], "Alice,30") + + XCTAssertNil(try newColumnsTable.next()) + } + + func testColumnOrderPreservation() throws { + let table = ParsedTable.fromArray([ + ["Alice"] + ], header: ["name"]) + + let format1 = try Format(format: "First").validated(header: nil) + let format2 = try Format(format: "Second").validated(header: nil) + let format3 = try Format(format: "Third").validated(header: nil) + + var newColumnsTable = NewColumnsTableView( + table: table, + additionalColumns: [ + ("first", format1), + ("second", format2), + ("third", format3) + ] + ) + + let row = try newColumnsTable.next()! + XCTAssertEqual(row[0], "Alice") + XCTAssertEqual(row[1], "First") + XCTAssertEqual(row[2], "Second") + XCTAssertEqual(row[3], "Third") + + XCTAssertNil(try newColumnsTable.next()) + } + + func testColumnWithEmptyFormat() throws { + let table = ParsedTable.fromArray([ + ["Alice"] + ], header: ["name"]) + + let format = try Format(format: "").validated(header: nil) + var newColumnsTable = NewColumnsTableView( + table: table, + additionalColumns: [("empty", format)] + ) + + let row = try newColumnsTable.next()! + XCTAssertEqual(row["empty"], "") + + XCTAssertNil(try newColumnsTable.next()) + } + + func testColumnWithWhitespace() throws { + let table = ParsedTable.fromArray([["Alice"]], header: ["name"]) + + let format = try Format(format: " Padded ").validated(header: nil) + var newColumnsTable = NewColumnsTableView( + table: table, + additionalColumns: [("padded", format)] + ) + + let row = try newColumnsTable.next()! + XCTAssertEqual(row["padded"], " Padded ") + + XCTAssertNil(try newColumnsTable.next()) + } + + func testColumnWithSpecialCharacters() throws { + let table = ParsedTable.fromArray([["Alice"]], header: ["name"]) + + let format = try Format(format: "Value: $100 & 50%").validated(header: nil) + var newColumnsTable = NewColumnsTableView( + table: table, + additionalColumns: [("special", format)] + ) + + let row = try newColumnsTable.next()! + XCTAssertEqual(row["special"], "Value: $100 & 50%") + + XCTAssertNil(try newColumnsTable.next()) + } + + func testColumnAccessByIndex() throws { + let table = ParsedTable.fromArray([ + ["Alice", "30"] + ], header: ["name", "age"]) + + let format = try Format(format: "Extra").validated(header: nil) + var newColumnsTable = NewColumnsTableView( + table: table, + additionalColumns: [("extra", format)] + ) + + let row = try newColumnsTable.next()! + XCTAssertEqual(row[0], "Alice") + XCTAssertEqual(row[1], "30") + XCTAssertEqual(row[2], "Extra") + + XCTAssertNil(try newColumnsTable.next()) + } + + func testColumnWithMaxFunction() throws { + let table = ParsedTable.fromArray([ + ["Alice", "30", "40"] + ], header: ["name", "age", "score"]) + + let format = try Format(format: "Max: %{max(${age},${score})}").validated(header: table.header) + var newColumnsTable = NewColumnsTableView( + table: table, + additionalColumns: [("max_value", format)] + ) + + let row = try newColumnsTable.next()! + XCTAssertEqual(row["max_value"], "Max: 40") + + XCTAssertNil(try newColumnsTable.next()) + } +} + diff --git a/Tests/table-Tests/SortTests.swift b/Tests/table-Tests/SortTests.swift new file mode 100644 index 0000000..7474e8a --- /dev/null +++ b/Tests/table-Tests/SortTests.swift @@ -0,0 +1,500 @@ +import XCTest +@testable import table + +class SortTests: XCTestCase { + + func testSortSingleColumnAscending() throws { + let table = ParsedTable.fromArray([ + ["3", "Charlie"], + ["1", "Alice"], + ["2", "Bob"] + ], header: ["id", "name"]) + + let sortExpr = try Sort("id").validated(header: table.header) + var sortedTable = try InMemoryTableView(table: table).sort(expr: sortExpr) + + let row1 = try sortedTable.next()! + XCTAssertEqual(row1["id"], "1") + XCTAssertEqual(row1["name"], "Alice") + + let row2 = try sortedTable.next()! + XCTAssertEqual(row2["id"], "2") + XCTAssertEqual(row2["name"], "Bob") + + let row3 = try sortedTable.next()! + XCTAssertEqual(row3["id"], "3") + XCTAssertEqual(row3["name"], "Charlie") + + XCTAssertNil(try sortedTable.next()) + } + + func testSortSingleColumnDescending() throws { + let table = ParsedTable.fromArray([ + ["1", "Alice"], + ["3", "Charlie"], + ["2", "Bob"] + ], header: ["id", "name"]) + + let sortExpr = try Sort("!id").validated(header: table.header) + var sortedTable = try InMemoryTableView(table: table).sort(expr: sortExpr) + + let row1 = try sortedTable.next()! + XCTAssertEqual(row1["id"], "3") + XCTAssertEqual(row1["name"], "Charlie") + + let row2 = try sortedTable.next()! + XCTAssertEqual(row2["id"], "2") + XCTAssertEqual(row2["name"], "Bob") + + let row3 = try sortedTable.next()! + XCTAssertEqual(row3["id"], "1") + XCTAssertEqual(row3["name"], "Alice") + + XCTAssertNil(try sortedTable.next()) + } + + func testSortMultipleColumns() throws { + // Sort by name first (ascending), then by id (ascending) + let table = ParsedTable.fromArray([ + ["2", "Alice"], + ["1", "Alice"], + ["3", "Bob"], + ["4", "Alice"] + ], header: ["id", "name"]) + + let sortExpr = try Sort("name,id").validated(header: table.header) + var sortedTable = try InMemoryTableView(table: table).sort(expr: sortExpr) + + // All Alice rows should come first, sorted by id + let row1 = try sortedTable.next()! + XCTAssertEqual(row1["id"], "1") + XCTAssertEqual(row1["name"], "Alice") + + let row2 = try sortedTable.next()! + XCTAssertEqual(row2["id"], "2") + XCTAssertEqual(row2["name"], "Alice") + + let row3 = try sortedTable.next()! + XCTAssertEqual(row3["id"], "4") + XCTAssertEqual(row3["name"], "Alice") + + let row4 = try sortedTable.next()! + XCTAssertEqual(row4["id"], "3") + XCTAssertEqual(row4["name"], "Bob") + + XCTAssertNil(try sortedTable.next()) + } + + func testSortMultipleColumnsMixedOrder() throws { + // Sort by name descending, then by id ascending + let table = ParsedTable.fromArray([ + ["1", "Alice"], + ["2", "Bob"], + ["3", "Alice"], + ["4", "Bob"] + ], header: ["id", "name"]) + + let sortExpr = try Sort("!name,id").validated(header: table.header) + var sortedTable = try InMemoryTableView(table: table).sort(expr: sortExpr) + + // Bob rows first (descending name), then Alice rows + let row1 = try sortedTable.next()! + XCTAssertEqual(row1["id"], "2") + XCTAssertEqual(row1["name"], "Bob") + + let row2 = try sortedTable.next()! + XCTAssertEqual(row2["id"], "4") + XCTAssertEqual(row2["name"], "Bob") + + let row3 = try sortedTable.next()! + XCTAssertEqual(row3["id"], "1") + XCTAssertEqual(row3["name"], "Alice") + + let row4 = try sortedTable.next()! + XCTAssertEqual(row4["id"], "3") + XCTAssertEqual(row4["name"], "Alice") + + XCTAssertNil(try sortedTable.next()) + } + + func testSortNumericValues() throws { + // Numeric sorting should be numeric-aware, not lexicographic + let table = ParsedTable.fromArray([ + ["10", "Item 10"], + ["2", "Item 2"], + ["1", "Item 1"], + ["20", "Item 20"] + ], header: ["id", "name"]) + + let sortExpr = try Sort("id").validated(header: table.header) + var sortedTable = try InMemoryTableView(table: table).sort(expr: sortExpr) + + // Should be sorted numerically: 1, 2, 10, 20 (not lexicographically: 1, 10, 2, 20) + let row1 = try sortedTable.next()! + XCTAssertEqual(row1["id"], "1") + + let row2 = try sortedTable.next()! + XCTAssertEqual(row2["id"], "2") + + let row3 = try sortedTable.next()! + XCTAssertEqual(row3["id"], "10") + + let row4 = try sortedTable.next()! + XCTAssertEqual(row4["id"], "20") + + XCTAssertNil(try sortedTable.next()) + } + + func testSortNumericDescending() throws { + let table = ParsedTable.fromArray([ + ["5", "Item 5"], + ["100", "Item 100"], + ["1", "Item 1"], + ["50", "Item 50"] + ], header: ["id", "name"]) + + let sortExpr = try Sort("!id").validated(header: table.header) + var sortedTable = try InMemoryTableView(table: table).sort(expr: sortExpr) + + // Should be sorted numerically descending: 100, 50, 5, 1 + let row1 = try sortedTable.next()! + XCTAssertEqual(row1["id"], "100") + + let row2 = try sortedTable.next()! + XCTAssertEqual(row2["id"], "50") + + let row3 = try sortedTable.next()! + XCTAssertEqual(row3["id"], "5") + + let row4 = try sortedTable.next()! + XCTAssertEqual(row4["id"], "1") + + XCTAssertNil(try sortedTable.next()) + } + + func testSortMixedNumericAndString() throws { + // When comparing: if both are numeric, compare numerically; otherwise compare as strings + let table = ParsedTable.fromArray([ + ["10", "Item 10"], + ["2a", "Item 2a"], + ["1", "Item 1"], + ["2", "Item 2"] + ], header: ["id", "name"]) + + let sortExpr = try Sort("id").validated(header: table.header) + var sortedTable = try InMemoryTableView(table: table).sort(expr: sortExpr) + + // Numeric values sorted numerically first, then strings lexicographically + // "1" and "2" and "10" are numeric, so sorted: 1, 2, 10 + // "2a" is string, compared with others as string: "10" < "2a" lexicographically + let row1 = try sortedTable.next()! + XCTAssertEqual(row1["id"], "1") + + let row2 = try sortedTable.next()! + XCTAssertEqual(row2["id"], "2") + + let row3 = try sortedTable.next()! + XCTAssertEqual(row3["id"], "10") // Numeric comparison: 10 > 2 + + let row4 = try sortedTable.next()! + XCTAssertEqual(row4["id"], "2a") // String comparison: "10" < "2a" lexicographically + + XCTAssertNil(try sortedTable.next()) + } + + func testSortStringValues() throws { + let table = ParsedTable.fromArray([ + ["1", "Zebra"], + ["2", "Apple"], + ["3", "Banana"] + ], header: ["id", "name"]) + + let sortExpr = try Sort("name").validated(header: table.header) + var sortedTable = try InMemoryTableView(table: table).sort(expr: sortExpr) + + let row1 = try sortedTable.next()! + XCTAssertEqual(row1["name"], "Apple") + + let row2 = try sortedTable.next()! + XCTAssertEqual(row2["name"], "Banana") + + let row3 = try sortedTable.next()! + XCTAssertEqual(row3["name"], "Zebra") + + XCTAssertNil(try sortedTable.next()) + } + + func testSortStringDescending() throws { + let table = ParsedTable.fromArray([ + ["1", "Apple"], + ["2", "Banana"], + ["3", "Zebra"] + ], header: ["id", "name"]) + + let sortExpr = try Sort("!name").validated(header: table.header) + var sortedTable = try InMemoryTableView(table: table).sort(expr: sortExpr) + + let row1 = try sortedTable.next()! + XCTAssertEqual(row1["name"], "Zebra") + + let row2 = try sortedTable.next()! + XCTAssertEqual(row2["name"], "Banana") + + let row3 = try sortedTable.next()! + XCTAssertEqual(row3["name"], "Apple") + + XCTAssertNil(try sortedTable.next()) + } + + func testSortCaseSensitive() throws { + // String sorting should be case-sensitive + let table = ParsedTable.fromArray([ + ["1", "apple"], + ["2", "Banana"], + ["3", "Apple"] + ], header: ["id", "name"]) + + let sortExpr = try Sort("name").validated(header: table.header) + var sortedTable = try InMemoryTableView(table: table).sort(expr: sortExpr) + + // Case-sensitive: "Apple" comes before "Banana" (A < B), and "apple" comes after (a > A) + let row1 = try sortedTable.next()! + XCTAssertEqual(row1["name"], "Apple") + + let row2 = try sortedTable.next()! + XCTAssertEqual(row2["name"], "Banana") + + let row3 = try sortedTable.next()! + XCTAssertEqual(row3["name"], "apple") + + XCTAssertNil(try sortedTable.next()) + } + + func testSortEmptyTable() throws { + let table = ParsedTable.fromArray([], header: ["id", "name"]) + + let sortExpr = try Sort("id").validated(header: table.header) + var sortedTable = try InMemoryTableView(table: table).sort(expr: sortExpr) + + XCTAssertNil(try sortedTable.next()) + } + + func testSortSingleRow() throws { + let table = ParsedTable.fromArray([ + ["1", "Alice"] + ], header: ["id", "name"]) + + let sortExpr = try Sort("id").validated(header: table.header) + var sortedTable = try InMemoryTableView(table: table).sort(expr: sortExpr) + + let row = try sortedTable.next()! + XCTAssertEqual(row["id"], "1") + XCTAssertEqual(row["name"], "Alice") + + XCTAssertNil(try sortedTable.next()) + } + + func testSortAlreadySorted() throws { + // Sorting an already sorted table should maintain order + let table = ParsedTable.fromArray([ + ["1", "Alice"], + ["2", "Bob"], + ["3", "Charlie"] + ], header: ["id", "name"]) + + let sortExpr = try Sort("id").validated(header: table.header) + var sortedTable = try InMemoryTableView(table: table).sort(expr: sortExpr) + + let row1 = try sortedTable.next()! + XCTAssertEqual(row1["id"], "1") + + let row2 = try sortedTable.next()! + XCTAssertEqual(row2["id"], "2") + + let row3 = try sortedTable.next()! + XCTAssertEqual(row3["id"], "3") + + XCTAssertNil(try sortedTable.next()) + } + + func testSortWithWhitespaceInColumnNames() throws { + // Test that whitespace in sort expression is handled correctly + let table = ParsedTable.fromArray([ + ["2", "Bob"], + ["1", "Alice"] + ], header: ["id", "name"]) + + // Sort expression with whitespace + let sortExpr = try Sort(" id ").validated(header: table.header) + var sortedTable = try InMemoryTableView(table: table).sort(expr: sortExpr) + + let row1 = try sortedTable.next()! + XCTAssertEqual(row1["id"], "1") + + let row2 = try sortedTable.next()! + XCTAssertEqual(row2["id"], "2") + + XCTAssertNil(try sortedTable.next()) + } + + func testSortWithMultipleWhitespace() throws { + let table = ParsedTable.fromArray([ + ["2", "Bob", "X"], + ["1", "Alice", "Y"] + ], header: ["id", "name", "status"]) + + // Multiple columns with whitespace + let sortExpr = try Sort(" name , id ").validated(header: table.header) + var sortedTable = try InMemoryTableView(table: table).sort(expr: sortExpr) + + let row1 = try sortedTable.next()! + XCTAssertEqual(row1["name"], "Alice") + + let row2 = try sortedTable.next()! + XCTAssertEqual(row2["name"], "Bob") + + XCTAssertNil(try sortedTable.next()) + } + + func testSortThrowsErrorOnInvalidColumn() throws { + let table = ParsedTable.fromArray([ + ["1", "Alice"] + ], header: ["id", "name"]) + + XCTAssertThrowsError(try Sort("nonexistent").validated(header: table.header)) { error in + let errorMessage = String(describing: error) + XCTAssertTrue(errorMessage.contains("Unknown column"), "Error should mention unknown column, got: \(errorMessage)") + XCTAssertTrue(errorMessage.contains("nonexistent"), "Error should mention the column name, got: \(errorMessage)") + } + } + + func testSortThrowsErrorOnMultipleInvalidColumns() throws { + let table = ParsedTable.fromArray([ + ["1", "Alice"] + ], header: ["id", "name"]) + + XCTAssertThrowsError(try Sort("id,invalid1,invalid2").validated(header: table.header)) { error in + let errorMessage = String(describing: error) + XCTAssertTrue(errorMessage.contains("Unknown column"), "Error should mention unknown column, got: \(errorMessage)") + } + } + + func testSortThreeColumns() throws { + // Sort by three columns + let table = ParsedTable.fromArray([ + ["1", "Alice", "A"], + ["2", "Alice", "B"], + ["3", "Bob", "A"], + ["4", "Alice", "A"] + ], header: ["id", "name", "status"]) + + let sortExpr = try Sort("name,status,id").validated(header: table.header) + var sortedTable = try InMemoryTableView(table: table).sort(expr: sortExpr) + + // All Alice rows first, sorted by status, then by id + let row1 = try sortedTable.next()! + XCTAssertEqual(row1["id"], "1") + XCTAssertEqual(row1["name"], "Alice") + XCTAssertEqual(row1["status"], "A") + + let row2 = try sortedTable.next()! + XCTAssertEqual(row2["id"], "4") + XCTAssertEqual(row2["name"], "Alice") + XCTAssertEqual(row2["status"], "A") + + let row3 = try sortedTable.next()! + XCTAssertEqual(row3["id"], "2") + XCTAssertEqual(row3["name"], "Alice") + XCTAssertEqual(row3["status"], "B") + + let row4 = try sortedTable.next()! + XCTAssertEqual(row4["id"], "3") + XCTAssertEqual(row4["name"], "Bob") + XCTAssertEqual(row4["status"], "A") + + XCTAssertNil(try sortedTable.next()) + } + + func testSortAllDescending() throws { + let table = ParsedTable.fromArray([ + ["1", "Alice"], + ["2", "Bob"], + ["3", "Alice"] + ], header: ["id", "name"]) + + let sortExpr = try Sort("!name,!id").validated(header: table.header) + var sortedTable = try InMemoryTableView(table: table).sort(expr: sortExpr) + + // Bob first (descending name), then Alice rows in descending id order + let row1 = try sortedTable.next()! + XCTAssertEqual(row1["name"], "Bob") + XCTAssertEqual(row1["id"], "2") + + let row2 = try sortedTable.next()! + XCTAssertEqual(row2["name"], "Alice") + XCTAssertEqual(row2["id"], "3") + + let row3 = try sortedTable.next()! + XCTAssertEqual(row3["name"], "Alice") + XCTAssertEqual(row3["id"], "1") + + XCTAssertNil(try sortedTable.next()) + } + + func testSortWithNegativeNumbers() throws { + let table = ParsedTable.fromArray([ + ["-5", "Item -5"], + ["10", "Item 10"], + ["-1", "Item -1"], + ["5", "Item 5"] + ], header: ["id", "name"]) + + let sortExpr = try Sort("id").validated(header: table.header) + var sortedTable = try InMemoryTableView(table: table).sort(expr: sortExpr) + + // Should sort numerically: -5, -1, 5, 10 + let row1 = try sortedTable.next()! + XCTAssertEqual(row1["id"], "-5") + + let row2 = try sortedTable.next()! + XCTAssertEqual(row2["id"], "-1") + + let row3 = try sortedTable.next()! + XCTAssertEqual(row3["id"], "5") + + let row4 = try sortedTable.next()! + XCTAssertEqual(row4["id"], "10") + + XCTAssertNil(try sortedTable.next()) + } + + func testSortPreservesOriginalRowData() throws { + // Ensure sorting doesn't modify the actual row data, just the order + let table = ParsedTable.fromArray([ + ["3", "Charlie", "C"], + ["1", "Alice", "A"], + ["2", "Bob", "B"] + ], header: ["id", "name", "status"]) + + let sortExpr = try Sort("id").validated(header: table.header) + var sortedTable = try InMemoryTableView(table: table).sort(expr: sortExpr) + + let row1 = try sortedTable.next()! + XCTAssertEqual(row1["id"], "1") + XCTAssertEqual(row1["name"], "Alice") + XCTAssertEqual(row1["status"], "A") + + let row2 = try sortedTable.next()! + XCTAssertEqual(row2["id"], "2") + XCTAssertEqual(row2["name"], "Bob") + XCTAssertEqual(row2["status"], "B") + + let row3 = try sortedTable.next()! + XCTAssertEqual(row3["id"], "3") + XCTAssertEqual(row3["name"], "Charlie") + XCTAssertEqual(row3["status"], "C") + + XCTAssertNil(try sortedTable.next()) + } +} + diff --git a/Tests/table-Tests/TableJoinTests.swift b/Tests/table-Tests/TableJoinTests.swift new file mode 100644 index 0000000..3892bc9 --- /dev/null +++ b/Tests/table-Tests/TableJoinTests.swift @@ -0,0 +1,343 @@ +import XCTest +@testable import table + +class JoinTests: XCTestCase { + + func testBasicJoinWithMatchingRows() throws { + // First table: users with id and name + let table1 = ParsedTable.fromArray([ + ["1", "Alice"], + ["2", "Bob"], + ["3", "Charlie"] + ], header: ["id", "name"]) + + // Second table: user details with user_id and email + let table2 = ParsedTable.fromArray([ + ["1", "alice@example.com"], + ["2", "bob@example.com"], + ["3", "charlie@example.com"] + ], header: ["user_id", "email"]) + + let join = try Join.parse(table2, joinOn: "id=user_id", firstTable: table1) + let joinedTable = JoinTableView(table: table1, join: join) + + // First row + let row1 = try joinedTable.next()! + XCTAssertEqual(row1["id"], "1") + XCTAssertEqual(row1["name"], "Alice") + XCTAssertEqual(row1["user_id"], "1") + XCTAssertEqual(row1["email"], "alice@example.com") + + // Second row + let row2 = try joinedTable.next()! + XCTAssertEqual(row2["id"], "2") + XCTAssertEqual(row2["name"], "Bob") + XCTAssertEqual(row2["user_id"], "2") + XCTAssertEqual(row2["email"], "bob@example.com") + + // Third row + let row3 = try joinedTable.next()! + XCTAssertEqual(row3["id"], "3") + XCTAssertEqual(row3["name"], "Charlie") + XCTAssertEqual(row3["user_id"], "3") + XCTAssertEqual(row3["email"], "charlie@example.com") + + XCTAssertNil(try joinedTable.next()) + } + + func testJoinWithNoMatches() throws { + // Left join behavior: rows from first table are kept even if no match + let table1 = ParsedTable.fromArray([ + ["1", "Alice"], + ["2", "Bob"], + ["99", "Unknown"] + ], header: ["id", "name"]) + + let table2 = ParsedTable.fromArray([ + ["1", "alice@example.com"], + ["2", "bob@example.com"] + ], header: ["user_id", "email"]) + + let join = try Join.parse(table2, joinOn: "id=user_id", firstTable: table1) + let joinedTable = JoinTableView(table: table1, join: join) + + // First row - has match + let row1 = try joinedTable.next()! + XCTAssertEqual(row1["id"], "1") + XCTAssertEqual(row1["name"], "Alice") + XCTAssertEqual(row1["email"], "alice@example.com") + + // Second row - has match + let row2 = try joinedTable.next()! + XCTAssertEqual(row2["id"], "2") + XCTAssertEqual(row2["name"], "Bob") + XCTAssertEqual(row2["email"], "bob@example.com") + + // Third row - no match, should have empty cells for joined columns + let row3 = try joinedTable.next()! + XCTAssertEqual(row3["id"], "99") + XCTAssertEqual(row3["name"], "Unknown") + XCTAssertEqual(row3["user_id"], "") + XCTAssertEqual(row3["email"], "") + + XCTAssertNil(try joinedTable.next()) + } + + func testJoinWithDefaultFirstColumns() throws { + // When no join expression is provided, uses first column of both tables + let table1 = ParsedTable.fromArray([ + ["1", "Alice"], + ["2", "Bob"] + ], header: ["id", "name"]) + + let table2 = ParsedTable.fromArray([ + ["1", "active"], + ["2", "inactive"] + ], header: ["id", "status"]) + + let join = try Join.parse(table2, joinOn: nil, firstTable: table1) + let joinedTable = JoinTableView(table: table1, join: join) + + let row1 = try joinedTable.next()! + XCTAssertEqual(row1["id"], "1") + XCTAssertEqual(row1["name"], "Alice") + XCTAssertEqual(row1["status"], "active") + + let row2 = try joinedTable.next()! + XCTAssertEqual(row2["id"], "2") + XCTAssertEqual(row2["name"], "Bob") + XCTAssertEqual(row2["status"], "inactive") + + XCTAssertNil(try joinedTable.next()) + } + + func testJoinHeaderCombination() throws { + let table1 = ParsedTable.fromArray([ + ["1", "Alice"] + ], header: ["id", "name"]) + + let table2 = ParsedTable.fromArray([ + ["1", "alice@example.com", "active"] + ], header: ["user_id", "email", "status"]) + + let join = try Join.parse(table2, joinOn: "id=user_id", firstTable: table1) + let joinedTable = JoinTableView(table: table1, join: join) + + // Header should combine both tables + let header = joinedTable.header + XCTAssertEqual(header.components(), ["id", "name", "user_id", "email", "status"]) + XCTAssertEqual(header.size, 5) + } + + func testJoinThrowsErrorOnDuplicateValues() throws { + // Second table has duplicate values in join column + let table1 = ParsedTable.fromArray([ + ["1", "Alice"] + ], header: ["id", "name"]) + + let table2 = ParsedTable.fromArray([ + ["1", "email1@example.com"], + ["1", "email2@example.com"] // Duplicate id + ], header: ["user_id", "email"]) + + XCTAssertThrowsError(try Join.parse(table2, joinOn: "id=user_id", firstTable: table1)) { error in + let errorMessage = String(describing: error) + XCTAssertTrue(errorMessage.contains("duplicate values"), "Error should mention duplicate values, got: \(errorMessage)") + } + } + + func testJoinThrowsErrorOnMissingColumnInSecondTable() throws { + let table1 = ParsedTable.fromArray([ + ["1", "Alice"] + ], header: ["id", "name"]) + + let table2 = ParsedTable.fromArray([ + ["1", "email@example.com"] + ], header: ["user_id", "email"]) + + XCTAssertThrowsError(try Join.parse(table2, joinOn: "id=nonexistent", firstTable: table1)) { error in + let errorMessage = String(describing: error) + XCTAssertTrue(errorMessage.contains("not found"), "Error should mention column not found, got: \(errorMessage)") + } + } + + func testJoinThrowsErrorOnInvalidJoinExpression() throws { + let table1 = ParsedTable.fromArray([ + ["1", "Alice"] + ], header: ["id", "name"]) + + let table2 = ParsedTable.fromArray([ + ["1", "email@example.com"] + ], header: ["user_id", "email"]) + + // Invalid format - no equals sign + XCTAssertThrowsError(try Join.parse(table2, joinOn: "id-user_id", firstTable: table1)) { error in + let errorMessage = String(describing: error) + XCTAssertTrue(errorMessage.contains("format"), "Error should mention format, got: \(errorMessage)") + } + + // Invalid format - too many parts + XCTAssertThrowsError(try Join.parse(table2, joinOn: "id=user_id=extra", firstTable: table1)) { error in + let errorMessage = String(describing: error) + XCTAssertTrue(errorMessage.contains("format"), "Error should mention format, got: \(errorMessage)") + } + } + + func testJoinWithMultipleColumns() throws { + // Test join with tables that have multiple columns + let table1 = ParsedTable.fromArray([ + ["1", "Alice", "25"], + ["2", "Bob", "30"] + ], header: ["id", "name", "age"]) + + let table2 = ParsedTable.fromArray([ + ["1", "alice@example.com", "Engineer"], + ["2", "bob@example.com", "Manager"] + ], header: ["user_id", "email", "role"]) + + let join = try Join.parse(table2, joinOn: "id=user_id", firstTable: table1) + let joinedTable = JoinTableView(table: table1, join: join) + + let row1 = try joinedTable.next()! + XCTAssertEqual(row1["id"], "1") + XCTAssertEqual(row1["name"], "Alice") + XCTAssertEqual(row1["age"], "25") + XCTAssertEqual(row1["user_id"], "1") + XCTAssertEqual(row1["email"], "alice@example.com") + XCTAssertEqual(row1["role"], "Engineer") + + let row2 = try joinedTable.next()! + XCTAssertEqual(row2["id"], "2") + XCTAssertEqual(row2["name"], "Bob") + XCTAssertEqual(row2["age"], "30") + XCTAssertEqual(row2["user_id"], "2") + XCTAssertEqual(row2["email"], "bob@example.com") + XCTAssertEqual(row2["role"], "Manager") + + XCTAssertNil(try joinedTable.next()) + } + + func testJoinWithPartialMatches() throws { + // Some rows match, some don't + let table1 = ParsedTable.fromArray([ + ["1", "Alice"], + ["2", "Bob"], + ["3", "Charlie"], + ["4", "David"] + ], header: ["id", "name"]) + + let table2 = ParsedTable.fromArray([ + ["1", "alice@example.com"], + ["3", "charlie@example.com"] + ], header: ["user_id", "email"]) + + let join = try Join.parse(table2, joinOn: "id=user_id", firstTable: table1) + let joinedTable = JoinTableView(table: table1, join: join) + + // Row 1 - has match + let row1 = try joinedTable.next()! + XCTAssertEqual(row1["id"], "1") + XCTAssertEqual(row1["name"], "Alice") + XCTAssertEqual(row1["email"], "alice@example.com") + + // Row 2 - no match + let row2 = try joinedTable.next()! + XCTAssertEqual(row2["id"], "2") + XCTAssertEqual(row2["name"], "Bob") + XCTAssertEqual(row2["email"], "") + + // Row 3 - has match + let row3 = try joinedTable.next()! + XCTAssertEqual(row3["id"], "3") + XCTAssertEqual(row3["name"], "Charlie") + XCTAssertEqual(row3["email"], "charlie@example.com") + + // Row 4 - no match + let row4 = try joinedTable.next()! + XCTAssertEqual(row4["id"], "4") + XCTAssertEqual(row4["name"], "David") + XCTAssertEqual(row4["email"], "") + + XCTAssertNil(try joinedTable.next()) + } + + func testJoinWithEmptySecondTable() throws { + // Second table is empty + let table1 = ParsedTable.fromArray([ + ["1", "Alice"], + ["2", "Bob"] + ], header: ["id", "name"]) + + let table2 = ParsedTable.fromArray([], header: ["user_id", "email"]) + + let join = try Join.parse(table2, joinOn: "id=user_id", firstTable: table1) + let joinedTable = JoinTableView(table: table1, join: join) + + // All rows should have empty joined columns + let row1 = try joinedTable.next()! + XCTAssertEqual(row1["id"], "1") + XCTAssertEqual(row1["name"], "Alice") + XCTAssertEqual(row1["user_id"], "") + XCTAssertEqual(row1["email"], "") + + let row2 = try joinedTable.next()! + XCTAssertEqual(row2["id"], "2") + XCTAssertEqual(row2["name"], "Bob") + XCTAssertEqual(row2["user_id"], "") + XCTAssertEqual(row2["email"], "") + + XCTAssertNil(try joinedTable.next()) + } + + func testJoinWithNumericValues() throws { + // Test join with numeric string values + let table1 = ParsedTable.fromArray([ + ["100", "Product A"], + ["200", "Product B"] + ], header: ["product_id", "name"]) + + let table2 = ParsedTable.fromArray([ + ["100", "10.99"], + ["200", "20.50"] + ], header: ["id", "price"]) + + let join = try Join.parse(table2, joinOn: "product_id=id", firstTable: table1) + let joinedTable = JoinTableView(table: table1, join: join) + + let row1 = try joinedTable.next()! + XCTAssertEqual(row1["product_id"], "100") + XCTAssertEqual(row1["name"], "Product A") + XCTAssertEqual(row1["id"], "100") + XCTAssertEqual(row1["price"], "10.99") + + let row2 = try joinedTable.next()! + XCTAssertEqual(row2["product_id"], "200") + XCTAssertEqual(row2["name"], "Product B") + XCTAssertEqual(row2["id"], "200") + XCTAssertEqual(row2["price"], "20.50") + + XCTAssertNil(try joinedTable.next()) + } + + func testJoinPreservesRowIndex() throws { + // Verify that row indices are preserved from the first table + let table1 = ParsedTable.fromArray([ + ["1", "Alice"], + ["2", "Bob"] + ], header: ["id", "name"]) + + let table2 = ParsedTable.fromArray([ + ["1", "alice@example.com"] + ], header: ["user_id", "email"]) + + let join = try Join.parse(table2, joinOn: "id=user_id", firstTable: table1) + let joinedTable = JoinTableView(table: table1, join: join) + + let row1 = try joinedTable.next()! + XCTAssertEqual(row1.index, 0) + + let row2 = try joinedTable.next()! + XCTAssertEqual(row2.index, 1) + } +} + diff --git a/Tests/table-Tests/TableParserTests.swift b/Tests/table-Tests/TableParserTests.swift index e050685..3eed174 100644 --- a/Tests/table-Tests/TableParserTests.swift +++ b/Tests/table-Tests/TableParserTests.swift @@ -29,6 +29,376 @@ class TableParserTests: XCTestCase { XCTAssertEqual(row.components[0].value, "Val,1") XCTAssertEqual(row.components[1].value, "Val,2") + } + + func testSemicolonDelimiter() throws { + let table = try ParsedTable.parse(reader: ArrayLineReader(lines: [ + "name;age;city", + "John;30;New York", + "Jane;25;London" + ]), hasHeader: nil, headerOverride: nil, delimeter: ";") + + XCTAssertEqual(table.header.components()[0], "name") + XCTAssertEqual(table.header.components()[1], "age") + XCTAssertEqual(table.header.components()[2], "city") + + let row1 = try table.next()! + XCTAssertEqual(row1.components[0].value, "John") + XCTAssertEqual(row1.components[1].value, "30") + XCTAssertEqual(row1.components[2].value, "New York") + + let row2 = try table.next()! + XCTAssertEqual(row2.components[0].value, "Jane") + XCTAssertEqual(row2.components[1].value, "25") + XCTAssertEqual(row2.components[2].value, "London") + } + + func testTabDelimiter() throws { + let table = try ParsedTable.parse(reader: ArrayLineReader(lines: [ + "col1\tcol2\tcol3", + "val1\tval2\tval3" + ]), hasHeader: nil, headerOverride: nil, delimeter: "\t") + + XCTAssertEqual(table.header.components()[0], "col1") + XCTAssertEqual(table.header.components()[1], "col2") + XCTAssertEqual(table.header.components()[2], "col3") + + let row = try table.next()! + XCTAssertEqual(row.components[0].value, "val1") + XCTAssertEqual(row.components[1].value, "val2") + XCTAssertEqual(row.components[2].value, "val3") + } + + func testPipeDelimiter() throws { + let table = try ParsedTable.parse(reader: ArrayLineReader(lines: [ + "a|b|c", + "1|2|3" + ]), hasHeader: nil, headerOverride: nil, delimeter: "|") + + XCTAssertEqual(table.header.components()[0], "a") + XCTAssertEqual(table.header.components()[1], "b") + XCTAssertEqual(table.header.components()[2], "c") + + let row = try table.next()! + XCTAssertEqual(row.components[0].value, "1") + XCTAssertEqual(row.components[1].value, "2") + XCTAssertEqual(row.components[2].value, "3") + } + func testQuotedFieldsWithCommas() throws { + let table = try ParsedTable.parse(reader: ArrayLineReader(lines: [ + "\"Name,Full\",Age,City", + "\"Smith,John\",30,\"New York, NY\"" + ]), hasHeader: nil, headerOverride: nil, delimeter: ",") + + XCTAssertEqual(table.header.components()[0], "Name,Full") + XCTAssertEqual(table.header.components()[1], "Age") + XCTAssertEqual(table.header.components()[2], "City") + + let row = try table.next()! + XCTAssertEqual(row.components[0].value, "Smith,John") + XCTAssertEqual(row.components[1].value, "30") + XCTAssertEqual(row.components[2].value, "New York, NY") + } + + func testMixedQuotedAndUnquotedFields() throws { + let table = try ParsedTable.parse(reader: ArrayLineReader(lines: [ + "name,age,\"city,state\"", + "John,30,\"New York,NY\"", + "\"Jane Doe\",25,Chicago" + ]), hasHeader: nil, headerOverride: nil, delimeter: ",") + + XCTAssertEqual(table.header.components()[0], "name") + XCTAssertEqual(table.header.components()[1], "age") + XCTAssertEqual(table.header.components()[2], "city,state") + + let row1 = try table.next()! + XCTAssertEqual(row1.components[0].value, "John") + XCTAssertEqual(row1.components[1].value, "30") + XCTAssertEqual(row1.components[2].value, "New York,NY") + + let row2 = try table.next()! + XCTAssertEqual(row2.components[0].value, "Jane Doe") + XCTAssertEqual(row2.components[1].value, "25") + XCTAssertEqual(row2.components[2].value, "Chicago") + } + + func testQuotedFieldsWithQuotes() throws { + // RFC 4180: If double-quotes are used to enclose fields, then a double-quote + // appearing inside a field must be escaped by preceding it with another double quote. + // Note: Current implementation does not handle escaped quotes ("" -> ") per RFC 4180. + // The parser finds the first quote after an opening quote and treats everything + // between them as content, then continues. This means escaped quotes are treated + // as separate quoted sections, resulting in the quotes being stripped entirely. + let table = try ParsedTable.parse(reader: ArrayLineReader(lines: [ + "name,description", + "\"John \"\"Johnny\"\" Smith\",\"He said \"\"Hello\"\"\"" + ]), hasHeader: nil, headerOverride: nil, delimeter: ",") + + XCTAssertEqual(table.header.components()[0], "name") + XCTAssertEqual(table.header.components()[1], "description") + + let row = try table.next()! + // Current behavior: The parser treats each quote pair separately, so + // "John ""Johnny"" Smith" becomes "John " + "Johnny" + " Smith" = "John Johnny Smith" + // This is a limitation - RFC 4180 escaping is not fully supported + XCTAssertEqual(row.components[0].value, "John Johnny Smith") + XCTAssertEqual(row.components[1].value, "He said Hello") + } + + func testQuotedFieldsWithNewlines() throws { + let table = try ParsedTable.parse(reader: ArrayLineReader(lines: [ + "id,description", + "1,\"Line 1\nLine 2\nLine 3\"", + "2,\"Another\nMulti-line\"" + ]), hasHeader: nil, headerOverride: nil, delimeter: ",") + + XCTAssertEqual(table.header.components()[0], "id") + XCTAssertEqual(table.header.components()[1], "description") + + let row1 = try table.next()! + XCTAssertEqual(row1.components[0].value, "1") + XCTAssertEqual(row1.components[1].value, "Line 1\nLine 2\nLine 3") + + let row2 = try table.next()! + XCTAssertEqual(row2.components[0].value, "2") + XCTAssertEqual(row2.components[1].value, "Another\nMulti-line") + } + + func testAllFieldsQuoted() throws { + let table = try ParsedTable.parse(reader: ArrayLineReader(lines: [ + "\"col1\",\"col2\",\"col3\"", + "\"val1\",\"val2\",\"val3\"" + ]), hasHeader: nil, headerOverride: nil, delimeter: ",") + + XCTAssertEqual(table.header.components()[0], "col1") + XCTAssertEqual(table.header.components()[1], "col2") + XCTAssertEqual(table.header.components()[2], "col3") + + let row = try table.next()! + XCTAssertEqual(row.components[0].value, "val1") + XCTAssertEqual(row.components[1].value, "val2") + XCTAssertEqual(row.components[2].value, "val3") + } + + func testEmptyFields() throws { + let table = try ParsedTable.parse(reader: ArrayLineReader(lines: [ + "a,b,c", + ",,", // All empty + "1,,3", // Middle empty + ",2,", // First and last empty + "1,2," // Last empty + ]), hasHeader: nil, headerOverride: nil, delimeter: ",") + + XCTAssertEqual(table.header.components().count, 3) + + let row1 = try table.next()! + XCTAssertEqual(row1.components[0].value, "") + XCTAssertEqual(row1.components[1].value, "") + XCTAssertEqual(row1.components[2].value, "") + + let row2 = try table.next()! + XCTAssertEqual(row2.components[0].value, "1") + XCTAssertEqual(row2.components[1].value, "") + XCTAssertEqual(row2.components[2].value, "3") + + let row3 = try table.next()! + XCTAssertEqual(row3.components[0].value, "") + XCTAssertEqual(row3.components[1].value, "2") + XCTAssertEqual(row3.components[2].value, "") + + let row4 = try table.next()! + XCTAssertEqual(row4.components[0].value, "1") + XCTAssertEqual(row4.components[1].value, "2") + XCTAssertEqual(row4.components[2].value, "") + } + + func testQuotedEmptyFields() throws { + let table = try ParsedTable.parse(reader: ArrayLineReader(lines: [ + "a,b,c", + "\"\",\"\",\"\"", + "\"\",b,\"\"" + ]), hasHeader: nil, headerOverride: nil, delimeter: ",") + + let row1 = try table.next()! + XCTAssertEqual(row1.components[0].value, "") + XCTAssertEqual(row1.components[1].value, "") + XCTAssertEqual(row1.components[2].value, "") + + let row2 = try table.next()! + XCTAssertEqual(row2.components[0].value, "") + XCTAssertEqual(row2.components[1].value, "b") + XCTAssertEqual(row2.components[2].value, "") + } + + func testWhitespaceInUnquotedFields() throws { + let table = try ParsedTable.parse(reader: ArrayLineReader(lines: [ + "a,b,c", + " value1 , value2 ,value3" + ]), hasHeader: nil, headerOverride: nil, delimeter: ",") + + let row = try table.next()! + // Note: Current implementation may preserve whitespace + XCTAssertEqual(row.components[0].value, " value1 ") + XCTAssertEqual(row.components[1].value, " value2 ") + XCTAssertEqual(row.components[2].value, "value3") + } + + func testWhitespaceInQuotedFields() throws { + let table = try ParsedTable.parse(reader: ArrayLineReader(lines: [ + "a,b", + "\" value1 \",\" value2 \"" + ]), hasHeader: nil, headerOverride: nil, delimeter: ",") + + let row = try table.next()! + XCTAssertEqual(row.components[0].value, " value1 ") + XCTAssertEqual(row.components[1].value, " value2 ") + } + + func testFieldsWithSpecialCharacters() throws { + let table = try ParsedTable.parse(reader: ArrayLineReader(lines: [ + "col1,col2,col3", + "value@domain.com,\"$100.50\",\"item & item\"" + ]), hasHeader: nil, headerOverride: nil, delimeter: ",") + + let row = try table.next()! + XCTAssertEqual(row.components[0].value, "value@domain.com") + XCTAssertEqual(row.components[1].value, "$100.50") + XCTAssertEqual(row.components[2].value, "item & item") + } + + func testFieldsWithUnicode() throws { + let table = try ParsedTable.parse(reader: ArrayLineReader(lines: [ + "name,city", + "José,北京", + "François,Москва" + ]), hasHeader: nil, headerOverride: nil, delimeter: ",") + + let row1 = try table.next()! + XCTAssertEqual(row1.components[0].value, "José") + XCTAssertEqual(row1.components[1].value, "北京") + + let row2 = try table.next()! + XCTAssertEqual(row2.components[0].value, "François") + XCTAssertEqual(row2.components[1].value, "Москва") + } + + func testSingleField() throws { + let table = try ParsedTable.parse(reader: ArrayLineReader(lines: [ + "single", + "value" + ]), hasHeader: nil, headerOverride: nil, delimeter: ",") + + XCTAssertEqual(table.header.components().count, 1) + XCTAssertEqual(table.header.components()[0], "single") + + let row = try table.next()! + XCTAssertEqual(row.components.count, 1) + XCTAssertEqual(row.components[0].value, "value") + } + + func testManyColumns() throws { + let header = (1...20).map { "col\($0)" }.joined(separator: ",") + let values = (1...20).map { "val\($0)" }.joined(separator: ",") + + let table = try ParsedTable.parse(reader: ArrayLineReader(lines: [ + header, + values + ]), hasHeader: nil, headerOverride: nil, delimeter: ",") + + XCTAssertEqual(table.header.components().count, 20) + + let row = try table.next()! + XCTAssertEqual(row.components.count, 20) + XCTAssertEqual(row.components[0].value, "val1") + XCTAssertEqual(row.components[19].value, "val20") + } + + func testQuotedFieldAtStart() throws { + let table = try ParsedTable.parse(reader: ArrayLineReader(lines: [ + "\"quoted\",unquoted,normal", + "\"value1\",value2,value3" + ]), hasHeader: nil, headerOverride: nil, delimeter: ",") + + XCTAssertEqual(table.header.components()[0], "quoted") + XCTAssertEqual(table.header.components()[1], "unquoted") + XCTAssertEqual(table.header.components()[2], "normal") + + let row = try table.next()! + XCTAssertEqual(row.components[0].value, "value1") + XCTAssertEqual(row.components[1].value, "value2") + XCTAssertEqual(row.components[2].value, "value3") + } + + func testQuotedFieldAtEnd() throws { + let table = try ParsedTable.parse(reader: ArrayLineReader(lines: [ + "normal,unquoted,\"quoted\"", + "value1,value2,\"value3\"" + ]), hasHeader: nil, headerOverride: nil, delimeter: ",") + + XCTAssertEqual(table.header.components()[0], "normal") + XCTAssertEqual(table.header.components()[1], "unquoted") + XCTAssertEqual(table.header.components()[2], "quoted") + + let row = try table.next()! + XCTAssertEqual(row.components[0].value, "value1") + XCTAssertEqual(row.components[1].value, "value2") + XCTAssertEqual(row.components[2].value, "value3") + } + + func testMultipleRows() throws { + let table = try ParsedTable.parse(reader: ArrayLineReader(lines: [ + "a,b,c", + "1,2,3", + "4,5,6", + "7,8,9" + ]), hasHeader: nil, headerOverride: nil, delimeter: ",") + + var row = try table.next()! + XCTAssertEqual(row.components[0].value, "1") + XCTAssertEqual(row.components[1].value, "2") + XCTAssertEqual(row.components[2].value, "3") + + row = try table.next()! + XCTAssertEqual(row.components[0].value, "4") + XCTAssertEqual(row.components[1].value, "5") + XCTAssertEqual(row.components[2].value, "6") + + row = try table.next()! + XCTAssertEqual(row.components[0].value, "7") + XCTAssertEqual(row.components[1].value, "8") + XCTAssertEqual(row.components[2].value, "9") + + XCTAssertNil(try table.next()) + } + + func testAutomaticCommaDetection() throws { + let table = try ParsedTable.parse(reader: ArrayLineReader(lines: [ + "a,b,c", + "1,2,3" + ]), hasHeader: nil, headerOverride: nil, delimeter: nil) + + XCTAssertEqual(table.conf.delimeter, ",") + XCTAssertEqual(table.header.components()[0], "a") + } + + func testAutomaticSemicolonDetection() throws { + let table = try ParsedTable.parse(reader: ArrayLineReader(lines: [ + "a;b;c", + "1;2;3" + ]), hasHeader: nil, headerOverride: nil, delimeter: nil) + + XCTAssertEqual(table.conf.delimeter, ";") + XCTAssertEqual(table.header.components()[0], "a") + } + + func testAutomaticTabDetection() throws { + let table = try ParsedTable.parse(reader: ArrayLineReader(lines: [ + "a\tb\tc", + "1\t2\t3" + ]), hasHeader: nil, headerOverride: nil, delimeter: nil) + + XCTAssertEqual(table.conf.delimeter, "\t") + XCTAssertEqual(table.header.components()[0], "a") } } From 3b00f4219b5bbc4e86c6f6ac6e55046e24025f25 Mon Sep 17 00:00:00 2001 From: Sergey Khruschak Date: Mon, 17 Nov 2025 19:04:09 +0200 Subject: [PATCH 2/2] Using older swift --- .github/workflows/build-test.yaml | 2 +- .github/workflows/release.yml | 2 +- Package.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-test.yaml b/.github/workflows/build-test.yaml index 01bfd3a..d4db5ef 100644 --- a/.github/workflows/build-test.yaml +++ b/.github/workflows/build-test.yaml @@ -9,7 +9,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest] - swift: ["6.2"] + swift: ["6.1"] steps: - uses: actions/checkout@v4 - uses: swift-actions/setup-swift@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 91dbb89..101d7e0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest ] - swift: ["6.2"] + swift: ["6.1"] steps: - uses: actions/checkout@v4 - uses: swift-actions/setup-swift@v2 diff --git a/Package.swift b/Package.swift index 6c68cb1..0aaf672 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.2.1 +// swift-tools-version: 6.1 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription