Wykorzystanie Vavr'a do parsowania pliku CSV

0

Mam plik CSV który zawiera listę rekordów z danymi. Napisałem sobie parser (w Kotlinie ale to akurat nie ma znaczenia bo w Javie można zrobić podobnie) który wczytuje te dane a następnie przepisuje na listę objektów klasy Transaction:

class Transaction(
    val transactionDateTime: LocalDateTime,
    val type: TransactionType,
    val amount: BigDecimal,
    val currency: Currency,
) 

Metoda która parsuje rekord zwaca Either i wygląda mniej więcej tak:

    fun getTransaction(rawRow: Row): Either<ParserError, Transaction> {
          return Try { buildTransaction(rawRow) }
                    .toEither(AppError(ErrorReason.COULD_NOT_PARSE_ROW, rawRow.toRawString()))
   }

  private fun buildTransaction(rawRow: Row): Transaction {
        return TransactionBuilder(
            transactionDateTime = getDateTime(rawRow),
            type = getTransactionType(rawRow),
            amount = getAmount(rawRow),
            currency = getCurrency(rawRow),
        )

Jeśli któraś z metod parsujących się wysypie (np błędny format daty itp) to rzucam Left'em z błędem + info który to rekord. Wszystko fajnie funkcjonuje ale:

  1. Nie mam pojęcia która komórka była błędna (znam tylko pełny rekord)
  2. Okazuje się że pewne dane są mniej potrzebne i nawet jeśli nie powiedzie się wczytanie jakiejś komórki to można ją pominąć (np. date transakcji).

Zastanawiam się więc jak to sensownie zrobić i nic mi ciekawego do głowy nie przychodzi. Pomyślałem sobie że może moje metody parsujące kolumny (getDateTime itd) powinny zwracać Try<LocalDateTime> i później ewentualnie zweryfikować które się powiodły które nie natomiast nie widzę w ogóle jak to mogłoby być sensownie zaimplementowane bo nie chciałbym potem robić czegoś takiego typu if(getDateTime(rawRow).isFailure) to pomiń tę komórkę a jeśli if(getAmount(rawRow).isFailure) to rzuć Leftem. Takie trochę słabe rozwiązanie. Ma ktoś może pomysł jak to sensowniej ugryźć?

0

A po co te ify?
Try ma metody toOption i toEither, czyli można napisać:

   var dataTimeOpt = getDateTime(rawRow).toOption();
   var amountEither = getAmount(rawRow).toEither();
1

Gdybyś miał taki standardowy builder, to mógłbyś spróbować jeszcze tak (nie znam za dobrze składni Kotlina, więc piszę w Javie) :)

private Either<Error, Transaction> buildTransaction(Row rawRow) {
  return Either.right(new TransactionBuilder())
    .flatMap(b -> getDateTime(rawRow).map(b::dateTime))
    .flatMap(b -> getTransactionType(rawRow).map(b::transactionType))
    .flatMap(b -> getAmount(rawRow).map(b::amount))
    .flatMap(b -> getCurrency(rawRow).map(b::currency))
    .map(TransactionBuilder::build);
}  

Tu przy założeniu, że każda metoda getDateTime, getTransactionType itd. zwracają Either<? extends Error, T>. Wówczas, jeśli już podczas pierwszego wywołania flatMap będzie zwrócony błąd Either.Left, to pozostałe wywołania się nie wykonają i zostanie zwrócony Either z tym konkretnym błędem.
Jeśli metody getDateTime itd. rzucają wyjątki, to jak napisał @Kamil Żabiński, masz metodę Try#toEither() i wewnątrz może to wyglądać jakoś tak:

Either<Error, DateTime> getDateTime(Row rawRow) {
  Try.of(() -> someCodeThatExtractsDateTimeFromRawRow(rawRow))
    .toEither()
    .mapLeft(e -> Error.of(ErrorType.DATE_TIME_EXTRACTION_ERROR, e));
}
0
catom napisał(a):

Wówczas, jeśli już podczas pierwszego wywołania flatMap będzie zwrócony błąd Either.Left, to pozostałe wywołania się nie wykonają i zostanie zwrócony Either z tym konkretnym błędem.

@catom Problem w tym że tak jak pisałem nie każda z tych metod przy wysypywaniu się powinna kończyć parsowanie. Część pól nie jest tak istotnych i nawet jeśli ich parsowanie się nie powiedzie to nie powinno to rzucać Leftem (wówczas może zwrócić po prostu Option). Z tym że w tym podejściu jeśli dobrze rozumiem nie jest to możliwe.

1

Czyli masz trzy sytuacje? Parsowanie nie udało się, udało częściowo albo udało całkowicie? W dwóch ostatnich przypadkach parsowanie powiodło się tak więc Eithler.left zostawiłbym dla tej pierwszej sytuacji. Co do parsowania udanego, zrobiłbym dwa obiekty (jakiś współny interface?) jeden dla całkowitego sparsowania, drugi dla minimalnego i je wrzucał jako right

0
eL napisał(a):

@catom Problem w tym że tak jak pisałem nie każda z tych metod przy wysypywaniu się powinna kończyć parsowanie. Część pól nie jest tak istotnych i nawet jeśli ich parsowanie się nie powiedzie to nie powinno to rzucać Leftem (wówczas może zwrócić po prostu Option). Z tym że w tym podejściu jeśli dobrze rozumiem nie jest to możliwe.

Możesz zrobić tak, ale rozwiązanie staje się coraz brzydsze :)

private Either<Error, Transaction> buildTransaction(Row rawRow) {
  return Either.right(new TransactionBuilder())
    .flatMap(b -> getDateTime(rawRow).map(b::dateTime).orElse(() -> Either.right(b))) // w przypadku, gdy dateTime można zignorować
    .flatMap(b -> getTransactionType(rawRow).map(b::transactionType))
    .flatMap(b -> getAmount(rawRow).map(b::amount))
    .flatMap(b -> getCurrency(rawRow).map(b::currency).orElse(() -> Either.right(b))) // w przypadku, gdy currency można zignorować
    .map(TransactionBuilder::build);
}  

Nie wiem, czy nie wypadałoby już stworzyć swojeg API do takich walidacji z informacją, czy błąd dla danej walidacji można zignorować, czy nie.
Kiedyś próbowałem używać Vavrowego Validation, ale mi jakoś nie przypadło do gustu.

0
catom napisał(a):

Możesz zrobić tak, ale rozwiązanie staje się coraz brzydsze :)

Oj tu faktycznie już na grubo pojechałeś :D Faktycznie działałoby ale nie wygląda to ładnie. Wymyśliłem coś innego co też szału nie robi ale póki co mi wystarczy żeby popracować dalej a do tego sobie wrócę bo efekt daje mi taki jaki potrzebuje.

Zrobiłem tak że metody które parsują wymagane pola rzucają Either<ParseError, Transaction> natomiast te które nie są aż tak ważne rzucają Option<Transaction> . Przykład:

   override fun getDateTime(rawRow: Row): Option<LocalDateTime> {
        return Try.of {
          // Parsowanie...
        }.toOption()
    }

    override fun getCurrency(rawRow: Row): Either<ParseError, Currency> {
        return Try.of {
           // Parsowanie...
        }.toEither(ParseError(ErrorReason.COULD_NOT_PARSE_ROW, rawRow, parsedValue))
    }

Potem jak próbuję wyciągnąć te dane to na wymaganych polach wywołuje proponowany przez Ciebie flatMap natomiast na tych opcjonalnych tylko peek. :

fun getTransaction(rawRow: Row): Either<ParseError, Transaction> {
   return Either.right<ParseError, TransactionBuilder>(TransactionBuilder())
      .peek { builder -> getDateTime(rawRow).map { builder.transactionDateTime(it) } }// pole opcjonalne.
      .flatMap { builder -> getCurrency(rawRow).map { builder.currency(it) } } // pole wymagane
      // inne parsowania
      .map { builder -> builder.build() }

Efekt zadawalający tylko trochę ponieważ:

  1. Brzydkie to to ;)
  2. To też nie rozwiązuje jeszcze innego problemu a mianowicie takiego że to jest tylko parser dla jednego typu pliku a takich parserów mam znacznie więcej i w każdym z nich różne pola mogą być polami opcjonalnymi i wymaganymi natomiast powyższe rozwiązanie wymusza na mnie ustawienie takich samych reguł dla wszystkich parserów :(
    protected abstract fun getDateTime(rawRow: Row): Option<LocalDateTime>
    protected abstract fun getCurrency(rawRow: Row): Either<ParseError, Currency>

Także będę pracował nad dopracowaniem tego i pójde w jakieś bardziej rozbudowane API ale tak czy inaczej Twoja propozycja na pare dni mnie satysfakcjonuje :) Dzięki!

1 użytkowników online, w tym zalogowanych: 0, gości: 1