brandonchinn178 / aeson-schemas

Easily consume JSON data on-demand with type-safety
http://hackage.haskell.org/package/aeson-schemas
BSD 3-Clause "New" or "Revised" License
52 stars 1 forks source link

Schema-abiding smart constructors / setters #87

Open dmjio opened 1 year ago

dmjio commented 1 year ago

Hello 👋🏼

Great work on this library.

I was curious / had a potential feature request. Has there been any exploration into generating smart constructors / setters that would allow a schema-abiding Value to be constructed? Would be nice if it could also guarantee all fields / leaves had been populated.

Thanks again for this library.

brandonchinn178 commented 1 year ago

Hello! No, theres been no explorartion of any sort like that. What would the API look like? If you're just talking about a conversion Aeson.Value -> Maybe (Object schema), you could just do Aeson.parseMaybe parseJSON. We could certainly make a helper for that, but otherwise, I'm not sure what a smart constructor would entail

dmjio commented 1 year ago

Thanks for the quick response.

I'm not really sure what a builder for a schema-abiding Value would look like either. But one idea could be something like ...

Given this schema:

type PersonSchema = [schema|
  {
    person: {
      name: Name,
      age: Age
    }
}   
|]

We could define some paths we want to set w/ values.

type PathSchema = '[ '("person.name", Name), '("person.age", Age) ]

schemaBuilderPerson :: Proxy PathSchema
schemaBuilderPerson = Proxy

In order to ensure we could encode a Schema statically, we'd need some type family to check the paths against the type level Person Schema defined above. The type family should also check if some paths haven't been specified as well (reported as errors). If valid, an empty anonymous record / HList / or empty aeson Object could be returned.

validate 
  :: (ValidSchema pathSchema schema) 
  => Proxy pathSchema 
  -> Proxy schema 
  -> ValidSchemaBuilder pathSchema schema
validate = ValidSchemaBuilder mempty

Once the valid function returns a ValidSchemaBuilder schema builder, the builder portion of the ValidSchemaBuilder schema builder can be deconstructed. set would have the effect of peeling off a type in the type level list, but also populating the anonymous record / HList / object.

setPersonSchema
   :: Name 
   -> Age 
   -> ValidSchemaBuilder PersonSchema PathSchema 
   -> ValidSchemaBuilder PersonSchema '[] 
  -- ^ the act of calling `set` removes a path in the type-level path list of `ValidSchemaBuilder`.
setPersonSchema name age (ValidSchemaBuilder schema)
  = ValidSchemaBuilder $ schema
  & set (Proxy @ "person.name") name
  & set (Proxy @ "person.age") age

And then finally we can encode constructed ValidSchemaBuilder schema '[] into Value.

validSchemaBuilderToJSON :: ValidSchemaBuilder schema '[] -> Value
 -- ^ builder ~ '[] in order to make `Value`

I haven't really fully fleshed out this idea, but I think the above is possible.

brandon-leapyear commented 1 year ago

:sparkles: This is an old work account. Please reference @brandonchinn178 for all future communication :sparkles:


Interesting! Yes, that certainly looks possible, but I'm not sure we need that many new concepts. It should be possible to do something like

type PersonSchema = [schema| { person: { name: Name, age: Age } } |]

-- buildObject :: [SomeField] -> Maybe (Object PersonSchema)
buildObject
  [ field @'["person", "name"] "Alice"
  , field @'["person", "age"] 20
  ]

data SomeField = forall path a. SomeField (Field path a)
data Field path a = Field
  { path :: [String] -- value-representation of type level 'path'
  , value :: a
  }

field :: forall path a. All KnownSymbol path => a -> Field path a

I'd certainly be open to a PR of that sort

Note: somewhat related to #2.

stevemao commented 4 months ago

@brandonchinn178 In your example, is the "person" type save?

If I made a typo

buildObject
  [ field @'["person2", "name"] "Alice"
  , field @'["person", "age"] 20
  ]

Would it error?

brandonchinn178 commented 4 months ago

No, it wouldn't error in my example, it would return Nothing at runtime, as the object it builds up would have a person key and a person2 key, which doesnt match the PersonSchema schema.

Rereading the thread, I guess my example didn't really answer the original question. In general, this problem could be solved in two ways:

  1. Additive - a schema starts empty and an hlist is built up
  2. Subtractive - schema defines its full schema and all the paths, and the paths hlist is picked off (what OP was considering)

Solution (2) / OP's example could be implemented, but I don't see how the example would prevent someone from specifying a PathsSchema that doesnt match PersonSchema.

So I think (1) would be better. It could be done with something like

data PartialObject paths = UnsafePartialObject [([String], Dynamic)]

set ::
  forall path a paths.
  (ToJSON a, AllKnownSymbol path) =>
  a
  -> PartialObject paths
  -> PartialObject (path ': paths)
set a (UnsafePartialObject paths) =
  UnsafePartialObject $ (getPath @path, toDyn a) : paths

class AllKnownSymbol path where
  getPath :: [String]
instance AllKnownSymbol [] where
  getPath = []
instance
  ( KnownSymbol s
  , AllKnownSymbol ss
  ) => AllKnownSymbol (s ': ss) where
  getPath = knownSym s : getPath @ss

validate ::
  forall schema paths.
  Matches schema paths =>
  PartialObject paths
  -> Object schema
validate (UnsafePartialObject paths) = UnsafeObject (foldr insertPath Map.empty paths)
  where
    insertPath :: ([String], Dynamic) -> Map Text Dynamic -> Map Text Dynamic
    insertPath = _

The hard part here is defining the type family Matches schema paths. It might get fairly hairy, but I'm not sure.