diff --git a/Project.toml b/Project.toml index 68ecaaa..ca3b3e8 100644 --- a/Project.toml +++ b/Project.toml @@ -3,30 +3,16 @@ uuid = "89b67f3b-d1aa-5f6f-9ca4-282e8d98620d" version = "1.0.1-DEV" [deps] -DataValues = "e7dc6d0d-1eca-5fa6-8ad6-5aecde8b7ea5" Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" -ExcelReaders = "c04bee98-12a5-510c-87df-2a230cb6e075" FileIO = "5789e2e9-d7fb-5bc7-8068-2c6fae9b9549" -IterableTables = "1c8ee90f-4401-5389-894e-7a04a3dc0f4d" -IteratorInterfaceExtensions = "82899510-4779-5014-852e-03e436cf321d" Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7" -PyCall = "438e738f-606a-5dbb-bf0a-cddfbfd45ab0" -TableShowUtils = "5e66a065-1f0a-5976-b372-e0b8c017ca10" -TableTraits = "3783bdb8-4a98-5b6b-af9a-565f29a5fe9c" -TableTraitsUtils = "382cd787-c1b6-5bf2-a167-d5b971a19bda" +Tables = "bd369af6-aec1-5ad0-b16a-f7cc5008161c" XLSX = "fdbf4ff8-1666-58a4-91e7-1b58723a45e0" [compat] -DataValues = "0.4.11" -ExcelReaders = "0.11" FileIO = "1" -IterableTables = "0.8.3, 0.9, 0.10, 0.11, 1" -IteratorInterfaceExtensions = "0.1.1, 1" -PyCall = "1.90" -TableShowUtils = "0.2" -TableTraits = "0.3.1, 0.4, 1" -TableTraitsUtils = "0.3, 0.4, 1" -XLSX = "0.4.1, 0.5, 0.6, 0.7, 0.8, 0.9" +Tables = "1" +XLSX = "0.11.3" julia = "1" [extras] diff --git a/README.md b/README.md index f175400..05281ff 100644 --- a/README.md +++ b/README.md @@ -7,84 +7,187 @@ ## Overview -This package provides load support for Excel files under the +This package provides support for Excel files under the [FileIO.jl](https://github.com/JuliaIO/FileIO.jl) package. +It provides functionality to read simple tabular data from +an Excel (.xlsx) file and to save simple tabular data to an +Excel file. + +For more extensive functionality when reading and writing Excel files, +consider using [XLSX.jl](https://juliadata.github.io/XLSX.jl/stable/). +Under the hood, `ExcelFiles.jl` uses the `XLSX.jl` functions `readtable` +and `writetable`. + ## Installation Use ``Pkg.add("ExcelFiles")`` in Julia to install ExcelFiles and its dependencies. -## Usage +# Usage -### Load an Excel file +## Load an Excel file -To read a Excel file into a ``DataFrame``, use the following julia code: +To read an Excel file into a `DataFrame`, use the following julia code: -````julia +```julia using ExcelFiles, DataFrames df = DataFrame(load("data.xlsx", "Sheet1")) -```` - -The call to ``load`` returns a ``struct`` that is an [IterableTable.jl](https://github.com/queryverse/IterableTables.jl), so it can be passed to any function that can handle iterable tables, i.e. all the sinks in [IterableTable.jl](https://github.com/queryverse/IterableTables.jl). Here are some examples of materializing an Excel file into data structures that are not a ``DataFrame``: - -````julia -using ExcelFiles, DataTables, IndexedTables, TimeSeries, Temporal, Gadfly - -# Load into a DataTable -dt = DataTable(load("data.xlsx", "Sheet1")) - -# Load into an IndexedTable -it = IndexedTable(load("data.xlsx", "Sheet1")) +``` + +The call to `load` returns an object that is a [Tables.jl](https://github.com/JuliaData/Tables.jl) table, so it can be passed to any function that can handle Tables.jl tables. Here are some examples of materializing an Excel file into such data structures: + +```julia +using ExcelFiles, DataFrames, PrettyTables + +# Load into a DataFrame +julia> DataFrame(load("HTable.xlsx")) +5×10 DataFrame + Row │ Year 1940 1950 1960 1970 1980 1990 2000 2010 2020 + │ String Any Any Float64 Float64 Any Any Float64 Float64 Float64 +─────┼─────────────────────────────────────────────────────────────────────────────────────────── + 1 │ Col A 1 2 3.0 4.0 5 6 7.0 8.0 9.0 + 2 │ Col B 10 20 30.0 40.0 50 60 70.0 80.0 90.0 + 3 │ Col C 100 200 300.0 400.0 500 600 700.0 800.0 900.0 + 4 │ Col D 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 + 5 │ Col E Hello 2025-12-19 3.0 3.33 Hello 2025-12-19 3.0 3.33 1.0 + +julia> DataFrame(load("HTable.xlsx"; transpose=true)) +9×6 DataFrame + Row │ Year Col A Col B Col C Col D Col E + │ Int64 Int64 Int64 Int64 Float64 Any +─────┼───────────────────────────────────────────────── + 1 │ 1940 1 10 100 0.1 Hello + 2 │ 1950 2 20 200 0.2 2025-12-19 + 3 │ 1960 3 30 300 0.3 3 + 4 │ 1970 4 40 400 0.4 3.33 + 5 │ 1980 5 50 500 0.5 Hello + 6 │ 1990 6 60 600 0.6 2025-12-19 + 7 │ 2000 7 70 700 0.7 3 + 8 │ 2010 8 80 800 0.8 3.33 + 9 │ 2020 9 90 900 0.9 true + + +# Load into a PrettyTable +julia> PrettyTable(load("HTable.xlsx")) +┌───────┬───────┬────────────┬───────┬───────┬───────┬────────────┬───────┬───────┬───────┐ +│ Year │ 1940 │ 1950 │ 1960 │ 1970 │ 1980 │ 1990 │ 2000 │ 2010 │ 2020 │ +├───────┼───────┼────────────┼───────┼───────┼───────┼────────────┼───────┼───────┼───────┤ +│ Col A │ 1 │ 2 │ 3.0 │ 4.0 │ 5 │ 6 │ 7.0 │ 8.0 │ 9.0 │ +│ Col B │ 10 │ 20 │ 30.0 │ 40.0 │ 50 │ 60 │ 70.0 │ 80.0 │ 90.0 │ +│ Col C │ 100 │ 200 │ 300.0 │ 400.0 │ 500 │ 600 │ 700.0 │ 800.0 │ 900.0 │ +│ Col D │ 0.1 │ 0.2 │ 0.3 │ 0.4 │ 0.5 │ 0.6 │ 0.7 │ 0.8 │ 0.9 │ +│ Col E │ Hello │ 2025-12-19 │ 3.0 │ 3.33 │ Hello │ 2025-12-19 │ 3.0 │ 3.33 │ 1.0 │ +└───────┴───────┴────────────┴───────┴───────┴───────┴────────────┴───────┴───────┴───────┘ + +julia> PrettyTable(load("HTable.xlsx"; transpose=true)) +┌──────┬───────┬───────┬───────┬───────┬────────────┐ +│ Year │ Col A │ Col B │ Col C │ Col D │ Col E │ +├──────┼───────┼───────┼───────┼───────┼────────────┤ +│ 1940 │ 1 │ 10 │ 100 │ 0.1 │ Hello │ +│ 1950 │ 2 │ 20 │ 200 │ 0.2 │ 2025-12-19 │ +│ 1960 │ 3 │ 30 │ 300 │ 0.3 │ 3 │ +│ 1970 │ 4 │ 40 │ 400 │ 0.4 │ 3.33 │ +│ 1980 │ 5 │ 50 │ 500 │ 0.5 │ Hello │ +│ 1990 │ 6 │ 60 │ 600 │ 0.6 │ 2025-12-19 │ +│ 2000 │ 7 │ 70 │ 700 │ 0.7 │ 3 │ +│ 2010 │ 8 │ 80 │ 800 │ 0.8 │ 3.33 │ +│ 2020 │ 9 │ 90 │ 900 │ 0.9 │ true │ +└──────┴───────┴───────┴───────┴───────┴────────────┘ + +``` + +The `load` function takes a number of arguments and keywords: + +```julia + FileIO.load( + source::String, + [sheet::String, + [columns::String]]; + [first_row::Int], + [first_column::String] + [column_labels::Vector{String}], + [header::Bool], + [normalizenames::Bool], + [transpose::Bool] + ) +``` + +### Arguments: + +* `source`: The name of the file to be loaded. +* `sheet`: Specifies the sheet name to be loaded. If `sheet` is not given, the first Excel sheet in the file will be used. +* `columns`: Determines which columns to read. For example, `"B:D"` will select columns B, C and D. If columns is not given, the algorithm will find the first sequence of consecutive non-empty cells. A valid sheet **must** be specified when specifying columns. If `transpose = true` or is omitted, `columns` should be used to specify rows. For example, specifying `"2:4"` with `transpose = true` will read only from these rows. + +### Keywords: + +* `first_row`: Indicates the first row of the data table to be read. For example, `first_row=5` will look for a table starting at sheet row 5. If first_row is not given, the algorithm will look for the first non-empty row in the sheet (ignored if `transpose = true`). +* `first_column`: Indicates the first row of the data table to be read. For example, `first_column="B"` will look for a table starting at sheet row 5. If first_row is not given, the algorithm will look for the first non-empty row in the sheet (ignored if `transpose = false` or is omitted). +* `column_labels`: Specifies column names for the header of the table. If `column_labels` are given and `header=true`, the headers given by `column_labels` will be used, and the first row of the table (containing headers) will be ignored. +* `header`: Indicates if the first row (column if `transpose = true`) is a header. If `header=true` and `column_labels` is not specified, the column labels for the table will be read from the first row (column) of the table. If `header=false` and `column_labels` is not specified, the algorithm will generate column labels. The default value is `header=true`. +* `normalizenames`: Set to `true` to normalize column names to valid Julia identifiers. Default=`false`. +* `transpose`: Set to `true` to transpose the table to read data from rows not columns. + +### Examples + +```julia +julia> PrettyTable(load("HTable.xlsx", "Offset"; first_row=2)) + +julia> df = DataFrame(load("HTable.xlsx", "Offset", "2:7"; transpose=true, first_column="B")) + +julia> df = DataFrame(load("HTable.xlsx"; normalizenames=true, transpose=true, column_labels=["Date", "Name1", "Name2", "Name3", "Name4", "Name5"])) + +``` +## Save an Excel file + +The following code saves any Tables.jl table (such as a `DataFrame`) as an Excel file: +```julia +using ExcelFiles -# Load into a TimeArray -ta = TimeArray(load("data.xlsx", "Sheet1")) +save("output.xlsx", tbl) +``` -# Load into a TS -ts = TS(load("data.xlsx", "Sheet1")) +The `save` function takes a number of arguments and keywords: -# Plot directly with Gadfly -plot(load("data.xlsx", "Sheet1"), x=:a, y=:b, Geom.line) -```` +```julia + FileIO.save( + source::String; + [sheetname::String], + [overwrite::Bool] + ) +``` -The ``load`` function also takes a number of parameters: +### Arguments: -````julia -function load(f::FileIO.File{FileIO.format"Excel"}, range; keywords...) -```` -#### Arguments: +* `source`: The name of the file to be created on save. -* ``range``: either the name of the sheet in the Excel file to read, or a full Excel range specification (i.e. "Sheetname!A1:B2"). -* The ``keywords`` arguments are the same as in [ExcelReaders.jl](https://github.com/queryverse/ExcelReaders.jl) (which is used under the hood to read Excel files). When ``range`` is a sheet name, the keyword arguments for the ``readxlsheet`` function from ExcelReaders.jl apply, if ``range`` is a range specification, the keyword arguments for the ``readxl`` function apply. +### Keywords: -### Save an Excel file +* `sheetname`: Specify the sheetname to be used in the created file. By default, the sheetname will be `Sheet1`. +* `overwrite`: Set `overwrite=true` to overwite any existing file of the same name. Default = `false`. -The following code saves any iterable table as an excel file: -````julia -using ExcelFiles +### Examples -save("output.xlsx", it) -```` -This will work as long as it is any of the types supported as sources in IterableTables.jl. +```julia +julia> save("myfile.xlsx", df; sheetname="myname", overwrite=true) +``` -### Using the pipe syntax +## Using the pipe syntax -``load`` also support the pipe syntax. For example, to load an Excel file into a ``DataFrame``, one can use the following code: +The `load` and `save` functions also support the pipe syntax. For example, to load an Excel file into a `DataFrame`, one can use the following code: -````julia +```julia using ExcelFiles, DataFrame df = load("data.xlsx", "Sheet1") |> DataFrame -```` +``` -To save an iterable table, one can use the following form: +To save any Tables.jl compatible table (such as a DataFrame), one can use the following form: -````julia +```julia using ExcelFiles, DataFrame df = # Aquire a DataFrame somehow df |> save("output.xlsx") -```` - -The pipe syntax is especially useful when combining it with [Query.jl](https://github.com/queryverse/Query.jl) queries, for example one can easily load an Excel file, pipe it into a query, then pipe it to the ``save`` function to store the results in a new file. +``` diff --git a/docs/src/index.md b/docs/src/index.md index e10b99d..539669f 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -1 +1,182 @@ # Introduction + +This package provides support for Excel files under the +[FileIO.jl](https://github.com/JuliaIO/FileIO.jl) package. + +It provides functionality to read simple tabular data from +an Excel (.xlsx) file and to save simple tabular data to an +Excel file. + +For more extensive functionality when reading and writing Excel files, +consider using [XLSX.jl](https://juliadata.github.io/XLSX.jl/stable/). +Under the hood, `ExcelFiles.jl` uses the `XLSX.jl` functions `readtable` +and `writetable`. + +# Usage + +## Load an Excel file + +To read an Excel file into a `DataFrame`, use the following julia code: + +```julia +using ExcelFiles, DataFrames + +df = DataFrame(load("data.xlsx", "Sheet1")) +``` + +The call to `load` returns an object that is a [Tables.jl](https://github.com/JuliaData/Tables.jl) table, so it can be passed to any function that can handle Tables.jl tables. Here are some examples of materializing an Excel file into such data structures: + +```julia +using ExcelFiles, DataFrames, PrettyTables + +# Load into a DataFrame +julia> DataFrame(load("HTable.xlsx")) +5×10 DataFrame + Row │ Year 1940 1950 1960 1970 1980 1990 2000 2010 2020 + │ String Any Any Float64 Float64 Any Any Float64 Float64 Float64 +─────┼─────────────────────────────────────────────────────────────────────────────────────────── + 1 │ Col A 1 2 3.0 4.0 5 6 7.0 8.0 9.0 + 2 │ Col B 10 20 30.0 40.0 50 60 70.0 80.0 90.0 + 3 │ Col C 100 200 300.0 400.0 500 600 700.0 800.0 900.0 + 4 │ Col D 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 + 5 │ Col E Hello 2025-12-19 3.0 3.33 Hello 2025-12-19 3.0 3.33 1.0 + +julia> DataFrame(load("HTable.xlsx"; transpose=true)) +9×6 DataFrame + Row │ Year Col A Col B Col C Col D Col E + │ Int64 Int64 Int64 Int64 Float64 Any +─────┼───────────────────────────────────────────────── + 1 │ 1940 1 10 100 0.1 Hello + 2 │ 1950 2 20 200 0.2 2025-12-19 + 3 │ 1960 3 30 300 0.3 3 + 4 │ 1970 4 40 400 0.4 3.33 + 5 │ 1980 5 50 500 0.5 Hello + 6 │ 1990 6 60 600 0.6 2025-12-19 + 7 │ 2000 7 70 700 0.7 3 + 8 │ 2010 8 80 800 0.8 3.33 + 9 │ 2020 9 90 900 0.9 true + + +# Load into a PrettyTable +julia> PrettyTable(load("HTable.xlsx")) +┌───────┬───────┬────────────┬───────┬───────┬───────┬────────────┬───────┬───────┬───────┐ +│ Year │ 1940 │ 1950 │ 1960 │ 1970 │ 1980 │ 1990 │ 2000 │ 2010 │ 2020 │ +├───────┼───────┼────────────┼───────┼───────┼───────┼────────────┼───────┼───────┼───────┤ +│ Col A │ 1 │ 2 │ 3.0 │ 4.0 │ 5 │ 6 │ 7.0 │ 8.0 │ 9.0 │ +│ Col B │ 10 │ 20 │ 30.0 │ 40.0 │ 50 │ 60 │ 70.0 │ 80.0 │ 90.0 │ +│ Col C │ 100 │ 200 │ 300.0 │ 400.0 │ 500 │ 600 │ 700.0 │ 800.0 │ 900.0 │ +│ Col D │ 0.1 │ 0.2 │ 0.3 │ 0.4 │ 0.5 │ 0.6 │ 0.7 │ 0.8 │ 0.9 │ +│ Col E │ Hello │ 2025-12-19 │ 3.0 │ 3.33 │ Hello │ 2025-12-19 │ 3.0 │ 3.33 │ 1.0 │ +└───────┴───────┴────────────┴───────┴───────┴───────┴────────────┴───────┴───────┴───────┘ + +julia> PrettyTable(load("HTable.xlsx"; transpose=true)) +┌──────┬───────┬───────┬───────┬───────┬────────────┐ +│ Year │ Col A │ Col B │ Col C │ Col D │ Col E │ +├──────┼───────┼───────┼───────┼───────┼────────────┤ +│ 1940 │ 1 │ 10 │ 100 │ 0.1 │ Hello │ +│ 1950 │ 2 │ 20 │ 200 │ 0.2 │ 2025-12-19 │ +│ 1960 │ 3 │ 30 │ 300 │ 0.3 │ 3 │ +│ 1970 │ 4 │ 40 │ 400 │ 0.4 │ 3.33 │ +│ 1980 │ 5 │ 50 │ 500 │ 0.5 │ Hello │ +│ 1990 │ 6 │ 60 │ 600 │ 0.6 │ 2025-12-19 │ +│ 2000 │ 7 │ 70 │ 700 │ 0.7 │ 3 │ +│ 2010 │ 8 │ 80 │ 800 │ 0.8 │ 3.33 │ +│ 2020 │ 9 │ 90 │ 900 │ 0.9 │ true │ +└──────┴───────┴───────┴───────┴───────┴────────────┘ + +``` + +The `load` function takes a number of arguments and keywords: + +```julia + FileIO.load( + source::String, + [sheet::String, + [columns::String]]; + [first_row::Int], + [first_column::String] + [column_labels::Vector{String}], + [header::Bool], + [normalizenames::Bool], + [transpose::Bool] + ) +``` + +### Arguments: + +* `source`: The name of the file to be loaded. +* `sheet`: Specifies the sheet name to be loaded. If `sheet` is not given, the first Excel sheet in the file will be used. +* `columns`: Determines which columns to read. For example, `"B:D"` will select columns B, C and D. If columns is not given, the algorithm will find the first sequence of consecutive non-empty cells. A valid sheet **must** be specified when specifying columns. If `transpose = true` or is omitted, `columns` should be used to specify rows. For example, specifying `"2:4"` with `transpose = true` will read only from these rows. + +### Keywords: + +* `first_row`: Indicates the first row of the data table to be read. For example, `first_row=5` will look for a table starting at sheet row 5. If first_row is not given, the algorithm will look for the first non-empty row in the sheet (ignored if `transpose = true`). +* `first_column`: Indicates the first row of the data table to be read. For example, `first_column="B"` will look for a table starting at sheet row 5. If first_row is not given, the algorithm will look for the first non-empty row in the sheet (ignored if `transpose = false` or is omitted). +* `column_labels`: Specifies column names for the header of the table. If `column_labels` are given and `header=true`, the headers given by `column_labels` will be used, and the first row of the table (containing headers) will be ignored. +* `header`: Indicates if the first row (column if `transpose = true`) is a header. If `header=true` and `column_labels` is not specified, the column labels for the table will be read from the first row (column) of the table. If `header=false` and `column_labels` is not specified, the algorithm will generate column labels. The default value is `header=true`. +* `normalizenames`: Set to `true` to normalize column names to valid Julia identifiers. Default=`false`. +* `transpose`: Set to `true` to transpose the table to read data from rows not columns. + +### Examples + +```julia +julia> PrettyTable(load("HTable.xlsx", "Offset"; first_row=2)) + +julia> df = DataFrame(load("HTable.xlsx", "Offset", "2:7"; transpose=true, first_column="B")) + +julia> df = DataFrame(load("HTable.xlsx"; normalizenames=true, transpose=true, column_labels=["Date", "Name1", "Name2", "Name3", "Name4", "Name5"])) + +``` +## Save an Excel file + +The following code saves any Tables.jl table (such as a `DataFrame`) as an Excel file: +```julia +using ExcelFiles + +save("output.xlsx", tbl) +``` + +The `save` function takes a number of arguments and keywords: + +```julia + FileIO.save( + source::String; + [sheetname::String], + [overwrite::Bool] + ) +``` + +### Arguments: + +* `source`: The name of the file to be created on save. + +### Keywords: + +* `sheetname`: Specify the sheetname to be used in the created file. By default, the sheetname will be `Sheet1`. +* `overwrite`: Set `overwrite=true` to overwite any existing file of the same name. Default = `false`. + +### Examples + +```julia +julia> save("myfile.xlsx", df; sheetname="myname", overwrite=true) +``` + +## Using the pipe syntax + +The `load` and `save` functions also support the pipe syntax. For example, to load an Excel file into a `DataFrame`, one can use the following code: + +```julia +using ExcelFiles, DataFrame + +df = load("data.xlsx", "Sheet1") |> DataFrame +``` + +To save any Tables.jl compatible table (such as a DataFrame), one can use the following form: + +```julia +using ExcelFiles, DataFrame + +df = # Aquire a DataFrame somehow + +df |> save("output.xlsx") +``` diff --git a/src/ExcelFiles.jl b/src/ExcelFiles.jl index 9b7eb6a..9259cec 100644 --- a/src/ExcelFiles.jl +++ b/src/ExcelFiles.jl @@ -1,133 +1,82 @@ module ExcelFiles +using XLSX, FileIO, Tables, Dates -using ExcelReaders, XLSX, IteratorInterfaceExtensions, TableTraits, DataValues -using TableTraitsUtils, FileIO, TableShowUtils, Dates, Printf -import IterableTables - -export load, save, File, @format_str +export load, save, File struct ExcelFile filename::String - range::String + sheet::Union{Nothing,String} + columns::Union{Nothing,String} keywords end -function Base.show(io::IO, source::ExcelFile) - TableShowUtils.printtable(io, getiterator(source), "Excel file") -end +# --- Display --- -function Base.show(io::IO, ::MIME"text/html", source::ExcelFile) - TableShowUtils.printHTMLtable(io, getiterator(source)) +# Radically simplified - now relies on universal adoption of Tables.jl among consumers. +# Retain only basic show method. +function Base.show(io::IO, f::ExcelFile) + print(io, "ExcelFile(\"$(f.filename)\")") end -Base.Multimedia.showable(::MIME"text/html", source::ExcelFile) = true +# --- FileIO integration --- -function Base.show(io::IO, ::MIME"application/vnd.dataresource+json", source::ExcelFile) - TableShowUtils.printdataresource(io, getiterator(source)) +function fileio_load(f::FileIO.File{FileIO.format"Excel"}, sheet, columns; kw...) + return ExcelFile(f.filename, sheet, columns, kw) end - -Base.Multimedia.showable(::MIME"application/vnd.dataresource+json", source::ExcelFile) = true - -function fileio_load(f::FileIO.File{FileIO.format"Excel"}, range; keywords...) - return ExcelFile(f.filename, range, keywords) +function fileio_load(f::FileIO.File{FileIO.format"Excel"}, sheet; kw...) + return ExcelFile(f.filename, sheet, nothing, kw) end - -function fileio_save(f::FileIO.File{FileIO.format"Excel"}, data; sheetname::AbstractString="") - cols, colnames = TableTraitsUtils.create_columns_from_iterabletable(data, na_representation=:missing) - return XLSX.writetable(f.filename, cols, colnames; sheetname=sheetname) +function fileio_load(f::FileIO.File{FileIO.format"Excel"}; kw...) + return ExcelFile(f.filename, nothing, nothing, kw) end -IteratorInterfaceExtensions.isiterable(x::ExcelFile) = true -TableTraits.isiterabletable(x::ExcelFile) = true - -function gennames(n::Integer) - res = Vector{Symbol}(undef, n) - for i in 1:n - res[i] = Symbol(@sprintf "x%d" i) - end - return res +function fileio_save(f::FileIO.File{FileIO.format"Excel"}, data; kw...) + XLSX.writetable(f.filename, data; kw...) end -function _readxl(file::ExcelReaders.ExcelFile, sheetname::AbstractString, startrow::Integer, startcol::Integer, endrow::Integer, endcol::Integer; header::Bool=true, colnames::Vector{Symbol}=Symbol[]) - data = ExcelReaders.readxl_internal(file, sheetname, startrow, startcol, endrow, endcol) +# --- Tables.jl interface --- - nrow, ncol = size(data) +Tables.istable(::ExcelFile) = true +Tables.columnaccess(::ExcelFile) = true - if length(colnames) == 0 - if header - headervec = data[1, :] - NAcol = map(i -> isa(i, DataValues.DataValue) && DataValues.isna(i), headervec) - headervec[NAcol] = gennames(count(!iszero, NAcol)) +function Tables.schema(file::ExcelFile) + tbl = _readxl(file) + return Tables.schema(tbl) +end - # This somewhat complicated conditional makes sure that column names - # that are integer numbers end up without an extra ".0" as their name - colnames = [isa(i, AbstractFloat) ? ( modf(i)[1] == 0.0 ? Symbol(Int(i)) : Symbol(string(i)) ) : Symbol(i) for i in vec(headervec)] - else - colnames = gennames(ncol) - end - elseif length(colnames) != ncol - error("Length of colnames must equal number of columns in selected range") - end +function Tables.columns(file::ExcelFile) + return Tables.columns(_readxl(file)) +end - columns = Array{Any}(undef, ncol) +function Tables.rows(file::ExcelFile) + return Tables.rows(_readxl(file)) +end - for i = 1:ncol - if header - vals = data[2:end,i] - else - vals = data[:,i] - end +# --- Internal reader --- - # Check whether all non-NA values in this column - # are of the same type - type_of_el = length(vals) > 0 ? typeof(vals[1]) : Any - for val = vals - type_of_el = promote_type(type_of_el, typeof(val)) - end +function _readxl(file::ExcelFile) + kw = NamedTuple(file.keywords) - if type_of_el <: DataValue - columns[i] = convert(DataValueArray{eltype(type_of_el)}, vals) + if get(kw, :transpose, false) + f = XLSX.readtransposedtable + kw = NamedTuple{filter(k -> k ∉ (:transpose, :first_row), keys(kw))}(kw) + else + f = XLSX.readtable + kw = NamedTuple{filter(k -> k ∉ (:transpose, :first_column), keys(kw))}(kw) + end - # TODO Check wether this hack is correct - for (j, v) in enumerate(columns[i]) - if v isa DataValue && !DataValues.isna(v) && v[] isa DataValue - columns[i][j] = v[] - end - end + if isnothing(file.columns) + if isnothing(file.sheet) + table = f(file.filename; kw...) else - columns[i] = convert(Array{type_of_el}, vals) + table = f(file.filename, file.sheet; kw...) end - end - - return columns, colnames -end - -function IteratorInterfaceExtensions.getiterator(file::ExcelFile) - column_data, col_names = if occursin("!", file.range) - excelfile = openxl(file.filename) - - sheetname, startrow, startcol, endrow, endcol = ExcelReaders.convert_ref_to_sheet_row_col(file.range) - - _readxl(excelfile, sheetname, startrow, startcol, endrow, endcol; file.keywords...) else - excelfile = openxl(file.filename) - sheet = excelfile.workbook.sheet_by_name(file.range) - - keywords = filter(i -> !(i[1] in (:header, :colnames)), file.keywords) - startrow, startcol, endrow, endcol = ExcelReaders.convert_args_to_row_col(sheet; keywords...) - - keywords2 = copy(file.keywords) - keywords2 = filter(i -> !(i[1] in (:skipstartrows, :skipstartcols, :nrows, :ncols)), file.keywords) - - _readxl(excelfile, file.range, startrow, startcol, endrow, endcol; keywords2...) + table = f(file.filename, file.sheet, file.columns; kw...) end - return create_tableiterator(column_data, col_names) -end - -function Base.collect(file::ExcelFile) - return collect(getiterator(file)) + return table # XLSX v0.11 returns a Tables.jl-compatible object directly end end # module diff --git a/test/data/TestData.xlsx b/test/data/TestData.xlsx new file mode 100644 index 0000000..420a8cb Binary files /dev/null and b/test/data/TestData.xlsx differ diff --git a/test/runtests.jl b/test/runtests.jl index d1d0372..bf7290b 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,149 +1,157 @@ using ExcelFiles -using ExcelReaders -using IteratorInterfaceExtensions -using TableTraits -using TableTraitsUtils +using Tables using Dates -using DataValues +using XLSX using DataFrames using Test -@testset "ExcelFiles" begin +data_directory = joinpath(dirname(pathof(ExcelFiles)), "..", "test","data") +@assert isdir(data_directory) - filename = normpath(dirname(pathof(ExcelReaders)), "..", "test", "TestData.xlsx") - - efile = load(filename, "Sheet1") +# Helper: get columns and names from a loaded ExcelFile +function get_cols(source) + tbl = Tables.columntable(source) + cols = [collect(tbl[n]) for n in Tables.columnnames(tbl)] + names = collect(Tables.columnnames(tbl)) + return cols, names +end - @test sprint((stream, data) -> show(stream, "text/html", data), efile) == "
Some Float64sSome StringsSome BoolsMixed columnMixed with NAFloat64 with NAString with NABool with NASome datesDates with NASome errorsErrors with NAColumn with NULL and then mixed
1.0"A"true2.09.03.0"FF"#NA2015-03-03T00:00:001965-04-03T00:00:00#DIV/0!#DIV/0!#NA
1.5"BB"false"EEEEE""III"#NA#NAtrue2015-02-04T10:14:001950-08-09T18:40:00#N/A#N/A3.4
2.0"CCC"falsefalse#NA3.5"GGG"#NA1988-04-09T00:00:0019:00:00#REF!#NAME?"HKEJW"
2.5"DDDD"true1.5true4.0"HHHH"false15:02:00#NA#NAME?#NA#NA
" +@testset "ExcelFiles" verbose=true begin - @test sprint((stream, data) -> show(stream, "application/vnd.dataresource+json", data), efile) == "{\"schema\":{\"fields\":[{\"name\":\"Some Float64s\",\"type\":\"number\"},{\"name\":\"Some Strings\",\"type\":\"string\"},{\"name\":\"Some Bools\",\"type\":\"boolean\"},{\"name\":\"Mixed column\",\"type\":\"string\"},{\"name\":\"Mixed with NA\",\"type\":\"string\"},{\"name\":\"Float64 with NA\",\"type\":\"number\"},{\"name\":\"String with NA\",\"type\":\"string\"},{\"name\":\"Bool with NA\",\"type\":\"boolean\"},{\"name\":\"Some dates\",\"type\":\"string\"},{\"name\":\"Dates with NA\",\"type\":\"string\"},{\"name\":\"Some errors\",\"type\":\"string\"},{\"name\":\"Errors with NA\",\"type\":\"string\"},{\"name\":\"Column with NULL and then mixed\",\"type\":\"string\"}]},\"data\":[{\"Some Float64s\":1.0,\"Some Strings\":\"A\",\"Some Bools\":true,\"Mixed column\":2.0,\"Mixed with NA\":9.0,\"Float64 with NA\":3.0,\"String with NA\":\"FF\",\"Bool with NA\":null,\"Some dates\":\"2015-03-03T00:00:00\",\"Dates with NA\":\"1965-04-03T00:00:00\",\"Some errors\":{\"errorcode\":7},\"Errors with NA\":{\"errorcode\":7},\"Column with NULL and then mixed\":null},{\"Some Float64s\":1.5,\"Some Strings\":\"BB\",\"Some Bools\":false,\"Mixed column\":\"EEEEE\",\"Mixed with NA\":\"III\",\"Float64 with NA\":null,\"String with NA\":null,\"Bool with NA\":true,\"Some dates\":\"2015-02-04T10:14:00\",\"Dates with NA\":\"1950-08-09T18:40:00\",\"Some errors\":{\"errorcode\":42},\"Errors with NA\":{\"errorcode\":42},\"Column with NULL and then mixed\":3.4},{\"Some Float64s\":2.0,\"Some Strings\":\"CCC\",\"Some Bools\":false,\"Mixed column\":false,\"Mixed with NA\":null,\"Float64 with NA\":3.5,\"String with NA\":\"GGG\",\"Bool with NA\":null,\"Some dates\":\"1988-04-09T00:00:00\",\"Dates with NA\":\"19:00:00\",\"Some errors\":{\"errorcode\":23},\"Errors with NA\":{\"errorcode\":29},\"Column with NULL and then mixed\":\"HKEJW\"},{\"Some Float64s\":2.5,\"Some Strings\":\"DDDD\",\"Some Bools\":true,\"Mixed column\":1.5,\"Mixed with NA\":true,\"Float64 with NA\":4.0,\"String with NA\":\"HHHH\",\"Bool with NA\":false,\"Some dates\":\"15:02:00\",\"Dates with NA\":null,\"Some errors\":{\"errorcode\":29},\"Errors with NA\":null,\"Column with NULL and then mixed\":null}]}" + filename = joinpath(data_directory, "TestData.xlsx") - @test sprint(show, efile) == "4x13 Excel file\nSome Float64s │ Some Strings │ Some Bools │ Mixed column │ Mixed with NA\n──────────────┼──────────────┼────────────┼──────────────┼──────────────\n1.0 │ A │ true │ 2.0 │ 9.0 \n1.5 │ BB │ false │ \"EEEEE\" │ \"III\" \n2.0 │ CCC │ false │ false │ #NA \n2.5 │ DDDD │ true │ 1.5 │ true \n... with 8 more columns: Float64 with NA, String with NA, Bool with NA, Some dates, Dates with NA, Some errors, Errors with NA, Column with NULL and then mixed" + efile = load(filename, "Sheet1") - @test TableTraits.isiterabletable(efile) == true - @test IteratorInterfaceExtensions.isiterable(efile) == true - @test showable("text/html", efile) == true - @test showable("application/vnd.dataresource+json", efile) == true + @test Tables.istable(efile) == true - @test isiterable(efile) == true + # Test show renders expected number of rows and columns, without depending on exact truncation/wrapping + @testset "show plain text" begin + s = sprint(show, efile) + @test s == "ExcelFile(\"$filename\")" + end - full_dfs = [create_columns_from_iterabletable(load(filename, "Sheet1!C3:O7")), create_columns_from_iterabletable(load(filename, "Sheet1"))] - for (df, names) in full_dfs + @testset "ReadTable" begin + for source in [load(filename, "Sheet1", "C:O"; first_row=3), load(filename, "Sheet1")] + df, names = get_cols(source) + @test length(df) == 13 + @test length(df[1]) == 4 + + @test df[1] == [1., 1.5, 2., 2.5] + @test df[2] == ["A", "BB", "CCC", "DDDD"] + @test df[3] == [true, false, false, true] + @test isequal(df[4], [2, "EEEEE", false, 1.5]) + @test isequal(df[5], [9., "III", missing, true]) + @test isequal(df[6], [3., missing, 3.5, 4.]) + @test isequal(df[7], ["FF", missing, "GGG", "HHHH"]) + @test isequal(df[8], [missing, true, missing, false]) + @test df[9] == [Date(2015, 3, 3), DateTime(2015, 2, 4, 10, 14), Date(1988, 4, 9), Dates.Time(15, 2, 0)] + @test isequal(df[10], [Date(1965, 4, 3), DateTime(1950, 8, 9, 18, 40), Dates.Time(19, 0, 0), missing]) + @test all(ismissing, df[11]) + @test isequal(df[12], [missing, missing, missing, missing]) + @test isequal(df[13], [missing, 3.4, "HKEJW", missing]) + end + + df, names = get_cols(load(filename, "Sheet1", "C:O"; first_row=4, header=false)) + @test names == [:C, :D, :E, :F, :G, :H, :I, :J, :K, :L, :M, :N, :O] + @test length(df[1]) == 4 + @test length(df) == 13 + @test df[1] == [1., 1.5, 2., 2.5] + @test df[2] == ["A", "BB", "CCC", "DDDD"] + @test df[3] == [true, false, false, true] + @test isequal(df[4], [2, "EEEEE", false, 1.5]) + @test isequal(df[5], [9., "III", missing, true]) + @test isequal(df[6], [3., missing, 3.5, 4.]) + @test isequal(df[7], ["FF", missing, "GGG", "HHHH"]) + @test isequal(df[8], [missing, true, missing, false]) + @test df[9] == [Date(2015, 3, 3), DateTime(2015, 2, 4, 10, 14), DateTime(1988, 4, 9), Dates.Time(15, 2, 0)] + @test isequal(df[10], [Date(1965, 4, 3), DateTime(1950, 8, 9, 18, 40), Dates.Time(19, 0, 0), missing]) + @test all(ismissing, df[11]) + @test all(ismissing, df[12]) + @test isequal(df[13], [missing, 3.4, "HKEJW", missing]) + @test ismissing(df[12][4]) + + good_colnames = [:c1, :c2, :c3, :c4, :c5, :c6, :c7, :c8, :c9, :c10, :c11, :c12, :c13] + + df, names = get_cols(load(filename, "Sheet1", "C:O"; first_row=4, header=false, column_labels=good_colnames)) + @test names == good_colnames + @test length(df[1]) == 4 @test length(df) == 13 + @test df[1] == [1., 1.5, 2., 2.5] + @test df[2] == ["A", "BB", "CCC", "DDDD"] + @test df[3] == [true, false, false, true] + @test isequal(df[4], [2, "EEEEE", false, 1.5]) + @test isequal(df[5], [9., "III", missing, true]) + @test isequal(df[6], [3., missing, 3.5, 4.]) + @test isequal(df[7], ["FF", missing, "GGG", "HHHH"]) + @test isequal(df[8], [missing, true, missing, false]) + @test df[9] == [Date(2015, 3, 3), DateTime(2015, 2, 4, 10, 14), DateTime(1988, 4, 9), Dates.Time(15, 2, 0)] + @test isequal(df[10], [Date(1965, 4, 3), DateTime(1950, 8, 9, 18, 40), Dates.Time(19, 0, 0), missing]) + @test all(ismissing, df[11]) + @test all(ismissing, df[12]) + @test isequal(df[13], [missing, 3.4, "HKEJW", missing]) + @test ismissing(df[12][4]) + + # Test for saving DataFrame to XLSX + input = (Day = ["Nov. 27", "Nov. 28", "Nov. 29"], Highest = [78, 79, 75]) |> DataFrame + save("file.xlsx", input) + output = load("file.xlsx", "Sheet1") |> DataFrame + @test input == output + rm("file.xlsx") + + # Test for saving DataFrame to XLSX with sheetname keyword + input = (Day = ["Nov. 27", "Nov. 28", "Nov. 29"], Highest = [78, 79, 75]) |> DataFrame + save("file.xlsx", input, sheetname="SheetName") + output = load("file.xlsx", "SheetName") |> DataFrame + @test input == output + rm("file.xlsx") + + df, names = get_cols(load(filename, "Sheet1"; column_labels=good_colnames)) + @test names == good_colnames @test length(df[1]) == 4 + @test length(df) == 13 + @test df[1] == [1., 1.5, 2., 2.5] + @test df[2] == ["A", "BB", "CCC", "DDDD"] + @test df[3] == [true, false, false, true] + @test isequal(df[4], [2, "EEEEE", false, 1.5]) + @test isequal(df[5], [9., "III", missing, true]) + @test isequal(df[6], [3., missing, 3.5, 4.]) + @test isequal(df[7], ["FF", missing, "GGG", "HHHH"]) + @test isequal(df[8], [missing, true, missing, false]) + @test df[9] == [Date(2015, 3, 3), DateTime(2015, 2, 4, 10, 14), DateTime(1988, 4, 9), Dates.Time(15, 2, 0)] + @test isequal(df[10], [Date(1965, 4, 3), DateTime(1950, 8, 9, 18, 40), Dates.Time(19, 0, 0), missing]) + @test all(ismissing, df[11]) + @test all(ismissing, df[12]) + @test isequal(df[13], [missing, 3.4, "HKEJW", missing]) + @test ismissing(df[12][4]) + + # Too few column labels + @test_throws XLSX.XLSXError get_cols(load(filename, "Sheet1", "C:O"; header=true, column_labels=[:c1, :c2, :c3, :c4])) + + # Test for constructing DataFrame with empty header cell + data, names = get_cols(load(filename, "Sheet2", "C:E")) + @test names == [:Col1, Symbol("#Empty"), :Col3] + + # normalizenames keyword (XLSX.jl v0.11 only) + data, names = get_cols(load(filename, "Sheet2", "C:E"; normalizenames=true)) + @test names == [:Col1, :_Empty, :Col3] - @test df[1] == [1., 1.5, 2., 2.5] - @test df[2] == ["A", "BB", "CCC", "DDDD"] - @test df[3] == [true, false, false, true] - @test df[4] == [2, "EEEEE", false, 1.5] - @test df[5] == [9., "III", NA, true] - @test df[6] == [3., NA, 3.5, 4] - @test df[7] == ["FF", NA, "GGG", "HHHH"] - @test df[8] == [NA, true, NA, false] - @test df[9] == [Date(2015, 3, 3), DateTime(2015, 2, 4, 10, 14), Date(1988, 4, 9), Dates.Time(15, 2, 0)] - @test df[10] == [Date(1965, 4, 3), DateTime(1950, 8, 9, 18, 40), Dates.Time(19, 0, 0), NA] - @test eltype(df[11]) == ExcelReaders.ExcelErrorCell - @test df[12][1][] isa ExcelReaders.ExcelErrorCell - @test df[12][2][] isa ExcelReaders.ExcelErrorCell - @test df[12][3][] isa ExcelReaders.ExcelErrorCell - @test df[12][4] == NA - @test df[13] == [NA, 3.4, "HKEJW", NA] end - df, names = create_columns_from_iterabletable(load(filename, "Sheet1!C4:O7", header=false)) - @test names == [:x1,:x2,:x3,:x4,:x5,:x6,:x7,:x8,:x9,:x10,:x11,:x12,:x13] - @test length(df[1]) == 4 - @test length(df) == 13 - @test df[1] == [1., 1.5, 2., 2.5] - @test df[2] == ["A", "BB", "CCC", "DDDD"] - @test df[3] == [true, false, false, true] - @test df[4] == [2, "EEEEE", false, 1.5] - @test df[5] == [9., "III", NA, true] - @test df[6] == [3, NA, 3.5, 4] - @test df[7] == ["FF", NA, "GGG", "HHHH"] - @test df[8] == [NA, true, NA, false] - @test df[9] == [Date(2015, 3, 3), DateTime(2015, 2, 4, 10, 14), DateTime(1988, 4, 9), Dates.Time(15, 2, 0)] - @test df[10] == [Date(1965, 4, 3), DateTime(1950, 8, 9, 18, 40), Dates.Time(19, 0, 0), NA] - @test isa(df[11][1], ExcelReaders.ExcelErrorCell) - @test isa(df[11][2], ExcelReaders.ExcelErrorCell) - @test isa(df[11][3], ExcelReaders.ExcelErrorCell) - @test isa(df[11][4], ExcelReaders.ExcelErrorCell) - @test isa(df[12][1][], ExcelReaders.ExcelErrorCell) - @test isa(df[12][2][], ExcelReaders.ExcelErrorCell) - @test isa(df[12][3][], ExcelReaders.ExcelErrorCell) - @test DataValues.isna(df[12][4]) - @test df[13] == [NA, 3.4, "HKEJW", NA] - - good_colnames = [:c1, :c2, :c3, :c4, :c5, :c6, :c7, :c8, :c9, :c10, :c11, :c12, :c13] - - df, names = create_columns_from_iterabletable(load(filename, "Sheet1!C4:O7", header=false, colnames=good_colnames)) - @test names == good_colnames - @test length(df[1]) == 4 - @test length(df) == 13 - @test df[1] == [1., 1.5, 2., 2.5] - @test df[2] == ["A", "BB", "CCC", "DDDD"] - @test df[3] == [true, false, false, true] - @test df[4] == [2, "EEEEE", false, 1.5] - @test df[5] == [9., "III", NA, true] - @test df[6] == [3, NA, 3.5, 4] - @test df[7] == ["FF", NA, "GGG", "HHHH"] - @test df[8] == [NA, true, NA, false] - @test df[9] == [Date(2015, 3, 3), DateTime(2015, 2, 4, 10, 14), DateTime(1988, 4, 9), Dates.Time(15, 2, 0)] - @test df[10] == [Date(1965, 4, 3), DateTime(1950, 8, 9, 18, 40), Dates.Time(19, 0, 0), NA] - @test isa(df[11][1], ExcelReaders.ExcelErrorCell) - @test isa(df[11][2], ExcelReaders.ExcelErrorCell) - @test isa(df[11][3], ExcelReaders.ExcelErrorCell) - @test isa(df[11][4], ExcelReaders.ExcelErrorCell) - @test isa(df[12][1][], ExcelReaders.ExcelErrorCell) - @test isa(df[12][2][], ExcelReaders.ExcelErrorCell) - @test isa(df[12][3][], ExcelReaders.ExcelErrorCell) - @test DataValues.isna(df[12][4]) - @test df[13] == [NA, 3.4, "HKEJW", NA] - -# Test for saving DataFrame to XLSX - input = (Day = ["Nov. 27","Nov. 28","Nov. 29"], Highest = [78,79,75]) |> DataFrame - file = save("file.xlsx", input) - output = load("file.xlsx", "Sheet1") |> DataFrame - @test input == output - rm("file.xlsx") - -# Test for saving DataFrame to XLSX with sheetname keyword - input = (Day = ["Nov. 27","Nov. 28","Nov. 29"], Highest = [78,79,75]) |> DataFrame - file = save("file.xlsx", input, sheetname="SheetName") - output = load("file.xlsx", "SheetName") |> DataFrame - @test input == output - rm("file.xlsx") - - df, names = create_columns_from_iterabletable(load(filename, "Sheet1", colnames=good_colnames)) - @test names == good_colnames - @test length(df[1]) == 4 - @test length(df) == 13 - @test df[1] == [1., 1.5, 2., 2.5] - @test df[2] == ["A", "BB", "CCC", "DDDD"] - @test df[3] == [true, false, false, true] - @test df[4] == [2, "EEEEE", false, 1.5] - @test df[5] == [9., "III", NA, true] - @test df[6] == [3, NA, 3.5, 4] - @test df[7] == ["FF", NA, "GGG", "HHHH"] - @test df[8] == [NA, true, NA, false] - @test df[9] == [Date(2015, 3, 3), DateTime(2015, 2, 4, 10, 14), DateTime(1988, 4, 9), Dates.Time(15, 2, 0)] - @test df[10] == [Date(1965, 4, 3), DateTime(1950, 8, 9, 18, 40), Dates.Time(19, 0, 0), NA] - @test isa(df[11][1], ExcelReaders.ExcelErrorCell) - @test isa(df[11][2], ExcelReaders.ExcelErrorCell) - @test isa(df[11][3], ExcelReaders.ExcelErrorCell) - @test isa(df[11][4], ExcelReaders.ExcelErrorCell) - @test isa(df[12][1][], ExcelReaders.ExcelErrorCell) - @test isa(df[12][2][], ExcelReaders.ExcelErrorCell) - @test isa(df[12][3][], ExcelReaders.ExcelErrorCell) - @test DataValues.isna(df[12][4]) - @test df[13] == [NA, 3.4, "HKEJW", NA] - -# Too few colnames - @test_throws ErrorException create_columns_from_iterabletable(load(filename, "Sheet1!C4:O7", header=true, colnames=[:c1, :c2, :c3, :c4])) - -# Test for constructing DataFrame with empty header cell - data, names = create_columns_from_iterabletable(load(filename, "Sheet2!C5:E7")) - @test names == [:Col1, :x1, :Col3] + @testset "Transposed tables" begin + # Note: readtransposedtable cannot handle entirely empty rows/columns, + # so the Transpose sheet omits those from the original Sheet1 data. + # Note: eltype of mixed date columns is Dates.TimeType (not Any) when + # there are no missing values, since a common supertype can be inferred. + df, names = get_cols(load(filename, "Transpose"; transpose=true, first_column=2)) + @test length(df) == 5 + @test length(df[1]) == 4 + @test names == [Symbol("Some Float64s"), Symbol("Some Strings"), Symbol("Some Bools"), Symbol("Mixed with NA"), Symbol("Some dates")] -end + @test df[1] == [1.0, 1.5, 2.0, 2.5] + @test df[2] == ["A", "BB", "CCC", "DDDD"] + @test df[3] == Bool[true, false, false, true] + @test isequal(df[4], Any[9, "III", missing, true]) + @test df[5] == Dates.TimeType[Date(2015, 3, 3), DateTime(2015, 2, 4, 10, 14), Date(1988, 4, 9), Dates.Time(15, 2, 0)] + end +end \ No newline at end of file