lib/dbf/database.ex

defmodule DBF.Database do
  alias DBF.Memo
  alias DBF.DatabaseError
  defstruct [
    :device,
    :filename,
    :memo_file,
    version: 0,
    options: [],
    last_updated: Date.from_erl!({1900, 01, 01}),
    number_of_records: 0,
    header_bytes: 0,
    record_bytes: 0,
    fields: [],
    position: 0
  ]
  @type t :: %__MODULE__{
    device: pid() | {:file_descriptor, atom(), any()},
    filename: String.t(),
    memo_file: Memo.t() | false,
    version: byte(),
    options: DBF.options(),
    last_updated: Date.t(),
    number_of_records: integer,
    header_bytes: integer,
    record_bytes: integer,
    fields: [DBF.Field.t()],
    position: integer
  }

  @versions %{
    0x02 => "FoxBase",
    0x03 => "dBase III without memo file",
    0x04 => "dBase IV without memo file",
    0x05 => "dBase V without memo file",
    0x07 => "Visual Objects 1.x",
    0x30 => "Visual FoxPro",
    0x31 => "Visual FoxPro with AutoIncrement field",
    0x32 => "Visual FoxPro with field type Varchar or Varbinary",
    0x43 => "dBASE IV SQL table files, no memo",
    0x63 => "dBASE IV SQL system files, no memo",
    0x7b => "dBase IV with memo file",
    0x83 => "dBase III with memo file",
    0x87 => "Visual Objects 1.x with memo file",
    0x8b => "dBase IV with memo file",
    0x8e => "dBase IV with SQL table",
    0xcb => "dBASE IV SQL table files, with memo",
    0xf5 => "FoxPro with memo file",
    0xe5 => "HiPer-Six format with SMT memo file",
    0xfb => "FoxPro without memo file"
  }
  @foxpro_versions [0x30, 0x31, 0x32, 0xf5, 0xfb]
  @supported_versions [0x02, 0x03, 0x83, 0x8b]

  def open_database(%__MODULE__{}=db) do
    with {:ok, db} <- read_version(db),
         {:ok, db} <- read_header(db)
    do
      {:ok, db}
    end
  end

  defp read_header(%__MODULE__{version: 0x02, device: device}=db) do
    {:ok, data} = :file.pread(device, 0, 8)

    <<
      _version::unsigned-integer-8,
      records::little-unsigned-integer-16,
      _unknown::binary-size(3),
      record_length::little-unsigned-integer-16,
    >> = data

    {:ok, %__MODULE__{ db |
      last_updated: Date.from_erl!({1900, 01, 01}),
      number_of_records: records,
      header_bytes: 521,
      record_bytes: record_length
    } }
  end

  defp read_header(%__MODULE__{device: device}=db) do
    {:ok, data} = :file.pread(device, 0, 32)

    <<
      _version::unsigned-integer-8,
      year::unsigned-integer-8,
      month::unsigned-integer-8,
      day::unsigned-integer-8,
      records::little-unsigned-integer-32,
      header_length::little-unsigned-integer-16,
      record_length::little-unsigned-integer-16,
      _reserved1::binary-size(2),
      _transaction::binary-size(1),
      _reserved2::binary-size(12),
      _table_flags::binary-size(1),
      _code_page_mark::binary-size(1),
      _reserved3::binary-size(2),
      _header_terminator::binary-size(1)
    >> = data

    {:ok, %__MODULE__{ db |
      last_updated: Date.from_erl!({year + 1900, month, day}),
      number_of_records: records,
      header_bytes: header_length,
      record_bytes: record_length
    } }
  end


  @doc false
  @spec foxpro?(DBF.Database.t()) :: boolean()
  def foxpro?(%__MODULE__{version: version}) do
    version in @foxpro_versions
  end

  @doc false
  @spec well_known_version?(DBF.Database.t()) :: boolean()
  def well_known_version?(%__MODULE__{version: version}) do
    Map.has_key?(@versions, version)
  end

  defp read_version(db) do
    {:ok, <<version::unsigned-integer-8>>} = :file.pread(db.device, 0, 1)
    if version in @supported_versions do
      {:ok, %__MODULE__{db | version: version}}
    else
      version_string = Map.get(@versions, version, Integer.to_string(version))
      {:error, DatabaseError.new(:unsupported_version, version_string)}
    end
  end

end

# Define the Enumerable implentation for the database.
defimpl Enumerable, for: DBF.Database do
  @spec count(DBF.Database.t()) :: {:ok, any()}
  def count(db) do
    {:ok, db.number_of_records}
  end

  @spec reduce(DBF.Database.t(), {:cont, any()} | {:halt, any()} | {:suspend, any()}, any()) ::
          {:done, any()}
          | {:halted, any()}
          | {:suspended, any(), ({any(), any()} -> {any(), any()} | {any(), any(), any()})}
  def reduce(db, {:cont, acc}, fun) do
    if (db.position == db.number_of_records) do
      {:done, acc}
    else
      record = DBF.get(db, db.position)
    reduce(Map.put(db, :position, db.position + 1), fun.(record, acc), fun)
    end
  end

  def reduce(_db, {:halt, acc}, _fun) do
    {:halted, acc}
  end

  def reduce(db, {:suspend, acc}, fun) do
    {:suspended, acc, &reduce(db, &1, fun)}
  end

  @spec slice(DBF.Database.t()) :: {:error, Enumerable.DBF.Database}
  def slice(_array) do
    {:error, __MODULE__}
  end

  @spec member?(DBF.Database.t(), any()) :: {:error, Enumerable.DBF.Database}
  def member?(_array, _element) do
    {:error, __MODULE__}
  end

end