Merge pull request 'Implements csv service skeleton closes #330' (#350) from feature/330_import_service_skeleton into main
Reviewed-on: #350
This commit is contained in:
commit
448a032878
2 changed files with 288 additions and 0 deletions
158
lib/mv/membership/import/member_csv.ex
Normal file
158
lib/mv/membership/import/member_csv.ex
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
defmodule Mv.Membership.Import.MemberCSV do
|
||||
@moduledoc """
|
||||
Service module for importing members from CSV files.
|
||||
|
||||
This module provides the core API for CSV member import functionality:
|
||||
- `prepare/2` - Parses and validates CSV content, returns import state
|
||||
- `process_chunk/3` - Processes a chunk of rows and creates members
|
||||
|
||||
## Error Handling
|
||||
|
||||
Errors are returned as `%MemberCSV.Error{}` structs containing:
|
||||
- `csv_line_number` - The physical line number in the CSV file (or `nil` for general errors)
|
||||
- `field` - The field name (atom) or `nil` if not field-specific
|
||||
- `message` - Human-readable error message (or `nil` for general errors)
|
||||
|
||||
## Import State
|
||||
|
||||
The `import_state` returned by `prepare/2` contains:
|
||||
- `chunks` - List of row chunks ready for processing
|
||||
- `column_map` - Map of canonical field names to column indices
|
||||
- `custom_field_map` - Map of custom field names to column indices
|
||||
- `warnings` - List of warning messages (e.g., unknown custom field columns)
|
||||
|
||||
## Chunk Results
|
||||
|
||||
The `chunk_result` returned by `process_chunk/3` contains:
|
||||
- `inserted` - Number of successfully created members
|
||||
- `failed` - Number of failed member creations
|
||||
- `errors` - List of `%MemberCSV.Error{}` structs (capped at 50 per import)
|
||||
|
||||
## Examples
|
||||
|
||||
# Prepare CSV for import
|
||||
{:ok, import_state} = MemberCSV.prepare(csv_content)
|
||||
|
||||
# Process first chunk
|
||||
chunk = Enum.at(import_state.chunks, 0)
|
||||
{:ok, result} = MemberCSV.process_chunk(chunk, import_state.column_map)
|
||||
"""
|
||||
|
||||
defmodule Error do
|
||||
@moduledoc """
|
||||
Error struct for CSV import errors.
|
||||
|
||||
## Fields
|
||||
|
||||
- `csv_line_number` - The physical line number in the CSV file (1-based, header is line 1)
|
||||
- `field` - The field name as an atom (e.g., `:email`) or `nil` if not field-specific
|
||||
- `message` - Human-readable error message
|
||||
"""
|
||||
defstruct csv_line_number: nil, field: nil, message: nil
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
csv_line_number: pos_integer() | nil,
|
||||
field: atom() | nil,
|
||||
message: String.t() | nil
|
||||
}
|
||||
end
|
||||
|
||||
@type import_state :: %{
|
||||
chunks: list(list({pos_integer(), map()})),
|
||||
column_map: %{atom() => non_neg_integer()},
|
||||
custom_field_map: %{String.t() => non_neg_integer()},
|
||||
warnings: list(String.t())
|
||||
}
|
||||
|
||||
@type chunk_result :: %{
|
||||
inserted: non_neg_integer(),
|
||||
failed: non_neg_integer(),
|
||||
errors: list(Error.t())
|
||||
}
|
||||
|
||||
@doc """
|
||||
Prepares CSV content for import by parsing, mapping headers, and validating limits.
|
||||
|
||||
This function:
|
||||
1. Strips UTF-8 BOM if present
|
||||
2. Detects CSV delimiter (semicolon or comma)
|
||||
3. Parses headers and data rows
|
||||
4. Maps headers to canonical member fields
|
||||
5. Maps custom field columns by name
|
||||
6. Validates row count limits
|
||||
7. Chunks rows for processing
|
||||
|
||||
## Parameters
|
||||
|
||||
- `file_content` - The raw CSV file content as a string
|
||||
- `opts` - Optional keyword list:
|
||||
- `:max_rows` - Maximum number of data rows allowed (default: 1000)
|
||||
- `:chunk_size` - Number of rows per chunk (default: 200)
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, import_state}` - Successfully prepared import state
|
||||
- `{:error, reason}` - Error reason (string or error struct)
|
||||
|
||||
## Examples
|
||||
|
||||
iex> MemberCSV.prepare("email\\njohn@example.com")
|
||||
{:ok, %{chunks: [...], column_map: %{email: 0}, ...}}
|
||||
|
||||
iex> MemberCSV.prepare("")
|
||||
{:error, "CSV file is empty"}
|
||||
"""
|
||||
@spec prepare(String.t(), keyword()) :: {:ok, import_state()} | {:error, String.t()}
|
||||
def prepare(file_content, opts \\ []) do
|
||||
# TODO: Implement in Issue #3 (CSV Parsing)
|
||||
# This is a skeleton implementation that will be filled in later
|
||||
_ = {file_content, opts}
|
||||
|
||||
# Placeholder return - will be replaced with actual implementation
|
||||
{:error, "Not yet implemented"}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Processes a chunk of CSV rows and creates members.
|
||||
|
||||
This function:
|
||||
1. Validates each row
|
||||
2. Creates members via Ash resource
|
||||
3. Creates custom field values for each member
|
||||
4. Collects errors with correct CSV line numbers
|
||||
5. Returns chunk processing results
|
||||
|
||||
## Parameters
|
||||
|
||||
- `chunk_rows_with_lines` - List of tuples `{csv_line_number, row_map}` where:
|
||||
- `csv_line_number` - Physical line number in CSV (1-based)
|
||||
- `row_map` - Map of column names to values
|
||||
- `column_map` - Map of canonical field names (atoms) to column indices
|
||||
- `opts` - Optional keyword list for processing options
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, chunk_result}` - Chunk processing results
|
||||
- `{:error, reason}` - Error reason (string)
|
||||
|
||||
## Examples
|
||||
|
||||
iex> chunk = [{2, %{"email" => "john@example.com"}}]
|
||||
iex> column_map = %{email: 0}
|
||||
iex> MemberCSV.process_chunk(chunk, column_map)
|
||||
{:ok, %{inserted: 1, failed: 0, errors: []}}
|
||||
"""
|
||||
@spec process_chunk(
|
||||
list({pos_integer(), map()}),
|
||||
%{atom() => non_neg_integer()},
|
||||
keyword()
|
||||
) :: {:ok, chunk_result()} | {:error, String.t()}
|
||||
def process_chunk(chunk_rows_with_lines, column_map, opts \\ []) do
|
||||
# TODO: Implement in Issue #6 (Persistence)
|
||||
# This is a skeleton implementation that will be filled in later
|
||||
_ = {chunk_rows_with_lines, column_map, opts}
|
||||
|
||||
# Placeholder return - will be replaced with actual implementation
|
||||
{:ok, %{inserted: 0, failed: 0, errors: []}}
|
||||
end
|
||||
end
|
||||
130
test/mv/membership/import/member_csv_test.exs
Normal file
130
test/mv/membership/import/member_csv_test.exs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
defmodule Mv.Membership.Import.MemberCSVTest do
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
alias Mv.Membership.Import.MemberCSV
|
||||
|
||||
describe "Error struct" do
|
||||
test "Error struct exists with required fields" do
|
||||
# This will fail at runtime if the struct doesn't exist
|
||||
# We use struct/2 to create the struct at runtime
|
||||
error =
|
||||
struct(MemberCSV.Error, %{
|
||||
csv_line_number: 5,
|
||||
field: :email,
|
||||
message: "is not a valid email"
|
||||
})
|
||||
|
||||
assert error.csv_line_number == 5
|
||||
assert error.field == :email
|
||||
assert error.message == "is not a valid email"
|
||||
end
|
||||
|
||||
test "Error struct allows nil field" do
|
||||
# This will fail at runtime if the struct doesn't exist
|
||||
error =
|
||||
struct(MemberCSV.Error, %{
|
||||
csv_line_number: 10,
|
||||
field: nil,
|
||||
message: "Row is empty"
|
||||
})
|
||||
|
||||
assert error.csv_line_number == 10
|
||||
assert error.field == nil
|
||||
assert error.message == "Row is empty"
|
||||
end
|
||||
end
|
||||
|
||||
describe "prepare/2" do
|
||||
test "function exists and accepts file_content and opts" do
|
||||
file_content = "email\njohn@example.com"
|
||||
opts = []
|
||||
|
||||
# This will fail until the function is implemented
|
||||
result = MemberCSV.prepare(file_content, opts)
|
||||
assert match?({:ok, _}, result) or match?({:error, _}, result)
|
||||
end
|
||||
|
||||
# Skip until Issue #3 is implemented
|
||||
@tag :skip
|
||||
test "returns {:ok, import_state} on success" do
|
||||
file_content = "email\njohn@example.com"
|
||||
opts = []
|
||||
|
||||
assert {:ok, import_state} = MemberCSV.prepare(file_content, opts)
|
||||
|
||||
# Check that import_state contains expected fields
|
||||
assert Map.has_key?(import_state, :chunks)
|
||||
assert Map.has_key?(import_state, :column_map)
|
||||
assert Map.has_key?(import_state, :custom_field_map)
|
||||
assert Map.has_key?(import_state, :warnings)
|
||||
end
|
||||
|
||||
test "returns {:error, reason} on failure" do
|
||||
file_content = ""
|
||||
opts = []
|
||||
|
||||
assert {:error, _reason} = MemberCSV.prepare(file_content, opts)
|
||||
end
|
||||
|
||||
test "function has documentation" do
|
||||
# Check that @doc exists by reading the module
|
||||
assert function_exported?(MemberCSV, :prepare, 2)
|
||||
end
|
||||
end
|
||||
|
||||
describe "process_chunk/3" do
|
||||
test "function exists and accepts chunk_rows_with_lines, column_map, and opts" do
|
||||
chunk_rows_with_lines = [{2, %{"email" => "john@example.com"}}]
|
||||
column_map = %{email: 0}
|
||||
opts = []
|
||||
|
||||
# This will fail until the function is implemented
|
||||
result = MemberCSV.process_chunk(chunk_rows_with_lines, column_map, opts)
|
||||
assert match?({:ok, _}, result) or match?({:error, _}, result)
|
||||
end
|
||||
|
||||
test "returns {:ok, chunk_result} on success" do
|
||||
chunk_rows_with_lines = [{2, %{"email" => "john@example.com"}}]
|
||||
column_map = %{email: 0}
|
||||
opts = []
|
||||
|
||||
assert {:ok, chunk_result} =
|
||||
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, opts)
|
||||
|
||||
# Check that chunk_result contains expected fields
|
||||
assert Map.has_key?(chunk_result, :inserted)
|
||||
assert Map.has_key?(chunk_result, :failed)
|
||||
assert Map.has_key?(chunk_result, :errors)
|
||||
assert is_integer(chunk_result.inserted)
|
||||
assert is_integer(chunk_result.failed)
|
||||
assert is_list(chunk_result.errors)
|
||||
end
|
||||
|
||||
test "returns {:error, reason} on failure" do
|
||||
chunk_rows_with_lines = []
|
||||
column_map = %{}
|
||||
opts = []
|
||||
|
||||
# This might return {:ok, _} with zero counts or {:error, _}
|
||||
result = MemberCSV.process_chunk(chunk_rows_with_lines, column_map, opts)
|
||||
assert match?({:ok, _}, result) or match?({:error, _}, result)
|
||||
end
|
||||
|
||||
test "function has documentation" do
|
||||
# Check that @doc exists by reading the module
|
||||
assert function_exported?(MemberCSV, :process_chunk, 3)
|
||||
end
|
||||
end
|
||||
|
||||
describe "module documentation" do
|
||||
test "module has @moduledoc" do
|
||||
# Check that the module exists and has documentation
|
||||
assert Code.ensure_loaded?(MemberCSV)
|
||||
|
||||
# Try to get the module documentation
|
||||
{:docs_v1, _, _, _, %{"en" => moduledoc}, _, _} = Code.fetch_docs(MemberCSV)
|
||||
assert is_binary(moduledoc)
|
||||
assert String.length(moduledoc) > 0
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue