Skip to content

Commit

Permalink
Merge pull request #11 from sergkh/table-view
Browse files Browse the repository at this point in the history
Customizable output formats
  • Loading branch information
sergkh authored Oct 9, 2023
2 parents cea3d8d + 9cfef3e commit 84405e7
Show file tree
Hide file tree
Showing 7 changed files with 237 additions and 34 deletions.
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

0 comments on commit 84405e7

Please sign in to comment.