diff --git a/README.md b/README.md index 49f160e..5813550 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,25 @@ If you have a SQL output like that you can easilly get a list of available only users: +```bash +$ table in.sql --filter available=1 +╭────┬────────────┬───────────┬───────────╮ +│ id │ first_name │ last_name │ available │ +├────┼────────────┼───────────┼───────────┤ +│ 1 │ John │ Smith │ 1 │ +│ 3 │ Steve │ Pitt │ 1 │ +│ 4 │ Mark │ Cousins │ 1 │ +... +``` + +Also in CSV form: + +```bash +$ table in.sql --filter available=1 --as csv +``` + +Or in a custom text format: + ```bash $ table in.sql --print '${first_name} ${last_name}' --filter available=1 John Smith diff --git a/Sources/table/FileType.swift b/Sources/table/FileType.swift index 29e8c7c..58b9ec2 100644 --- a/Sources/table/FileType.swift +++ b/Sources/table/FileType.swift @@ -1,5 +1,31 @@ enum FileType: CaseIterable { case csv + case table // own table format case sql case cassandraSql + + static func hasOuterBorders(type: FileType) -> Bool { + switch type { + case .table: + return true + case .sql: + return true + default: + return false + } + } + + static func outFormat(strFormat: String?) throws -> FileType { + if let strFormat = strFormat { + if strFormat == "csv" { + return .csv + } else if strFormat == "table" { + return .table + } else { + throw RuntimeError("Unknown output format \(strFormat)") + } + } else { + return .table + } + } } \ No newline at end of file diff --git a/Sources/table/MainApp.swift b/Sources/table/MainApp.swift index b22c3d5..3458526 100644 --- a/Sources/table/MainApp.swift +++ b/Sources/table/MainApp.swift @@ -5,6 +5,31 @@ struct Global { static var debug: Bool = false } +func buildPrinter(formatOpt: Format?, outFileFmt: FileType, outputFile: String?) throws -> TablePrinter { + let outHandle: FileHandle + + if let outFile = outputFile { + if (!FileManager.default.createFile(atPath: outFile, contents: nil, attributes: nil)) { + throw RuntimeError("Unable to create output file \(outFile)") + } + outHandle = try FileHandle(forWritingAtPath: outFile).orThrow(RuntimeError("Output file \(outFile) is not found")) + } else { + outHandle = FileHandle.standardOutput + } + + if let formatOpt { + return CustomFormatTablePrinter(format: formatOpt, outHandle: outHandle) + } + + if (outFileFmt == .table) { + return PrettyTablePrinter(outHandle: outHandle) + } else if (outFileFmt == .csv) { + return CsvTablePrinter(delimeter: ",", outHandle: outHandle) + } else { + throw RuntimeError("Unsupported output format \(outFileFmt)") + } +} + @main struct MainApp: ParsableCommand { static var configuration = CommandConfiguration( @@ -54,6 +79,9 @@ struct MainApp: ParsableCommand { @Option(name: [.customLong("print")], help: "Format output accorindg to format string. Use ${column name} to print column value. Example: Column1 value is ${column1}.") var printFormat: String? + @Option(name: [.customLong("as")], help: "Prints output in the specified format. Supported formats: table (default) or csv.") + var asFormat: String? + // TODO: Support complex or multiple filters? @Option(name: .shortAndLong, 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") var filter: String? @@ -69,22 +97,12 @@ struct MainApp: ParsableCommand { var joinCriteria: String? mutating func run() throws { - let outHandle: FileHandle - + if debug { Global.debug = true print("Debug enabled") } - - if let outFile = outputFile { - if (!FileManager.default.createFile(atPath: outFile, contents: nil, attributes: nil)) { - throw RuntimeError("Unable to create output file \(outFile)") - } - outHandle = try FileHandle(forWritingAtPath: outFile).orThrow(RuntimeError("Output file \(outFile) is not found")) - } else { - outHandle = FileHandle.standardOutput - } - + let headerOverride = header.map { Header(data: $0, delimeter: ",", trim: false, hasOuterBorders: false) } var table: any Table = try ParsedTable.parse(path: inputFile, hasHeader: !noInHeader, headerOverride: headerOverride, delimeter: delimeter) @@ -103,8 +121,6 @@ struct MainApp: ParsableCommand { let formatOpt = try printFormat.map { try Format(format: $0).validated(header: table.header) } - let newLine = "\n".data(using: .utf8)! - if let columns { let columns = columns.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } if (Global.debug) { print("Showing columns: \(columns.joined(separator: ","))") } @@ -112,16 +128,16 @@ struct MainApp: ParsableCommand { table = ColumnsTableView(table: table, visibleColumns: columns) } + let printer = try buildPrinter(formatOpt: formatOpt, outFileFmt: try FileType.outFormat(strFormat: asFormat), outputFile: outputFile) + // when print format is set, header is not relevant anymore - if !skipOutHeader && printFormat == nil { - outHandle.write(table.header.asCsvData()) - outHandle.write(newLine) + if !skipOutHeader { + printer.writeHeader(header: table.header) } var skip = skipLines ?? 0 var limit = limitLines ?? Int.max - - // for row: Row in table { TODO: + while let row = table.next() { if let filter { if !filter.apply(row: row) { continue } @@ -136,14 +152,11 @@ struct MainApp: ParsableCommand { break } - if let rowFormat = formatOpt { - outHandle.write(rowFormat.fillData(rows: [row])) - } else { - outHandle.write(row.asCsvData()) - } - - outHandle.write(newLine) + printer.writeRow(row: row) + limit -= 1 } + + printer.flush() } } \ No newline at end of file diff --git a/Sources/table/Row.swift b/Sources/table/Row.swift index 3524d14..cbba520 100644 --- a/Sources/table/Row.swift +++ b/Sources/table/Row.swift @@ -2,15 +2,15 @@ import Foundation class Row { let index: Int - var components: [String] + let components: [String] let header: Header? convenience init(header: Header?, index: Int, data: String, delimeter: String, trim: Bool, hasOuterBorders: Bool) { var components = data.components(separatedBy: delimeter) - + if trim { components = components.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - } + } if hasOuterBorders { components = components.dropFirst().dropLast() @@ -22,7 +22,7 @@ class Row { init(header: Header?, index: Int, components: [String]) { self.header = header self.index = index - self.components = components + self.components = components } subscript(index: Int) -> String { diff --git a/Sources/table/Table.swift b/Sources/table/Table.swift index d500432..046bcbf 100644 --- a/Sources/table/Table.swift +++ b/Sources/table/Table.swift @@ -23,6 +23,8 @@ struct TableConfig { class ParsedTable: Table { static let sqlHeaderPattern = "^[\\+-]{1,}$" + static let ownHeaderPattern = "^╭[\\┬─]{1,}╮$" + static let technicalRowPattern = "^[\\+-╭┬╮├┼┤─╰┴╯]{1,}$" var prereadRows: [String] let reader: LineReader let conf: TableConfig @@ -54,7 +56,7 @@ class ParsedTable: Table { var row = nextLine() - while row?.matches(ParsedTable.sqlHeaderPattern) ?? false { + while technicalRow(row) { row = reader.readLine() } @@ -65,10 +67,15 @@ class ParsedTable: Table { data:row, delimeter: conf.delimeter, trim: conf.trim, - hasOuterBorders: conf.type == .sql) + hasOuterBorders: FileType.hasOuterBorders(type: conf.type)) } } + // matches rows that has to be skipped, usually horizontal delimeters + private func technicalRow(_ str: String?) -> Bool { + return str?.matches(ParsedTable.technicalRowPattern) ?? false + } + static func empty() -> ParsedTable { ParsedTable(reader: ArrayLineReader([]), conf: TableConfig(header: Header.auto(size: 0)), prereadRows: []) } @@ -99,16 +106,26 @@ class ParsedTable: Table { // TODO: has header is not yet used static func detectFile(reader: LineReader, hasHeader: Bool?, headerOverride: Header?, delimeter: String?) throws -> (TableConfig, [String])? { if let row = reader.readLine() { - if row.matches(ParsedTable.sqlHeaderPattern) { // SQL table header used in MySQL/MariaDB like '+----+-------+' + if row.matches(ParsedTable.ownHeaderPattern) { + if (Global.debug) { print("Detected tool own table format") } + let parsedHeader = try reader.readLine().map { + Header(data: $0, delimeter: "│", trim: true, hasOuterBorders: true) + }.orThrow(RuntimeError("Failed to parse own table header")) + + return (TableConfig(header: headerOverride ?? parsedHeader, type: FileType.table, delimeter: "│", trim: true), []) + } else if row.matches(ParsedTable.sqlHeaderPattern) { // SQL table header used in MySQL/MariaDB like '+----+-------+' + if (Global.debug) { print("Detected SQL like table format") } let parsedHeader = try reader.readLine().map { Header(data: $0, delimeter: "|", trim: true, hasOuterBorders: true) }.orThrow(RuntimeError("Failed to parse SQL like header")) return (TableConfig(header: headerOverride ?? parsedHeader, type: FileType.sql, delimeter: "|", trim: true), []) } else if row.matches("^([A-Za-z_0-9\\s]+\\|\\s*)+[A-Za-z_0-9\\s]+$") { // Cassandra like header: name | name2 | name3 + if (Global.debug) { print("Detected Cassandra like table format") } let header = Header(data: row, delimeter: "|", trim: true, hasOuterBorders: false) return (TableConfig(header: headerOverride ?? header, type: FileType.cassandraSql, delimeter: "|", trim: true), []) - } else { + } else { + if (Global.debug) { print("Detected Cassandra like table format") } let delimeters = delimeter.map{ [$0] } ?? [",", ";", "\t", " ", "|"] // Pre-read up to 2 rows and apply delimeter to the header and rows. @@ -126,11 +143,13 @@ class ParsedTable: Table { if match { let header: Header = (hasHeader ?? true) ? Header(data: row, delimeter: d, trim: false, hasOuterBorders: false) : Header.auto(size: 1) // TODO: ??? + if (Global.debug) { print("Detected as CSV format separated by '\(d)'") } let cachedRows = (hasHeader ?? true) ? dataRows : ([row] + dataRows) return (TableConfig(header: headerOverride ?? header, type: FileType.csv, delimeter: d, trim: false), cachedRows) } } + if (Global.debug) { print("Detected as headless file") } // Treat as a single line file let header: Header = (hasHeader ?? true) ? Header(data: row, delimeter: delimeter ?? ",", trim: false, hasOuterBorders: false) : Header.auto(size: 1) return (TableConfig(header: headerOverride ?? header, type: FileType.csv, delimeter: delimeter ?? ",", trim: false), dataRows) diff --git a/Sources/table/TablePrinter.swift b/Sources/table/TablePrinter.swift new file mode 100644 index 0000000..e0ceeba --- /dev/null +++ b/Sources/table/TablePrinter.swift @@ -0,0 +1,114 @@ + +import Foundation + +let newLine = "\n".data(using: .utf8)! + +protocol TablePrinter { + func writeHeader(header: Header) + func writeRow(row: Row) + func flush() +} + +class CsvTablePrinter: TablePrinter { + private let delimeter: String + private let outHandle: FileHandle + + init(delimeter: String, outHandle: FileHandle) { + self.delimeter = delimeter + self.outHandle = outHandle + } + + func writeHeader(header: Header) { + self.outHandle.write(header.cols.joined(separator: delimeter).data(using: .utf8)!) + self.outHandle.write(newLine) + } + + func writeRow(row: Row) { + self.outHandle.write(row.components.joined(separator: delimeter).data(using: .utf8)!) + self.outHandle.write(newLine) + } + + func flush() {} +} + +class CustomFormatTablePrinter: TablePrinter { + private let format: Format + private let outHandle: FileHandle + + init(format: Format, outHandle: FileHandle) { + self.format = format + self.outHandle = outHandle + } + + // no header needed + func writeHeader(header: Header) {} + + func writeRow(row: Row) { + self.outHandle.write(self.format.fillData(rows: [row])) + self.outHandle.write(newLine) + } + + func flush() {} +} + +class PrettyTablePrinter: TablePrinter { + private let outHandle: FileHandle + private var columnWidths: [Int] = [] + private var header: Header? + private var cachedRows: [Row] = [] + + init(outHandle: FileHandle) { + self.outHandle = outHandle + } + + func writeHeader(header: Header) { + self.header = header + self.adjustColumns(row: header.cols) + } + + func writeRow(row: Row) { + self.cachedRows.append(row) + self.adjustColumns(row: row.components) + } + + func flush() { + let topBorder = "╭" + self.columnWidths.map( { String(repeating: "─", count: $0 + 2)}).joined(separator: "┬") + "╮\n" + self.outHandle.write(topBorder.data(using: .utf8)!) + + if let header = self.header { + self.outHandle.write(formatRow(header.cols).data(using: .utf8)!) + + let headerBorder = "├" + self.columnWidths.map( { String(repeating: "─", count: $0 + 2)}).joined(separator: "┼") + "┤\n" + self.outHandle.write(headerBorder.data(using: .utf8)!) + } + + for row in self.cachedRows { + self.outHandle.write(formatRow(row.components).data(using: .utf8)!) + } + + let bottomBorder = "╰" + self.columnWidths.map( { String(repeating: "─", count: $0 + 2)}).joined(separator: "┴") + "╯\n" + self.outHandle.write(bottomBorder.data(using: .utf8)!) + } + + private func adjustColumns(row: [String]) { + if self.columnWidths.isEmpty { + self.columnWidths = row.map { $0.count } + } else { + + if (row.count != self.columnWidths.count) { + fatalError("Row \(row) has irregular size. Table output is not possible, please use CSV format") + } + + for (i, col) in row.enumerated() { + self.columnWidths[i] = max(self.columnWidths[i], col.count) + } + } + } + + private func formatRow(_ row: [String]) -> String { + return row.enumerated().map { (idx, col) in + let padding = String(repeating: " ", count: self.columnWidths[idx] - col.count) + return "│ " + col + padding + " " + }.joined(separator: "") + "│\n" + } +} \ No newline at end of file diff --git a/test-data/table-format.out b/test-data/table-format.out new file mode 100644 index 0000000..75e0671 --- /dev/null +++ b/test-data/table-format.out @@ -0,0 +1,12 @@ +╭────┬────────────┬───────────┬───────────╮ +│ id │ first_name │ last_name │ available │ +├────┼────────────┼───────────┼───────────┤ +│ 1 │ John │ Smith │ 1 │ +│ 2 │ Mary │ McAdams │ 0 │ +│ 3 │ Steve │ Pitt │ 1 │ +│ 4 │ Mark │ Cousins │ 1 │ +│ 5 │ Shaun │ Jones │ 0 │ +│ 7 │ Amy │ McDonald │ 1 │ +│ 8 │ Brad │ Swan │ 1 │ +│ 10 │ Wendy │ Johnson │ 1 │ +╰────┴────────────┴───────────┴───────────╯ \ No newline at end of file