Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Customizable output formats #11

Merged
merged 1 commit into from
Oct 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions Sources/table/FileType.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
65 changes: 39 additions & 26 deletions Sources/table/MainApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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?
Expand All @@ -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)
Expand All @@ -103,25 +121,23 @@ 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: ","))") }
try columns.forEach { if table.header.index(ofColumn: $0) == nil { throw RuntimeError("Column \($0) is not found in the table") } }
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 }
Expand All @@ -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()
}
}
8 changes: 4 additions & 4 deletions Sources/table/Row.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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 {
Expand Down
27 changes: 23 additions & 4 deletions Sources/table/Table.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -54,7 +56,7 @@ class ParsedTable: Table {

var row = nextLine()

while row?.matches(ParsedTable.sqlHeaderPattern) ?? false {
while technicalRow(row) {
row = reader.readLine()
}

Expand All @@ -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: [])
}
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand Down
114 changes: 114 additions & 0 deletions Sources/table/TablePrinter.swift
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading