clojure-russia 2018-12-27

Привет! Можете объяснить концепцию/философию clojure.spec? Почему нет из коробки поддержки спецификаций для мапов? Вменяемой сторонней библиотеки тоже нет. Допустим, у меня есть следующие данные:

{:title "some title"
 :content "some content"
 :published-at (java.time.Instant/now)}
или
{:title "-"
 :content nil
 :published-at nil}
Т.е. если published-at - inst?, то title и content - должы присутствовать в мапе, быть строками, а title быть длинее 3х символов. Если published-at nil?, то ключи могут отсутствовать, быть nil или любой строкой. В дргих случаях мапа невалидна. Получается, что в зависимости от внешних условий :titile и :content имеют разные спецификации. Судя по тому, что сделали s/keys и спецификации на отдельные ключи это by design нельзя делать. А что делать, если хочется? Spec не для таких данных? А что, если данные были “подходящие“, а стали “неподходящие“? Страдать, пока оно в alpha?

(s/def ::title (s/nilable (s/and string?)))
(s/def ::content (s/nilable string?))
(s/def ::published-at (s/nilable inst?))

(s/def ::translation (s/and (s/keys :opt-un [::title ::content ::published-at])
                            (fn [{:keys [title content published-at]}]
                              (or (nil? published-at)
                                  (and (> (count title) 3)
                                       (not-empty content))))))

вот так?

Да, так.

> А что, если данные были “подходящие“, а стали “неподходящие“? Это называется "сломать свой софт", и так делать нельзя.

@kuzmin_m не, ну выразить-то такое можно довольно легко

(s/def ::title (s/and string? #(< 3 (count %))))
(s/def ::content string?)
(s/def ::published-at inst?)

(s/def ::translation (s/or
                      (s/keys :req-un [::title ::content ::published-at])
                      map?))

но это так конечно себе спека )

а если я захочу расширить ::translation?

(s/def ::foo string?)
(s/def ::ext (s/merge ::translation (s/keys :req-un [::foo])))

оно же через s/and и будет ошибка

ну или более строго

(s/def ::title (s/and string? #(< 3 (count %))))
(s/def ::content string?)
(s/def ::published-at inst?)

(s/def :other.title (s/nilable string?))
(s/def :other.content (s/nilable string?))
(s/def :other.published-at nil?)

(s/def ::translation (s/or
                      (s/keys :req-un [::title ::content ::published-at])
                      (s/keys :req-un [:other.published-at]
                              :opt-un [:other.title :other.content])))

> а если я захочу расширить ::translation? в смысле добавить еще один вариант в or?

> данные были “подходящие“, а стали “неподходящие“? и теперь у меня не req-un, а req. и фейковые неймспейсы не работают

> в смысле добавить еще один вариант в or? есть у меня где-то :some.ns/translation в другом неймспейсе я хочу сделать версию перевода: (s/def :another.ns/translation (s/merge :some.ns/translation ....)

Т.е. к тем полям еще добавить полей

@kuzmin_m Да вроде норм. Добавлять поля можно, это ничего не ломает.

s/keys требует указанные поля, но не запрещает другие.

по идее все это можно свести к вопросу как на s/keys наложить ограничения с помощью своей функцией и потом к этому еще что-то смержить через s/merge

@kuzmin_m Да уже и так работает. Функция выбирает нужные поля и их валидирует. Остальное её не волнует.

хм, видимо у меня в первый раз что-то не завелось валидатор работает а с генератором нужно разобраться

т.е. я по спеке еще данные хочу генерировать

Угу. Если ограничитель серьёзный, то спеке надо подсказать, иначе она будет генерить варианты миллиардами, и миллиардами же их откидывать.

не, там именно ошибка была

а не то, что он не смог сгенерировать

(s/def ::title (s/nilable (s/and string?)))
(s/def ::content (s/nilable string?))
(s/def ::published-at (s/nilable inst?))

(s/def ::translation (s/and (s/keys :opt-un [::title ::content ::published-at])
                            (fn [{:keys [title content published-at]}]
                              (or (nil? published-at)
                                  (and (>= (count title) 3)
                                       (not-empty content))))))

(assert (s/valid? ::translation {}))
(assert (not (s/valid? ::translation {:title 1})))
(assert (s/valid? ::translation {:title nil}))
(assert (s/valid? ::translation {:published-at (java.time.Instant/now)
                                 :title "123"
                                 :content "some"}))
(gen ::translation)

(s/def ::foo string?)
(s/def ::ext (s/merge ::translation (s/keys :req-un [::foo])))

(gen ::ext)

работает

т.е. логика тут такая для каждого ключа добавляем спеку на все возможные варианты а уже для “сущности” вешаем ограничения

Да, это правильно.

ок, спасибо)

Хотя nilable не нужно. И так ведь opt-un?

Или у тебя поле с явным значением nil?

с явным nil

Тогда ок.

это уже детали, и может быть я где-то и ошибся, но главное, что концепция понятна

там еще есть мультиспека

но она честно говоря не особо читаемая

вообще у меня возникло такое ощущение, что спека скорее о шейпе данных чем о валидации

типа защита от вызова .toLowerCase на Integer

потому что если валидацию доводить до совершенства и пользоваться спекой, то нечитаемый код получается

ребята из Attendify вот такое сделали https://github.com/KitApps/schema-refined

там есть удобная штука dispatch-on которая в спеке решается через мультики

но поскольку оно на схеме, то оно более менее читаемое

мультики со спекой это спагетти 88 уровня

Где провести грань между спекой и валидацией?

(s/def ::title (s/nilable string?))
(s/def ::summary (s/nilable string?))
(s/def ::tags (s/coll-of string? :kind vector? :distinct true))
(s/def ::published-at (s/nilable inst?))

(s/def ::translation (s/and (s/keys :req-un [::title ::summary ::tags ::published-at])
                            (fn [{:keys [title summary published-at]}]
                              (or (nil? published-at)
                                  (and (not-empty title)
                                       (not-empty summary))))))
все что кроме (fn …) - спека, а (fn …) - валидация?

и эту валидацю как-то отдельно делать?

и да, в моем варианте explain гранулированный не получить

плюс, нужно на схему посмотреть, спасибо за ссылку

@kuzmin_m посмотри на ту штуку что я кинул, там реально очень хорошая идея по валидации именно данных

мне кажется спека больше о том, что можно а что нельзя делать над данными

в то время как реальная валидация скорее о бизнес-контексте

например если у тебя в ticket :type = :free то :price должно быть nil

ну или 0

потому что не может быть :free ticket с ценой > 0

т.е. это про валидацию

schema-refined поможет с этим?

да, schema refined как раз про fine grained validation

типа int? ничего не говорит, а вот int в границах от 0 до 1000 это уже нормальный бизнес-кейс

рекомендую тоже взглянуть

👌 1

еще там есть фишка с неизвестными ключами

спека на них кладет болт, а схема нет

по умолчанию в смысле

я вот такое рисовал чтобы обойтись

(defmacro strict-keys
  "The same as `clojure.spec.alpha/keys` but limits keyset to given keys"
  [& {:keys [req opt req-un opt-un] :as keyspec}]
  `(s/merge (s/keys :req ~req :opt ~opt :req-un ~req-un :opt-un ~opt-un)
            (s/map-of (->> ~keyspec vals (reduce #(into %1 %2) #{})) any?)))

;; 
(defmacro only-keys [& {:keys [req req-un opt opt-un] :as args}]
  (let [keys-spec `(s/keys ~@(apply concat (vec args)))]
    `(s/with-gen
       (s/merge ~keys-spec
                (s/map-of ~(set (concat req
                                        (map (comp keyword name) req-un)
                                        opt
                                        (map (comp keyword name) opt-un)))
                          any?))
       #(s/gen ~keys-spec))))
с генератором

ну это красивее

наверное

то я давно писал

explain дает уродский

да, у этой штуки explain красивый

вообще не знаю, кто-то заметил или нет, в кложуре 1.10 добавили в спеку возможность спеки удалять из регистра

то есть можно schema-style по идее запилить

не знаю насколько оно thread-safe правда

подозреваю что не thread-safe at all

(s/def ::translation (s/and (s/keys :req-un [::title ::summary ::tags ::published-at])
                            (s/or :not-published #(-> % :published-at nil?)
                                  :published (s/and #(-> % :title not-empty)
                                                    #(-> % :summary not-empty)))))
вот мой вариант получше, если кому-то интересно он более точный explain дает

да и понятнее

а что первый ключ в or означает?

это имя ветки

ааа, хм

действительно симпатично

но у тебя ключ published-at должен присутствовать и быть nil

(s/def ::title (s/nilable string?))
(s/def ::summary (s/nilable string?))
(s/def ::tags (s/coll-of string? :kind vector? :distinct true))
(s/def ::published-at (s/nilable inst?))

(s/def ::translation (s/and (s/keys :req-un [::title ::summary ::tags ::published-at])
                            (s/or :not-published #(-> % :published-at nil?)
                                  :published (s/and #(-> % :title not-empty)
                                                    #(-> % :summary not-empty)))))

можно еще вот так:

(s/def ::translation (s/and (s/keys :opt-un [::title ::summary ::published-at]
                                    :req-un [::tags])
                            (s/or :not-published #(-> % :published-at nil?)
                                  :published (s/and #(-> % :title not-empty)
                                                    #(-> % :summary not-empty)))))

ну по опыту ключ скорее не будет чем будет nil

когда он opt то норм

(s/def ::translation (s/and (s/keys :req-un [::title ::summary ::tags] :opt-un [::published-at])
                            (s/or :not-published #(-> % :published-at nil?)
                                  :published (s/and #(-> % :title not-empty)
                                                    #(-> % :summary not-empty)))))

тут пока спорно, я пока еще не решил

как-то только он присутствует, сразу требуются тайтл и саммари

не, title и summary s/nilable

ну у меня нет изначальных спек

только вот эта

хотя это тоже может поменяется

и там not-empty

(s/explain-data ::translation {:title "" :summary "" :tags ""});; => nil

так правильно

у тебя published-at опущен

(s/explain-data ::translation {:title "" :summary "" :tags "" :published-at ""})
;; => #:clojure.spec.alpha{:problems ({:path [:not-published], :pred (clojure.core/fn [%] (clojure.core/-> % :published-at clojure.core/nil?)), :val {:title "", :summary "", :tags "", :published-at ""}, :via [:eventum.fiken/translation], :in []} {:path [:published], :pred (clojure.core/fn [%] (clojure.core/-> % :title clojure.core/not-empty)), :val {:title "", :summary "", :tags "", :published-at ""}, :via [:eventum.fiken/translation], :in []}), :spec :eventum.fiken/translation, :value {:title "", :summary "", :tags "", :published-at ""}}

и это ок

а вот если добавляю его

в общем у меня вопросы закончились) дальше уже бантики

да, если разнести еще ветки or по спекам то вообще красиво выходит

если выносить в отдельные спеки, то в published нужно добавить проверку #(-> % :published-at inst?)

т.к. пока все в одном or можно не делать, а если разносить, то нужно явно указать

(s/def ::published (s/and #(-> % :published-at some?)
                          #(-> % :title not-empty)
                          #(-> % :summary not-empty)))
(s/def ::not-published #(-> % :published-at nil?))

(s/def ::translation (s/and (s/keys :req-un [::title ::summary ::tags ::published-at])
                            (s/or :not-published ::not-published
                                  :published ::published)))

та вроде работает и без этого