Fork me on GitHub
#clojure-russia
<
2018-12-27
>
kuzmin_m16:12:14

Привет! Можете объяснить концепцию/философию 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?

kuzmin_m16:12:26

(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_m16:12:30

вот так?

dottedmag16:12:14

Да, так.

dottedmag16:12:35

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

prepor16:12:35

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

prepor16:12:40

(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?))

prepor16:12:50

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

kuzmin_m16:12:11

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

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

kuzmin_m16:12:26

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

prepor16:12:26

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

prepor16:12:32

(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])))

prepor16:12:07

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

kuzmin_m16:12:29

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

kuzmin_m16:12:48

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

kuzmin_m16:12:03

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

dottedmag16:12:52

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

dottedmag16:12:05

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

kuzmin_m16:12:05

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

dottedmag16:12:48

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

kuzmin_m17:12:09

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

kuzmin_m17:12:29

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

dottedmag17:12:04

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

kuzmin_m17:12:33

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

kuzmin_m17:12:43

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

kuzmin_m17:12:35

(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)

kuzmin_m17:12:42

работает

kuzmin_m17:12:42

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

dottedmag17:12:35

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

kuzmin_m17:12:44

ок, спасибо)

dottedmag17:12:00

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

dottedmag17:12:38

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

kuzmin_m17:12:50

с явным nil

dottedmag17:12:54

Тогда ок.

kuzmin_m17:12:14

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

fmnoise20:12:57

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

fmnoise20:12:11

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

fmnoise20:12:15

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

fmnoise20:12:51

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

fmnoise20:12:47

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

fmnoise20:12:03

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

fmnoise20:12:37

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

fmnoise20:12:57

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

fmnoise20:12:21

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

kuzmin_m20:12:03

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

(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 …) - валидация?

kuzmin_m20:12:16

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

kuzmin_m20:12:45

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

kuzmin_m20:12:13

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

fmnoise20:12:46

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

fmnoise20:12:36

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

fmnoise20:12:04

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

fmnoise20:12:48

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

fmnoise20:12:57

ну или 0

fmnoise20:12:16

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

kuzmin_m20:12:36

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

kuzmin_m20:12:50

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

fmnoise20:12:07

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

fmnoise20:12:56

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

fmnoise20:12:02

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

👌 4
fmnoise20:12:31

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

fmnoise20:12:44

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

fmnoise20:12:02

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

fmnoise20:12:15

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

(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?)))

kuzmin_m20:12:40

;; 
(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))))
с генератором

fmnoise20:12:34

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

fmnoise20:12:45

наверное

fmnoise20:12:48

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

fmnoise20:12:01

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

fmnoise20:12:57

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

fmnoise21:12:35

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

fmnoise21:12:10

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

fmnoise21:12:37

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

fmnoise21:12:50

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

kuzmin_m21:12:36

(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 дает

kuzmin_m21:12:59

да и понятнее

fmnoise21:12:24

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

kuzmin_m21:12:38

это имя ветки

fmnoise21:12:47

ааа, хм

fmnoise21:12:54

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

fmnoise21:12:13

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

kuzmin_m21:12:28

(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)))))

kuzmin_m21:12:16

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

(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)))))

fmnoise21:12:54

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

fmnoise21:12:06

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

fmnoise21:12:14

(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)))))

kuzmin_m21:12:25

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

fmnoise21:12:37

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

kuzmin_m21:12:51

не, title и summary s/nilable

fmnoise21:12:12

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

fmnoise21:12:14

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

kuzmin_m21:12:17

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

fmnoise21:12:22

и там not-empty

fmnoise21:12:35

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

kuzmin_m21:12:01

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

kuzmin_m21:12:08

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

fmnoise21:12:11

(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 ""}}

kuzmin_m21:12:12

и это ок

fmnoise21:12:46

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

kuzmin_m21:12:36

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

fmnoise21:12:19

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

kuzmin_m21:12:34

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

kuzmin_m21:12:57

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

kuzmin_m21:12:42

(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)))

fmnoise21:12:57

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