Skip to content

Commit

Permalink
Enhance validator spec
Browse files Browse the repository at this point in the history
  • Loading branch information
stijn-dejongh committed Nov 22, 2024
1 parent 00088cb commit ce02ec2
Show file tree
Hide file tree
Showing 8 changed files with 224 additions and 58 deletions.
27 changes: 19 additions & 8 deletions dsl/src/main/java/be/sddevelopment/validation/dsl/CsvFile.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,11 @@
import com.opencsv.CSVParserBuilder;
import org.jspecify.annotations.Nullable;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
import java.util.Vector;
import java.util.function.Predicate;
import java.util.stream.Stream;

import static java.nio.file.Files.readAllLines;
Expand Down Expand Up @@ -53,15 +50,22 @@ public Vector<String> line(int lineNumber) {
return lines.get(lineNumber);
}

public static @Nullable CsvFile fromFile(Path dataFile) throws IOException {
return CsvFile.fromLines(readAllLines(dataFile), dataFile.toFile().getName());
}
public static CsvFile fromLines(List<String> lines) throws IOException {
return fromLines(lines, DEFAULT);
}

public static CsvFile fromLines(List<String> lines, String fileIdentifier) throws IOException {
if (lines.isEmpty()) {
throw new IllegalArgumentException("No lines provided. A data file requires at least one line");
}

var header = parseHeader(lines.getFirst());

var dataLines = lines.stream().skip(1).map(ExceptionSuppressor.uncheck(CsvFile::parseLine)).toList();
return new CsvFile(DEFAULT, header, new Vector<>(dataLines));
return new CsvFile(fileIdentifier, header, new Vector<>(dataLines));
}

private static Vector<String> parseHeader(String lineToParse) throws IOException {
Expand All @@ -83,15 +87,22 @@ private static CSVParser createParser() {
.build();
}

public static @Nullable CsvFile fromFile(Path dataFile) throws IOException {
return CsvFile.fromLines(readAllLines(dataFile));
}

public boolean isEmpty() {
return this.lines.isEmpty();
}

public boolean isNotEmpty() {
return !this.isEmpty();
}

public List<String> distinctValuesFor(String field) {
if(!headerFields().contains(field)) {
return List.of();
}

return lines.parallelStream()
.map(line -> line.get(fieldIndex(field)))
.distinct()
.toList();
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
package be.sddevelopment.validation.dsl;

public record ExpectedValue(
public record FieldValue(
String field,
String value
) {

public static ExpectedValue withValue(String field, String value) {
return new ExpectedValue(field, value);
public static FieldValue withValue(String field, String value) {
return new FieldValue(field, value);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,13 @@
import be.sddevelopment.validation.core.Constraint;
import be.sddevelopment.validation.core.ModularRuleset.ModularValidatorBuilder;
import be.sddevelopment.validation.dsl.CsvFile;
import be.sddevelopment.validation.dsl.ExpectedValue;
import be.sddevelopment.validation.dsl.FieldValue;
import be.sddevelopment.validation.dsl.SpecificationParserException;

import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.Vector;
import java.util.function.Function;
import java.util.stream.Collectors;

import static be.sddevelopment.commons.access.AccessProtectionUtils.utilityClassConstructor;
import static java.util.Arrays.stream;
Expand All @@ -23,7 +21,8 @@ public final class CsvValidationRules {

public static final String PARAMETER_SEPARATOR = ",";
private static final List<CsvRuleSpec> KNOWN_RULESPECS = List.of(
new CsvRuleSpec("FieldExists", CsvValidationRules::createFieldExistsRule)
new CsvRuleSpec("Field", CsvValidationRules::createFieldExistsRule),
new CsvRuleSpec("UniqueField", CsvValidationRules::createFieldDistinctnessRule)
);

private CsvValidationRules() {
Expand All @@ -33,7 +32,7 @@ private CsvValidationRules() {
public static Function<ModularValidatorBuilder<CsvFile>, ModularValidatorBuilder<CsvFile>> fromLine(String line)
throws SpecificationParserException {
return knownRuleSpecifications().stream()
.map(spec -> spec.toRule(line))
.map(spec -> spec.toRule(line.trim()))
.flatMap(Optional::stream)
.findFirst()
.orElseThrow(() -> new SpecificationParserException("Unknown rule specification: %s".formatted(line)));
Expand All @@ -44,18 +43,33 @@ public static RuleSpecificationAppender<CsvFile> createFieldExistsRule(String li
return ruleset -> ruleset.must(haveField(field));
}

public static RuleSpecificationAppender<CsvFile> createFieldDistinctnessRule(String line) {
var field = CsvRuleSpec.parametersFrom(line).getFirst();
return ruleset -> ruleset
.must(haveField(field))
.must(haveDistinctValuesFor(field))
;
}

public static Constraint<CsvFile> haveField(String field) {
return new Constraint<>(file -> file.headerFields().contains(field), "Field '%s' must exist in the data file".formatted(field));
}

public static Constraint<CsvFile> containRecord(ExpectedValue identifier, ExpectedValue... expectedValues) {
public static Constraint<CsvFile> haveDistinctValuesFor(String field) {
return new Constraint<>(file ->
file.lines().size() == file.distinctValuesFor(field).size(),
"Field '%s' must have distinct values in the data file".formatted(field)
);
}

public static Constraint<CsvFile> containRecord(FieldValue identifier, FieldValue... fieldValues) {

return new Constraint<>(file -> identifiedBy(identifier).apply(file)
.map(line -> stream(expectedValues).parallel()
.map(line -> stream(fieldValues).parallel()
.allMatch(expected -> expected.value().equals(line.get(file.fieldIndex(expected.field()))))
)
.orElse(false),
"Expected record identified by %s with values [%s]".formatted(identifier.toString(), stream(expectedValues).map(ExpectedValue::toString).collect(joining(" and ")))
"Expected record identified by %s with values [%s]".formatted(identifier.toString(), stream(fieldValues).map(FieldValue::toString).collect(joining(" and ")))
);
}

Expand All @@ -71,7 +85,7 @@ public static ModularValidatorBuilder<CsvFile> addToRuleset(ModularValidatorBuil
return fromLine(ruleToAdd).apply(ruleSet);
}

public static Function<CsvFile, Optional<Vector<String>>> identifiedBy(ExpectedValue identifier) {
public static Function<CsvFile, Optional<Vector<String>>> identifiedBy(FieldValue identifier) {
return file -> file.findLineByFieldValue(identifier.field(), identifier.value());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import com.tngtech.archunit.junit.AnalyzeClasses;
import org.junit.jupiter.api.DisplayNameGeneration;

@AnalyzeClasses(packages = "be.sddevelopment.commons")
@AnalyzeClasses(packages = "be.sddevelopment.validation.dsl")
@DisplayNameGeneration(ReplaceUnderscoredCamelCasing.class)
public class ConventionsAdherenceTests implements CodeConventions {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,16 @@ void failsWhenProvidedWithMalformedRuleSpecification() throws Exception {
.isInstanceOf(SpecificationParserException.class);
}

@Disabled("Not yet fully implemented")
@Nested
class parsesSimplesRulesTest {

@Disabled("This test is disabled because the implementation is not yet complete")
@ParameterizedTest
@ValueSource(strings = {
"RecordIdentifier('NAME')",
"Field('HOMEWORLD')",
"UniqueField('NAME')",
"FieldPopulated('SPECIES')",
"FieldExists('HOMEWORLD')",
"RecordExists('C-3PO')"
"RecordExists('NAME', 'C-3PO')"
})
void recognizesSimpleRule(String ruleToParse) {
assertThat(ruleToParse).matches(
Expand Down
94 changes: 88 additions & 6 deletions dsl/src/test/java/be/sddevelopment/validation/dsl/CsvFileTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,53 @@
import java.io.IOException;
import java.nio.file.Paths;
import java.util.List;
import java.util.Vector;

import static be.sddevelopment.validation.core.ModularRuleset.aValid;
import static be.sddevelopment.validation.core.Reason.passed;
import static be.sddevelopment.validation.dsl.ExpectedValue.withValue;
import static be.sddevelopment.validation.dsl.rules.CsvValidationRules.containRecord;
import static be.sddevelopment.validation.dsl.rules.CsvValidationRules.haveField;
import static be.sddevelopment.validation.dsl.FieldValue.withValue;
import static be.sddevelopment.validation.dsl.rules.CsvValidationRules.*;

@Slf4j
@DisplayName("Comma Separated Values File")
@DisplayNameGeneration(ReplaceUnderscoredCamelCasing.class)
class CsvFileTest implements WithAssertions {

@Test
void doesNotAllowNullHeaders() {
var identifier = "STARWARS_INPUT_DATA.csv";
var lines = new Vector<Vector<String>>();

assertThatExceptionOfType(IllegalArgumentException.class)
.isThrownBy(() -> new CsvFile(identifier, null, lines)
)
.withMessage("Header fields and lines must not be null")
.withNoCause();
}

@Test
void doesNotAllowNullLines() {
var identifier = "STARWARS_INPUT_DATA.csv";
var header = new Vector<>(List.of("NAME", "HEIGHT", "SPECIES"));

assertThatExceptionOfType(IllegalArgumentException.class)
.isThrownBy(() -> new CsvFile(identifier, header, null)
)
.withMessage("Header fields and lines must not be null")
.withNoCause();
}

@Test
void containsFileNameAfterParsing() throws Exception {
var dataFile = Paths.get(CsvFileTest.class.getClassLoader().getResource("parsing/star_wars/STARWARS_INPUT_DATA.csv").toURI());
assertThat(dataFile).exists().isRegularFile().hasExtension("csv");

var result = CsvFile.fromFile(dataFile);

assertThat(result).satisfies(CsvFile::isNotEmpty);
assertThat(result.fileIdentifier()).contains("STARWARS_INPUT_DATA.csv");
}

@Nested
class LineBasedParsing {

Expand Down Expand Up @@ -70,12 +105,59 @@ void canHandleEmptyDataSets() throws IOException {
}
}

@Nested
class ContentAccessors {
@Test
void listsAllDistinctValuesForExistingField() throws Exception {
var dataWithHeader = """
NAME,HEIGHT,SPECIES
Luke Skywalker,172,Human
C-3PO,167,Droid
R2-D2,96,Droid
Boba Fett,183, Human
""";
var file = CsvFile.fromLines(dataWithHeader.lines().toList());

var result = file.distinctValuesFor("SPECIES");

assertThat(result).contains("Human", "Droid");
}

@Test
void listNoValuesForMissingField() throws Exception {
var dataWithHeader = """
NAME,HEIGHT,SPECIES
Luke Skywalker,172,Human
C-3PO,167,Droid
""";
var file = CsvFile.fromLines(dataWithHeader.lines().toList());

var result = file.distinctValuesFor("HOMEWORLD");

assertThat(result).isEmpty();
}

@Test
void listNoValuesForEmptyFile() throws Exception {
var dataWithHeader = """
NAME,HEIGHT,SPECIES
""";
var file = CsvFile.fromLines(dataWithHeader.lines().toList());
assertThat(file).isNotNull().satisfies(CsvFile::isEmpty);

var result = file.distinctValuesFor("NAME");

assertThat(result).isEmpty();
}
}

@Nested
@DisplayName("Using CsvFile for validation")
class UseCaseTest {

public static final ModularRuleset<CsvFile> THE_DROIDS_WE_ARE_LOOKING_FOR = aValid(CsvFile.class)
.must(haveField("NAME"))
.must(haveDistinctValuesFor("NAME"))
.must(haveField("SPECIES"))
.must(containRecord(withName("C-3PO"), withSpecies("Droid"), withHomeWorld("Tatooine")))
.must(containRecord(withName("R2-D2"), withSpecies("Droid"), withHomeWorld("Naboo")))
Expand Down Expand Up @@ -125,15 +207,15 @@ void failsWhenExpectedRecordsAreNotPresent() throws Exception {
);
}

private static ExpectedValue withSpecies(String species) {
private static FieldValue withSpecies(String species) {
return withValue("SPECIES", species);
}

private static ExpectedValue withHomeWorld(String homeWorld) {
private static FieldValue withHomeWorld(String homeWorld) {
return withValue("HOMEWORLD", homeWorld);
}

private static ExpectedValue withName(String name) {
private static FieldValue withName(String name) {
return withValue("NAME", name);
}

Expand Down
Loading

0 comments on commit ce02ec2

Please sign in to comment.