Skip to content

Commit bc9fc51

Browse files
authored
Résumé de validation IRVE (#5429)
1 parent e9288b1 commit bc9fc51

3 files changed

Lines changed: 185 additions & 1 deletion

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
defmodule Transport.IRVE.Validator.Summary do
2+
@moduledoc """
3+
Struct representing the human-actionable summary produced after IRVE validation.
4+
"""
5+
6+
@enforce_keys [
7+
:valid,
8+
:valid_row_count,
9+
:invalid_row_count,
10+
:total_row_count,
11+
:file_level_errors,
12+
:column_errors,
13+
:error_samples
14+
]
15+
16+
defstruct [
17+
:valid,
18+
:valid_row_count,
19+
:invalid_row_count,
20+
:total_row_count,
21+
:file_level_errors,
22+
:column_errors,
23+
:error_samples
24+
]
25+
end

apps/transport/lib/irve/validator/validator.ex

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ defmodule Transport.IRVE.Validator do
1818
so files that are not strictly valid may be considered as valid without any notice by this function.
1919
If you want to call a strict validator (no preprocessing), use `compute_validation/1` instead.
2020
"""
21-
2221
def validate(path, extension \\ ".csv") do
2322
# NOTE: for now, load the body in memory, because refactoring to get full streaming
2423
# is too involved for the current sprint deadline.
@@ -40,4 +39,92 @@ defmodule Transport.IRVE.Validator do
4039
df["check_row_valid"]
4140
|> Explorer.Series.all?()
4241
end
42+
43+
@doc """
44+
Produces a human-actionable summary from the validated DataFrame returned by `compute_validation/1`.
45+
Still an Elixir map, but the content should allow someone to know how to correct an invalid file.
46+
47+
Returns a map with:
48+
- `valid` - whether the file is fully valid
49+
- `valid_row_count` - number of valid rows
50+
- `invalid_row_count` - number of invalid rows
51+
- `column_errors` - map of field name => number of invalid rows for that column (only columns with at least one error)
52+
- `error_samples` - up to 5 sample errors per errored column, each with `id_pdc_itinerance`, `column`, and `value`
53+
"""
54+
def summarize(%Explorer.DataFrame{} = df) do
55+
{valid_count, invalid_count} = summarize_total_counts(df)
56+
column_errors = summarize_column_errors(df)
57+
error_samples = error_samples(df, column_errors)
58+
59+
%Transport.IRVE.Validator.Summary{
60+
valid: invalid_count == 0,
61+
valid_row_count: valid_count,
62+
invalid_row_count: invalid_count,
63+
total_row_count: valid_count + invalid_count,
64+
file_level_errors: [],
65+
column_errors: column_errors,
66+
error_samples: error_samples
67+
}
68+
end
69+
70+
@doc """
71+
Combines `validate/2` and `summarize/1` into a single call, catching any file-level error
72+
(bad encoding, wrong format, missing columns, etc.) instead of raising.
73+
74+
Returns the same map as `summarize/1` with an additional `file_level_error` key:
75+
- `nil` when validation ran successfully (the file could still be invalid row-by-row)
76+
- an error message string when a hard file-level error was caught
77+
78+
On a file-level error, `valid_row_count`, `invalid_row_count`, `column_errors`, and
79+
`error_samples` are all `nil`/empty since there is no DataFrame to summarize from.
80+
"""
81+
def validate_and_summarize(path, extension \\ ".csv") do
82+
path
83+
|> validate(extension)
84+
|> summarize()
85+
rescue
86+
error ->
87+
%Transport.IRVE.Validator.Summary{
88+
valid: false,
89+
valid_row_count: nil,
90+
invalid_row_count: nil,
91+
total_row_count: nil,
92+
file_level_errors: [Exception.message(error)],
93+
column_errors: %{},
94+
error_samples: []
95+
}
96+
end
97+
98+
defp summarize_total_counts(df) do
99+
valid_count = df["check_row_valid"] |> Explorer.Series.sum()
100+
invalid_count = Explorer.DataFrame.n_rows(df) - valid_count
101+
{valid_count, invalid_count}
102+
end
103+
104+
defp summarize_column_errors(df) do
105+
Transport.IRVE.StaticIRVESchema.field_names_list()
106+
|> Enum.map(fn field_name -> {field_name, "check_column_#{field_name}_valid"} end)
107+
|> Enum.flat_map(fn {field_name, check_col} ->
108+
error_count = df[check_col] |> Explorer.Series.not() |> Explorer.Series.sum()
109+
if error_count > 0, do: [{field_name, error_count}], else: []
110+
end)
111+
|> Map.new()
112+
end
113+
114+
defp error_samples(df, column_errors) do
115+
column_errors
116+
|> Enum.flat_map(fn {field_name, _error_count} ->
117+
check_col = "check_column_#{field_name}_valid"
118+
119+
df
120+
|> Explorer.DataFrame.filter_with(&(&1[check_col] |> Explorer.Series.not()))
121+
|> Explorer.DataFrame.select(["id_pdc_itinerance", field_name])
122+
# Limit to 5 samples per error column
123+
|> Explorer.DataFrame.head(5)
124+
|> Explorer.DataFrame.to_rows()
125+
|> Enum.map(fn row ->
126+
%{id_pdc_itinerance: row["id_pdc_itinerance"], column: field_name, value: row[field_name]}
127+
end)
128+
end)
129+
end
43130
end

apps/transport/test/transport/irve/validation/validator_test.exs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,4 +138,76 @@ defmodule Transport.IRVE.ValidatorTest do
138138
assert Transport.IRVE.Validator.full_file_valid?(result)
139139
end)
140140
end
141+
142+
test "summarize/1 returns a valid summary for a valid file" do
143+
csv_content = [DB.Factory.IRVE.generate_row()] |> DB.Factory.IRVE.to_csv_body()
144+
145+
with_tmp_file(csv_content, fn path ->
146+
summary = Transport.IRVE.Validator.validate(path) |> Transport.IRVE.Validator.summarize()
147+
148+
assert %Transport.IRVE.Validator.Summary{
149+
valid: true,
150+
valid_row_count: 1,
151+
invalid_row_count: 0,
152+
total_row_count: 1,
153+
file_level_errors: [],
154+
column_errors: %{},
155+
error_samples: []
156+
} == summary
157+
end)
158+
end
159+
160+
test "summarize/1 reports column errors and caps error samples to 5 per column" do
161+
# 10 rows with invalid puissance_nominale, 1 row with invalid nbre_pdc (valid puissance_nominale)
162+
invalid_puissance_rows = for _ <- 1..10, do: DB.Factory.IRVE.generate_row(%{"puissance_nominale" => "not-a-number"})
163+
invalid_nbre_pdc_row = DB.Factory.IRVE.generate_row(%{"nbre_pdc" => "not-a-number"})
164+
csv_content = (invalid_puissance_rows ++ [invalid_nbre_pdc_row]) |> DB.Factory.IRVE.to_csv_body()
165+
166+
with_tmp_file(csv_content, fn path ->
167+
summary = Transport.IRVE.Validator.validate(path) |> Transport.IRVE.Validator.summarize()
168+
169+
assert summary.valid == false
170+
assert summary.valid_row_count == 0
171+
assert summary.invalid_row_count == 11
172+
assert summary.column_errors == %{"puissance_nominale" => 10, "nbre_pdc" => 1}
173+
174+
# 5 samples from puissance_nominale (capped) + 1 from nbre_pdc
175+
assert length(summary.error_samples) == 6
176+
177+
assert summary.error_samples
178+
|> Enum.filter(&(&1.column == "puissance_nominale"))
179+
|> Enum.all?(&(&1.value == "not-a-number"))
180+
181+
assert [%{column: "nbre_pdc", value: "not-a-number", id_pdc_itinerance: _}] =
182+
Enum.filter(summary.error_samples, &(&1.column == "nbre_pdc"))
183+
end)
184+
end
185+
186+
test "validate_and_summarize/1 returns a normal summary with file_level_error: nil for a valid file" do
187+
csv_content = [DB.Factory.IRVE.generate_row()] |> DB.Factory.IRVE.to_csv_body()
188+
189+
with_tmp_file(csv_content, fn path ->
190+
summary = Transport.IRVE.Validator.validate_and_summarize(path)
191+
192+
assert %{valid: true, valid_row_count: 1, invalid_row_count: 0, file_level_errors: []} = summary
193+
end)
194+
end
195+
196+
test "validate_and_summarize/1 returns an error summary instead of raising on a file-level error" do
197+
with_tmp_file("PK\x03\x04" <> "some content", fn path ->
198+
summary = Transport.IRVE.Validator.validate_and_summarize(path)
199+
200+
assert %{
201+
valid: false,
202+
valid_row_count: nil,
203+
invalid_row_count: nil,
204+
total_row_count: nil,
205+
file_level_errors: [error_message],
206+
column_errors: %{},
207+
error_samples: []
208+
} = summary
209+
210+
assert error_message =~ "zip"
211+
end)
212+
end
141213
end

0 commit comments

Comments
 (0)