%%% vim:ts=2:sw=2:et %%%----------------------------------------------------------------------------- %%% @doc CSV file parsing functions %%% %%% @author Serge Aleynikov %%% @copyright 2021 Serge Aleynikov %%% @end %%%----------------------------------------------------------------------------- %%% Created 2021-06-01 %%%----------------------------------------------------------------------------- -module(csv). -author('saleyn@gmail.com'). -export([parse/1, parse/2, parse_line/1]). -export([max_field_lengths/2, guess_data_types/2, guess_data_type/1, load_to_mysql/4]). %%------------------------------------------------------------------------------ %% CSV parsing %%------------------------------------------------------------------------------ -spec parse(string()) -> [[string()]]. parse(File) when is_list(File) -> parse(File, []). %%------------------------------------------------------------------------------ %% @doc Parse a given CSV file. %% Options: %%
%%
fix_lengths
if a record has a column count %% greater than what's found in the header row, those extra columns will be %% dropped, and if a row has fewer columns, empty columns will be added.
%%
{open, list()}
Options given to file:open/2
%%
{open, list()}
Options given to file:open/2
%%
%% @end %%------------------------------------------------------------------------------ -spec parse(string(), [fix_lengths | {open, Opts::list()}]) -> [[string()]]. parse(File, Opts) when is_list(File), is_list(Opts) -> FileOpts = proplists:get_value(open, Opts, []), Mode = case proplists:get_value(binary, Opts, true) andalso not proplists:get_value(list, Opts, false) of true -> binary; false -> list end, {ok, F} = file:open(File, [read, raw, binary]++FileOpts), FstLine = case file:read_line(F) of {ok, <>} when I == 65279 -> %% Utf-8 <<239,187,191,...>> {ok, Line}; Other -> Other end, Res = case parse_csv_file(F, 1, FstLine, []) of L when Mode == binary -> L; L -> [[binary_to_list(B) || B <- Row] || Row <- L] end, case lists:member(fix_lengths, Opts) of true when hd(Res) == hd(Res) -> %% Not an empty list HLen = length(hd(Res)), [fix_length(HLen, length(R), R) || R <- Res]; _ -> Res end. parse_csv_file(F, _, eof, Done) -> file:close(F), lists:reverse(Done); parse_csv_file(F, LineNo, {ok, Line}, Done) -> parse_csv_file(F, LineNo+1, file:read_line(F), [parse_line(LineNo, Line)|Done]); parse_csv_file(_F, LineNo, {error, Reason}, _) -> throw({error, [{line, LineNo}, {reason, file:format_error(Reason)}]}). trim_eol(Line) -> trim_eol(0, Line). trim_eol(N, Line) when N < byte_size(Line) -> Z = byte_size(Line), M = N+1, case binary:at(Line, Z-M) of C when C == $\r; C == $\n -> trim_eol(M, Line); _ -> Z-N end; trim_eol(N, Line) -> byte_size(Line) - N. parse_line(Line) -> parse_line(1, Line). parse_line(LineNo, Line) -> try parse_csv_field(0, trim_eol(Line), Line, 0,0, [], false) catch E:R:S -> erlang:raise(E, {line_parse_error, LineNo, Line, R}, S) end. field(Line, Pos, Len, true = _HasEscDblQuote) -> binary:replace(binary:part(Line, Pos, Len), <<"\"\"">>, <<"\"">>, [global]); field(Line, Pos, Len, _) -> binary:part(Line, Pos, Len). parse_csv_field(From, To, Line, Pos,Len, Fields, HasEscDblQuote) when From >= To -> lists:reverse([field(Line, Pos, Len, HasEscDblQuote) | Fields]); parse_csv_field(From, To, Line, Pos,Len, Fields, HasEscDblQuote) -> case Line of <<_:From/binary, "\"", _/binary>> -> parse_csv_field_q(From+1, To, Line, From+1, 0, Fields, HasEscDblQuote); <<_:From/binary, ",", _/binary>> -> parse_csv_field(From+1,To,Line, From+1, 0, [field(Line,Pos,Len,HasEscDblQuote)|Fields], false); _ -> parse_csv_field(From+1,To,Line, Pos,Len+1, Fields, HasEscDblQuote) end. parse_csv_field_q(From, To, Line, Pos,Len, Fields, HasEscDblQuote) when From < To -> case Line of <<_:From/binary, "\"\"", _/binary>> -> parse_csv_field_q(From+2, To, Line, Pos, Len+2, Fields, true); <<_:From/binary, "\\", _/binary>> -> parse_csv_field_q(From+1, To, Line, Pos, Len+1, Fields, HasEscDblQuote); <<_:From/binary, "\"", _/binary>> -> parse_csv_field (From+1, To, Line, Pos, Len, Fields, HasEscDblQuote); _ -> parse_csv_field_q(From+1, To, Line, Pos, Len+1, Fields, HasEscDblQuote) end; parse_csv_field_q(From, To, Line, Pos,Len, Fields, HasEscDblQuote) -> parse_csv_field(From, To, Line, Pos,Len, Fields, HasEscDblQuote). fix_length(HLen, HLen, Data) -> Data; fix_length(HLen, DLen, Data) when HLen < DLen -> {RR, _} = lists:split(HLen, Data), RR; fix_length(HLen, DLen, Data) when HLen > DLen -> Value = if is_binary(hd(Data)) -> <<"">>; true -> "" end, RR = lists:duplicate(HLen-DLen, Value), Data ++ RR. %%------------------------------------------------------------------------------ %% @doc Get max field lengths for a list obtained by parsing a CSV file with %% `parse_csv_file(File,[fix_lengths])'. %% @end %%------------------------------------------------------------------------------ -spec max_field_lengths(HasHeaderRow::boolean(), Rows::[Fields::list()]) -> [Len::integer()]. max_field_lengths(true = _HasHeaders, [_Headers | Rows ] = _CSV) -> max_field_lengths(false, Rows); max_field_lengths(false = _HasHeaders, CsvRows) -> % Calculate length of a field in a row: % CSV -> [[R1FieldLen0, ..., R1FieldLenN], ..., % [RnFieldLen0, RnFieldLenN]] F = fun(Row) -> [if is_binary(I) -> byte_size(I); true -> length(I) end || I <- Row] end, CsvL = [F(Row) || Row <- CsvRows], MLens = fun G([[] | _], Acc) -> lists:reverse(Acc); G(RRows, Acc) -> {MaxLen, F1Nrows} = lists:foldl(fun([H|T], {A,L}) -> {max(H,A),[T|L]} end, {0,[]}, RRows), G(F1Nrows, [MaxLen | Acc]) end, MLens(CsvL, []). guess_data_types(HasHeaders, CSV) -> guess_data_types(HasHeaders, CSV, 20.0, 1_000_000). %%------------------------------------------------------------------------------ %% @doc Guess data types of fields in the given CSV list of rows obtained by %% parsing a CSV file with `parse_csv_file(File,[fix_lengths])'. %% @end %%------------------------------------------------------------------------------ -spec guess_data_types(HasHeaderRow::boolean(), Rows::[Fields::list()], number(), integer()) -> {[{FieldType::string|date|datetime|integer|float|number, MaxFieldLength::integer(), NullsCount::integer()}], [Row::[term()]]}. guess_data_types(true = _HasHeaders, [Headers | Rows ] = _CSV, NullsMaxPcnt, SniffRows) -> {TpLenNulls, DataRows} = guess_data_types(false, Rows, NullsMaxPcnt, SniffRows), {TpLenNulls, [Headers | DataRows]}; guess_data_types(false = _HasHeaders, CsvRows, NullsMaxPcnt, SniffRows) when is_number(NullsMaxPcnt), NullsMaxPcnt >= 0.0, NullsMaxPcnt =< 100.0 , is_integer(SniffRows) -> % CSV -> [[R1Field0, ..., R1FieldN], ..., [RnField0, RnFieldN]] NRows = length(CsvRows), F = fun(UseString, Row) -> [guess_data_type2(I,UseString) || I <- Row] end, CsvL = [F(I > SniffRows, Row) || {I, Row} <- lists:zip(lists:seq(1,NRows), CsvRows)], Res = scan_column(CsvL, NullsMaxPcnt, NRows), Data = [ [V || {_,V,_} <- R] || R <- CsvL], {Res, Data}. sort_fun(A,A) -> true; sort_fun(null,_) -> true; sort_fun(_,null) -> false; sort_fun(A,B) -> A =< B. scan_column([], _NullsMaxPcnt, _NRows) -> []; scan_column(L, NullsMaxPcnt, NRows) when is_number(NullsMaxPcnt) -> scan_column(L, NRows, NullsMaxPcnt, []). scan_column([[]|_], _NRows, _NullsMaxPcnt, Acc) -> lists:reverse(Acc); scan_column(L, NRows, NullsMaxPcnt, Acc) -> {_Type, _MLen, _Nulls} = Res = scan_mlt(L, NRows, NullsMaxPcnt), Acc1 = [Res | Acc], case L of [[_|_]|_] -> scan_column([tl(I) || I <- L], NRows, NullsMaxPcnt, Acc1); _ -> scan_column([[]], NRows, NullsMaxPcnt, Acc1) end. scan_mlt(L, NRows, NullsMaxPcnt) when is_number(NullsMaxPcnt) -> {CTypes, MLen, Nulls, _} = scan_mlt2(L, {#{}, 0, 0, false}), PcntNulls = if NRows == 0 -> 100; true -> Nulls / NRows * 100 end, Fun = fun ([T]) when T /= null -> T; ([date,datetime]) -> datetime; ([float,integer]) -> number; (_) -> string end, Type = case lists:sort(fun sort_fun/2, maps:keys(CTypes)) of [null|T] when PcntNulls =< NullsMaxPcnt -> Fun(T); T -> Fun(T) end, {Type, MLen, Nulls}. scan_mlt2([[{I,_,V} | _] | T], {ATypes, MLen, Nulls, HasStrings}) -> case I of null -> scan_mlt2(T, {ATypes#{null => 1}, MLen, Nulls+1, HasStrings}); string when HasStrings -> scan_mlt2(T, {ATypes, max(byte_size(V),MLen), Nulls, true}); string -> scan_mlt2(T, {ATypes#{string => 1}, max(byte_size(V),MLen), Nulls, true}); Type -> scan_mlt2(T, {ATypes#{Type => 1}, max(byte_size(V),MLen), Nulls, HasStrings}) end; scan_mlt2([[]|_], Acc) -> Acc; scan_mlt2([], Acc) -> Acc. %%------------------------------------------------------------------------------ %% @doc Load CSV data from File to MySQL database %% NOTE: this function requires https://github.com/mysql-otp/mysql-otp.git %% @end %%------------------------------------------------------------------------------ -spec load_to_mysql(File::string(), Tab::string(), MySqlPid::pid(), Opts::[{batch_size, integer()}| {blob_size, integer()}| {save_create_sql_to_file, string()}| {guess_types,boolean()}| {guess_limit_rows, integer()}| {max_nulls_pcnt, float()}| {primary_key,PKColumns::binary()|[binary()|list()]}| {encoding, string()|atom()}| {verbose, boolean()}]) -> {Columns::list(), RecCount::integer()}. load_to_mysql(File, Tab, MySqlPid, Opts) when is_list(File), is_list(Tab), is_pid(MySqlPid), is_list(Opts) -> BatSz = proplists:get_value(batch_size, Opts, 100), % Insert this many records per call BlobSz = proplists:get_value(blob_size, Opts, 1000),% Length at which VARCHAR becomes BLOB Enc = encoding(proplists:get_value(encoding, Opts, undefined)), Verbose= proplists:get_value(verbose, Opts, false), PKey = case proplists:get_value(primary_key, Opts, undefined) of K when is_binary(K) -> [K]; [H|_] = K when is_binary(H); is_list(H) -> [to_binary(I) || I <- K]; undefined -> []; [] -> []; Other -> throw({badarg, {primary_key, Other}}) end, CSV0 = parse(File, [fix_lengths, binary]), ColCnt = length(hd(CSV0)), CSV1 = [[list_to_binary(cleanup_header(to_string(S))) || S <- hd(CSV0)] | tl(CSV0)], Heads = hd(CSV1), PKey /= [] andalso lists:foreach(fun(K) -> lists:member(K, Heads) orelse throw({primary_key_not_found, K, Heads}) end, PKey), Merge = fun M([], []) -> []; M([H|T1], [{T,I,J}|T2]) -> [{H,T,I,J}|M(T1,T2)]; M([H|T1], [I|T2]) when is_integer(I) -> [{H,string,I,0}|M(T1,T2)] end, {ColNmTpMxLens, CSV} = case proplists:get_value(guess_types, Opts, false) of true -> SniffRows = proplists:get_value(guess_limit_rows, Opts, 1_000_000), MaxNullsPcnt = proplists:get_value(max_nulls_pcnt, Opts, 40.0), {TLN, CSV2} = guess_data_types(true, CSV1, MaxNullsPcnt, SniffRows), {Merge(Heads, TLN), CSV2}; false -> {Merge(Heads, max_field_lengths(true, CSV1)), CSV1} end, Verbose andalso io:format(standard_error, "Columns:\n ~p\n", [ColNmTpMxLens]), TmpTab = Tab ++ "_tmp", OldTab = Tab ++ "_OLD", CrTab = lists:flatten([ "DROP TABLE IF EXISTS `", TmpTab, "`;\n", "CREATE TABLE `", TmpTab, "` (", string:join([case T of _ when I > BlobSz -> io_lib:format("`~s` BLOB", [to_string(S)]); date -> io_lib:format("`~s` DATE", [to_string(S)]); datetime -> io_lib:format("`~s` DATETIME", [to_string(S)]); integer -> io_lib:format("`~s` BIGINT", [to_string(S)]); float -> io_lib:format("`~s` DOUBLE", [to_string(S)]); number -> io_lib:format("`~s` DOUBLE", [to_string(S)]); _ -> io_lib:format("`~s` VARCHAR(~w)", [to_string(S),I]) end || {S,T,I,_} <- ColNmTpMxLens], ","), ");\n"]), case proplists:get_value(save_create_sql_to_file, Opts, false) of false -> ok; true -> SQLF = filename:join(filename:dirname(File), filename:basename(File, ".csv") ++ ".sql"), ok = file:write_file(SQLF, CrTab); SQLF when is_list(SQLF) -> ok = file:write_file(SQLF, CrTab) end, Verbose andalso io:format(standard_error, "SQL:\n====\n~s\n", [CrTab]), case mysql:query(MySqlPid, CrTab) of ok -> ok; {error, {Code, _, Msg}} -> throw({error_creating_table, Code, binary_to_list(Msg), CrTab}) end, [HD|Rows] = CSV, [_|QQQ0s] = string:copies(",?", ColCnt), HHHs = string:join(["`"++binary_to_list(S)++"`" || S <- HD], ","), QQQs = lists:append([",(", QQQ0s, ")"]), BatchRows = stringx:batch_split(BatSz, Rows), FstBatLen = length(hd(BatchRows)), if Enc /= [] -> Verbose andalso io:format(standard_error, "SQL:\n====\n~s\n", [Enc]), ok = mysql:query(MySqlPid, Enc); true -> ok end, PfxSQL = lists:append(["INSERT INTO ", TmpTab, " (", HHHs, ") VALUES "]), [_|SfxSQL]= string:copies(QQQs, FstBatLen), Verbose andalso io:format(standard_error, "SQL:\n====\n~s~s\n", [PfxSQL, tl(QQQs)]), {ok,Ref} = mysql:prepare(MySqlPid, PfxSQL++SfxSQL), lists:foldl(fun(Batch, I) -> NRows = length(Batch), {SqlRef, Unprepare} = if NRows == FstBatLen -> {Ref, false}; true -> [_|Sfx2] = string:copies(QQQs, NRows), {ok,R} = mysql:prepare(MySqlPid, PfxSQL++Sfx2), {R, true} end, Row = lists:append(Batch), Res = mysql:execute(MySqlPid, SqlRef, Row), Unprepare andalso mysql:unprepare(MySqlPid, SqlRef), case Res of ok -> I + NRows; {error, {_Code, _, _Msg}} -> SQL2 = PfxSQL++tl(QQQs), try lists:foldl(fun(R, J) -> case mysql:query(MySqlPid, SQL2, R) of ok -> J+1; {error, {Code1, _, Msg1}} -> throw({error_inserting_records, Code1, binary_to_list(Msg1), {row, J, R}}) end end, I, Batch) catch _:E -> mysql:unprepare(MySqlPid, Ref), throw(E) end end end, 1, BatchRows), mysql:unprepare(MySqlPid, Ref), PKSql = case PKey of [] -> ""; _ -> SS = string:join(["`"++binary_to_list(S)++"`" || S <- PKey], ","), io_lib:format("ALTER TABLE `~s` ADD PRIMARY KEY (~s);\n", [TmpTab, SS]) end, SQL = lists:flatten( io_lib:format("DROP TABLE IF EXISTS `~s`;\n" "~s" "-- Check if the real prod table exists\n" "SELECT count(*) INTO @exists\n" " FROM information_schema.tables\n" " WHERE table_schema=database() AND\n" " table_name='~s';\n" "-- If so, atomically rename it to another table,\n" "-- rename temp table to prod table, and drop the old table\n" "-- Otherwise, just atomically rename the temp table into prod\n" "SET @query = IF(@exists>0,\n" " 'RENAME TABLE `~s` TO `~s`, `~s` TO `~s`',\n" " 'RENAME TABLE `~s` TO `~s`');\n" "PREPARE stmt FROM @query;\n" "EXECUTE stmt;\n" "DEALLOCATE PREPARE stmt;\n" "DROP TABLE IF EXISTS `~s`;\n", [OldTab, PKSql, Tab, Tab, OldTab, TmpTab, Tab, TmpTab, Tab, OldTab])), Verbose andalso io:format(standard_error, "SQL:\n====\n~s\n", [SQL]), ok = mysql:query(MySqlPid, SQL), {HD, length(CSV)-1}. encoding(undefined) -> []; encoding(A) when is_atom(A) -> encoding2(atom_to_list(A)); encoding(L) when is_list(L) -> encoding2(L). encoding2(L) -> ["SET NAMES ", L, ";\n", encoding3(L)]. encoding3("utf8"++_) -> "SET CHARACTER SET utf8;\n"; encoding3(Other) -> ["SET CHARACTER SET ", Other]. to_string(L) when is_binary(L) -> binary_to_list(L); to_string(L) when is_list(L) -> L. to_binary(I) when is_binary(I) -> I; to_binary(L) when is_list(L) -> list_to_binary(L). cleanup_header([$ |T]) -> [$_|cleanup_header(T)]; cleanup_header([C|T]) when (C >= $a andalso C =< $z); (C >= $A andalso C =< $Z); (C >= $0 andalso C =< $9); (C == $_) -> [C|cleanup_header(T)]; cleanup_header([_|T]) -> cleanup_header(T); cleanup_header([]) -> []. -spec guess_data_type(binary()) -> {null | date | datetime | integer | float | string, term(), string()}. guess_data_type(S) -> guess_data_type2(S, false). guess_data_type2(<<"">>, _) -> {null, null, <<"">>}; guess_data_type2(S, true) -> {string, S, S}; guess_data_type2(S, _) -> guess_data_type3(S). guess_data_type3(<> = S) when C >= $0, C =< $9 ; C == $-; C == $+ -> try I = binary_to_integer(S), %case binary_to_integer(S) of % I when I < -2147483648; I > 2147483647 -> % {integer, I, S}; % I -> % {integer, I, S} %end; {integer, I, S} catch _:_ -> try F = binary_to_float(S), {float, F, S} catch _:_ -> guess_data_type4(S) end end; guess_data_type3(S) -> guess_data_type4(S). guess_data_type4(<> = V) when Y1 >= $0, Y1 =< $9, Y2 >= $0, Y2 =< $9, Y3 >= $0, Y3 =< $9, Y4 >= $0, Y4 =< $9 , M1 >= $0, M1 =< $9, M2 >= $0, M2 =< $9 , D1 >= $0, D1 =< $9, D2 >= $0, D2 =< $9 -> Y = i(Y1)*1000 + i(Y2)*100 + i(Y3)*10 + i(Y4), M = i(M1)*10 + i(M2), D = i(D1)*10 + i(D2), if Y < 1000; Y > 2500; M < 1; M > 12; D < 1; D > 31 -> def_type(V); true -> case V of <<_:10/binary, C,H1,H2,$:,N1,N2,$:,S1,S2, _/binary>> when (C == $ orelse C == $T) , H1 >= $0, H1 =< $9, H2 >= $0, H2 =< $9 , N1 >= $0, N1 =< $9, N2 >= $0, N2 =< $9 , S1 >= $0, S1 =< $9, S2 >= $0, S2 =< $9 -> H = i(H1)*10 + i(H2), N = i(N1)*10 + i(N2), S = i(S1)*10 + i(S2), if H < 0; H > 23; N < 0; N > 59; S < 0; S > 59 -> def_type(V); true -> {datetime, {{Y,M,D},{H,N,S}}, V} end; _ when byte_size(V) == 10 -> {date, {Y,M,D}, V}; _ -> def_type(V) end end; guess_data_type4(S) -> def_type(S). def_type(V) -> {string, V, V}. i(C) -> C - $0. %%------------------------------------------------------------------------------ %% Tests %%------------------------------------------------------------------------------ -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). parse_test() -> ?assertEqual([<<>>], parse_line(<<"\n">>)), ?assertEqual([<<>>], parse_line(<<"\r\n">>)), ?assertEqual([<<>>], parse_line(<<"\n\r">>)), ?assertEqual([<<>>], parse_line(<<"\n\r\n">>)), ?assertEqual([<<"a">>], parse_line(<<"a\r\n">>)), ?assertEqual([<<>>,<<>>], parse_line(<<",\r\n">>)), ?assertEqual([<<>>,<<>>], parse_line(<<",">>)), ?assertEqual([<<>>,<<"a">>],parse_line(<<",a">>)), ?assertEqual([<<"a">>,<<"b \r\nbb">>,<<"c">>],parse_line(<<"\"a\",\"b \r\nbb\",\"c\"\r\n">>)), ?assertEqual([<<"a\"b">>,<<"c\"">>], parse_line(<<"\"a\"\"b\",\"c\"\"\"">>)), ?assertEqual([<<"a,b">>], parse_line(<<"\"a\,b\"">>)), Lines = [<<"a,bb,ccc">>, <<",b,c">>, <<",b,\"c,d\"">>, <<"\"c,d\"\r">>, <<"xx,yyy,zzzz">>, <<"xxx,yyyy,zzzzz">>, <<"a,b\n">>, <<"x,y\r">>, <<"z\r\n">>], CSV = [parse_line(L) || L <- Lines], Res = [[<<"a">>, <<"bb">>, <<"ccc">>], [<<"">>, <<"b">>, <<"c">>], [<<"">>, <<"b">>, <<"c,d">>], [<<"c,d">>], [<<"xx">>, <<"yyy">>, <<"zzzz">>], [<<"xxx">>, <<"yyyy">>, <<"zzzzz">>], [<<"a">>,<<"b">>], [<<"x">>,<<"y">>], [<<"z">>] ], ?assertEqual(Res, CSV). max_lens_test() -> Lines = [[<<"a">>, <<"bb">>, <<"ccc">>], [<<"xx">>, <<"yyy">>, <<"zzzz">>], [<<"xxx">>, <<"yyyy">>, <<"zzzzz">>]], Res = max_field_lengths(true, Lines), ?assertEqual([3,4,5], Res). guess_type_test() -> ?assertEqual({integer, 1, <<"1">>}, guess_data_type(<<"1">>)), ?assertEqual({float, 1.0, <<"1.0">>}, guess_data_type(<<"1.0">>)), ?assertEqual({date, {2021,1,1}, <<"2021-01-01">>}, guess_data_type(<<"2021-01-01">>)), ?assertEqual({datetime,{{2021,1,1},{0,0,0}}, <<"2021-01-01 00:00:00">>}, guess_data_type(<<"2021-01-01 00:00:00">>)), ?assertEqual({datetime,{{2021,1,1},{0,0,0}}, <<"2021-01-01 00:00:00+01:00">>}, guess_data_type(<<"2021-01-01 00:00:00+01:00">>)), ?assertEqual({datetime,{{2021,1,1},{0,0,0}}, <<"2021-01-01 00:00:00-01:00">>}, guess_data_type(<<"2021-01-01 00:00:00-01:00">>)), ?assertEqual({string, <<"abc">>, <<"abc">>}, guess_data_type(<<"abc">>)), ?assertEqual({null, null, <<"">>}, guess_data_type(<<"">>)). col_types_test() -> LineS = [["a", "b", "c", "d", "e", "f", "g", "h", "i"], ["1", "1.0", "1.0", "2021-01-02", "2021-01-02", "abc", "1", "1.0","2021-01-02"], ["1", "2.0", "3.0", "2021-03-02", "2021-03-02", "abc", "1", "3.0","2023-01-02"], ["1", "2.0", "3.0", "2021-03-02", "2021-03-02", "abc", "1", "3.0","2023-01-02"], ["1", "2.0", "3.0", "2021-03-02", "2021-03-02", "abc", "1", "3.0","2023-01-02"], ["2", "2", "2.0", "2021-02-03", "2021-01-02 00:01:02","efg", "", "", ""]], Lines = [[list_to_binary(I) || I <- Row] || Row <- LineS], Res = guess_data_types(true, Lines), ?assertEqual({[{integer, 1,0}, {number, 3,0}, {float, 3,0}, {date, 10,0}, {datetime,19,0}, {string, 3,0}, {integer, 1,1}, {float, 3,1}, {date, 10,1}], [ [<<"a">>,<<"b">>,<<"c">>,<<"d">>,<<"e">>,<<"f">>,<<"g">>,<<"h">>,<<"i">>], [1,1.0,1.0,{2021,01,02},{2021,01,02}, <<"abc">>, 1, 1.0, {2021,01,02}], [1,2.0,3.0,{2021,03,02},{2021,03,02}, <<"abc">>, 1, 3.0, {2023,01,02}], [1,2.0,3.0,{2021,03,02},{2021,03,02}, <<"abc">>, 1, 3.0, {2023,01,02}], [1,2.0,3.0,{2021,03,02},{2021,03,02}, <<"abc">>, 1, 3.0, {2023,01,02}], [2,2, 2.0,{2021,02,03},{{2021,01,02},{0,1,2}}, <<"efg">>, null, null, null] ]}, Res), Res2 = guess_data_types(true, Lines, 10.0, 5), ?assertEqual({[{integer, 1,0}, {number, 3,0}, {float, 3,0}, {date, 10,0}, {datetime,19,0}, {string, 3,0}, {string, 1,1}, {string, 3,1}, {string, 10,1}], [ [<<"a">>,<<"b">>,<<"c">>,<<"d">>,<<"e">>,<<"f">>,<<"g">>,<<"h">>,<<"i">>], [1,1.0,1.0,{2021,01,02},{2021,01,02}, <<"abc">>, 1, 1.0, {2021,01,02}], [1,2.0,3.0,{2021,03,02},{2021,03,02}, <<"abc">>, 1, 3.0, {2023,01,02}], [1,2.0,3.0,{2021,03,02},{2021,03,02}, <<"abc">>, 1, 3.0, {2023,01,02}], [1,2.0,3.0,{2021,03,02},{2021,03,02}, <<"abc">>, 1, 3.0, {2023,01,02}], [2,2, 2.0,{2021,02,03},{{2021,01,02},{0,1,2}}, <<"efg">>, null, null, null] ]}, Res2). -endif.