defmodule DBF do alias DBF.Database alias DBF.Field alias DBF.Record alias DBF.Memo alias DBF.DatabaseError @type options() :: [ memo_file: String.t() | nil # allow_missing_memo: boolean() ] @default_options [ memo_file: nil # allow_missing_memo: false ] @moduledoc """ Read DBASE files in Elixir. At the moment it only supports read. ## Usage Open a file with open/1 or open/2 ```elixir {:ok, db} = DBF.open("test/dbf_files/bayarea_zipcodes.dbf") ``` The resulting DB follows the enumerable protocol, so you can use all the functions in the Enum module. So to get all the records of a database you can do: ```elixir db |> Enum.to_list() ``` The result will be a tuple ´{status, %{...}}´ with the record status being either :record or :deleted_record. You can get specific rows by using the `DBF.get/2` function. ```elixir case DBF.get(db, 2) do {:record, row} -> IO.inspect row {:deleted_record, row} -> IO.inspect row {:error, _} -> IO.puts "OMG" end ``` """ @doc """ Open a DBase database file. """ @spec open(String.t()) :: {:ok, Database.t()} | {:error, Error.t()} @spec open(String.t(), options()) :: {:ok, Database.t()} | {:error, atom()} def open(filename, options \\ []) when is_binary(filename) do with {:ok, db} <- create_database_struct(filename, options), {:ok, db} <- Database.open_database(db), {:ok, db} <- open_memo_file(db), {:ok, db} <- Field.parse_fields(db) do {:ok, db} end end @doc """ Same as `open/2` but throws errors """ @spec open!(String.t(), options()) :: Database.t() @spec open!(binary()) :: Database.t() def open!(filename, options \\ []) when is_binary(filename) do case open(filename, options) do {:ok, db} -> db {:error, error} -> raise error end end @doc """ Closes the file access. """ @spec close(Database.t()) :: :ok | {:error, atom()} def close(%Database{device: dev}=db) when is_struct(db, Database) do if db.memo_file do File.close(db.memo_file.device) end File.close(dev) end @doc """ Get a record by number. """ @spec get(Database.t(), integer()) :: {:deleted_record, map()} | {:record, map()} | {:unknown, map()} def get(%Database{number_of_records: total}, record_number) when record_number >= total do {:error, :record_not_found} end def get(%Database{device: dev, record_bytes: record_bytes, header_bytes: header_bytes } = db, record_number) do offset = header_bytes + record_number * record_bytes {:ok, <>} = :file.pread(dev, offset, record_bytes) type = case raw_type do " " -> :record "*" -> :deleted_record _ -> :unknown end {type, Record.parse_record(db, data)} end @spec has_memo_file?(Database.t()) :: boolean() def has_memo_file?(%Database{memo_file: nil}), do: false def has_memo_file?(%Database{memo_file: _}), do: true defp open_memo_file(%Database{version: version}=db) do case search_memo_file(db) do nil -> {:ok, db} memo_filename -> {:ok, memo_file} = Memo.open(memo_filename, version) {:ok, %Database{db | memo_file: memo_file} } end end @spec search_memo_file(Database.t()) :: String.t() | nil defp search_memo_file(db) when is_struct(db) do case options(db, :memo_file) do nil -> search_memo_file_wildly(db.filename) memo_filename when is_binary(memo_filename) -> memo_filename end end defp search_memo_file_wildly(filename) do search_path = (filename |> Path.rootname() ) <> ".{fpt,FPT,dbt,DBT}" case Path.wildcard(search_path) do [] -> nil memo_file_list when is_list(memo_file_list) -> hd(memo_file_list) end end @doc false @spec options(DBF.Database.t(), atom()) :: any() def options(%Database{options: options}, key) do if Keyword.has_key?(options, key) do Keyword.get(options, key) else Keyword.get(@default_options, key) end end defp create_database_struct(filename, options) do with {:ok, file} <- File.open(filename, [:read, :binary]), {:ok, validated_options} <- validate_options(options) do {:ok, %Database{filename: filename, device: file, options: validated_options} } end end defp validate_options(option) do # TODO: This needs to be fixed to be modern case Keyword.validate(option, @default_options) do {:ok, result} -> {:ok, result} {:error, _} -> {:error, DatabaseError.new(:invalid_option)} end end end