Привет! Можете объяснить концепцию/философию 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 это уже нормальный бизнес-кейс
там еще ссылка на слайды есть https://speakerdeck.com/kachayev/keep-your-data-safe-with-refined-types
рекомендую тоже взглянуть
еще там есть фишка с неизвестными ключами
спека на них кладет болт, а схема нет
по умолчанию в смысле
я вот такое рисовал чтобы обойтись
(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?)))
;; <https://groups.google.com/forum/#!topic/clojure/fti0eJdPQJ8>
(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)))
та вроде работает и без этого