From 6ea26335aadb9cd62bda9ea1653c43bde05c9a9c Mon Sep 17 00:00:00 2001 From: ccamel Date: Fri, 11 Oct 2024 14:14:56 +0200 Subject: [PATCH] refactor(logic)!: preserve object key order in json_prolog/2 Object keys are no longer sorted in json_prolog/2. The original order of keys is now preserved to maintain consistency with the input structure. --- x/logic/predicate/json.go | 338 +++++++++++++++++++++++++------------- x/logic/prolog/error.go | 4 +- x/logic/prolog/json.go | 58 +++---- x/logic/prolog/list.go | 31 +++- 4 files changed, 277 insertions(+), 154 deletions(-) diff --git a/x/logic/predicate/json.go b/x/logic/predicate/json.go index 2c798930..0bdc8a13 100644 --- a/x/logic/predicate/json.go +++ b/x/logic/predicate/json.go @@ -1,10 +1,11 @@ package predicate import ( + "bytes" "encoding/json" "errors" "fmt" - "sort" + "io" "strconv" "strings" @@ -14,6 +15,23 @@ import ( "github.com/axone-protocol/axoned/v10/x/logic/prolog" ) +var ( + // AtomSyntaxErrorJSON represents a syntax error related to JSON. + AtomSyntaxErrorJSON = engine.NewAtom("json") + + // AtomMalformedJSON represents a specific type of JSON syntax error where the JSON is malformed. + AtomMalformedJSON = engine.NewAtom("malformed_json") + + // AtomEOF represents a specific type of JSON syntax error where an unexpected end-of-file occurs. + AtomEOF = engine.NewAtom("eof") + + // AtomUnknown represents an unknown or unspecified syntax error. + AtomUnknown = engine.NewAtom("unknown") + + // AtomValidJSONNumber is the atom denoting a valid JSON number. + AtomValidJSONNumber = engine.NewAtom("json_number") +) + // JSONProlog is a predicate that unifies a JSON into a prolog term and vice versa. // // The signature is as follows: @@ -39,165 +57,261 @@ import ( // // # JSON conversion to Prolog. // - json_prolog('{"foo": "bar"}', json([foo-bar])). -func JSONProlog(_ *engine.VM, json, term engine.Term, cont engine.Cont, env *engine.Env) *engine.Promise { - forwardConverter := func(json []engine.Term, _ engine.Term, env *engine.Env) ([]engine.Term, error) { - term, err := decodeJSONToTerm(json[0], env) +func JSONProlog(_ *engine.VM, j, p engine.Term, cont engine.Cont, env *engine.Env) *engine.Promise { + forwardConverter := func(in []engine.Term, _ engine.Term, env *engine.Env) ([]engine.Term, error) { + payload, err := prolog.TextTermToString(in[0], env) if err != nil { return nil, err } - return []engine.Term{term}, nil - } - backwardConverter := func(term []engine.Term, _ engine.Term, env *engine.Env) ([]engine.Term, error) { - b, err := encodeTermToJSON(term[0], env) + + decoder := json.NewDecoder(strings.NewReader(payload)) + term, err := decodeJSONToTerm(decoder, env) if err != nil { return nil, err } - return []engine.Term{prolog.BytesToAtom(b)}, nil - } - return prolog.UnifyFunctionalPredicate( - []engine.Term{json}, []engine.Term{term}, prolog.AtomEmpty, forwardConverter, backwardConverter, cont, env) -} - -// decodeJSONToTerm decode a JSON, given as a prolog text, into a prolog term. -func decodeJSONToTerm(j engine.Term, env *engine.Env) (engine.Term, error) { - payload, err := prolog.TextTermToString(j, env) - if err != nil { - return nil, err - } - - var values any - decoder := json.NewDecoder(strings.NewReader(payload)) - decoder.UseNumber() // unmarshal a number into an interface{} as a Number instead of as a float64 - - if err := decoder.Decode(&values); err != nil { - return nil, prolog.WithError( - engine.DomainError(prolog.ValidEncoding("json"), j, env), err, env) - } + if _, err := decoder.Token(); !errors.Is(err, io.EOF) { + return nil, engine.SyntaxError(AtomSyntaxErrorJSON.Apply(AtomMalformedJSON.Apply(engine.Integer(decoder.InputOffset()))), env) + } - term, err := jsonToTerm(values) - if err != nil { - return nil, prolog.WithError( - engine.DomainError(prolog.ValidEncoding("json"), j, env), err, env) + return []engine.Term{term}, nil } + backwardConverter := func(in []engine.Term, _ engine.Term, env *engine.Env) ([]engine.Term, error) { + var buf bytes.Buffer + err := encodeTermToJSON(in[0], &buf, env) + if err != nil { + return nil, err + } - return term, nil -} - -// encodeTermToJSON converts a Prolog term to a JSON byte array. -func encodeTermToJSON(term engine.Term, env *engine.Env) ([]byte, error) { - bs, err := termToJSON(term, env) - - var exception engine.Exception - if err != nil && !errors.As(err, &exception) { - return nil, prolog.WithError(engine.DomainError(prolog.ValidEncoding("json"), term, env), err, env) + return []engine.Term{prolog.BytesToAtom(buf.Bytes())}, nil } - - return bs, err + return prolog.UnifyFunctionalPredicate( + []engine.Term{j}, []engine.Term{p}, prolog.AtomEmpty, forwardConverter, backwardConverter, cont, env) } -func termToJSON(term engine.Term, env *engine.Env) ([]byte, error) { +func encodeTermToJSON(term engine.Term, buf *bytes.Buffer, env *engine.Env) (err error) { switch t := term.(type) { case engine.Atom: - return json.Marshal(t.String()) + bs, err := json.Marshal(t.String()) + if err != nil { + return prologErrorToException(t, err, env) + } + + buf.Write(bs) case engine.Integer: - return json.Marshal(t) + bs, err := json.Marshal(t) + if err != nil { + return prologErrorToException(t, err, env) + } + + buf.Write(bs) case engine.Float: float, err := strconv.ParseFloat(t.String(), 64) if err != nil { - return nil, err + return prologErrorToException(t, err, env) + } + bs, err := json.Marshal(float) + if err != nil { + return prologErrorToException(t, err, env) } - return json.Marshal(float) + buf.Write(bs) case engine.Compound: - return compoundToJSON(t, env) + return encodeCompoundToJSON(t, buf, env) + default: + return engine.TypeError(prolog.AtomTypeJSON, term, env) } - return nil, engine.TypeError(prolog.AtomTypeJSON, term, env) + return nil } -func compoundToJSON(term engine.Compound, env *engine.Env) ([]byte, error) { +func encodeCompoundToJSON(term engine.Compound, buf *bytes.Buffer, env *engine.Env) error { switch { case term.Functor() == prolog.AtomDot: - iter, err := prolog.ListIterator(term, env) + return encodeArrayToJSON(term, buf, env) + case term.Functor() == prolog.AtomJSON: + return encodeObjectToJSON(term, buf, env) + case prolog.JSONBool(true).Compare(term, env) == 0: + buf.Write([]byte("true")) + case prolog.JSONBool(false).Compare(term, env) == 0: + buf.Write([]byte("false")) + case prolog.JSONEmptyArray().Compare(term, env) == 0: + buf.Write([]byte("[]")) + case prolog.JSONNull().Compare(term, env) == 0: + buf.Write([]byte("null")) + default: + return engine.TypeError(prolog.AtomTypeJSON, term, env) + } + + return nil +} + +func encodeObjectToJSON(term engine.Compound, buf *bytes.Buffer, env *engine.Env) error { + if _, err := prolog.AssertJSON(term, env); err != nil { + return err + } + buf.Write([]byte("{")) + if err := prolog.ForEach(term.Arg(0), env, func(t engine.Term, hasNext bool) error { + k, v, err := prolog.AssertPair(t, env) if err != nil { - return nil, err + return err + } + key, err := prolog.AssertAtom(k, env) + if err != nil { + return err + } + bs, err := json.Marshal(key.String()) + if err != nil { + return prologErrorToException(t, err, env) + } + buf.Write(bs) + buf.Write([]byte(":")) + if err := encodeTermToJSON(v, buf, env); err != nil { + return err } - elements := make([]json.RawMessage, 0) - for iter.Next() { - element, err := termToJSON(iter.Current(), env) - if err != nil { - return nil, err - } - elements = append(elements, element) + if hasNext { + buf.Write([]byte(",")) } - return json.Marshal(elements) - case term.Functor() == prolog.AtomJSON: - terms, err := prolog.ExtractJSONTerm(term, env) + return nil + }); err != nil { + return err + } + buf.Write([]byte("}")) + + return nil +} + +func encodeArrayToJSON(term engine.Compound, buf *bytes.Buffer, env *engine.Env) error { + buf.Write([]byte("[")) + if err := prolog.ForEach(term, env, func(t engine.Term, hasNext bool) error { + err := encodeTermToJSON(t, buf, env) if err != nil { - return nil, err + return err } - attributes := make(map[string]json.RawMessage, len(terms)) - for key, term := range terms { - raw, err := termToJSON(term, env) - if err != nil { - return nil, err - } - attributes[key] = raw + if hasNext { + buf.Write([]byte(",")) } - return json.Marshal(attributes) - case prolog.JSONBool(true).Compare(term, env) == 0: - return json.Marshal(true) - case prolog.JSONBool(false).Compare(term, env) == 0: - return json.Marshal(false) - case prolog.JSONEmptyArray().Compare(term, env) == 0: - return json.Marshal([]json.RawMessage{}) - case prolog.JSONNull().Compare(term, env) == 0: - return json.Marshal(nil) + + return nil + }); err != nil { + return err } + buf.Write([]byte("]")) - return nil, engine.TypeError(prolog.AtomTypeJSON, term, env) + return nil } -func jsonToTerm(value any) (engine.Term, error) { - switch v := value.(type) { - case string: - return prolog.StringToAtom(v), nil - case json.Number: - return engine.NewFloatFromString(v.String()) - case bool: - return prolog.JSONBool(v), nil - case nil: +func jsonErrorToException(err error, env *engine.Env) engine.Exception { + if err, ok := lo.ErrorsAs[*json.SyntaxError](err); ok { + return engine.SyntaxError(AtomSyntaxErrorJSON.Apply(AtomMalformedJSON.Apply(engine.Integer(err.Offset))), env) + } + + if errors.Is(err, io.EOF) { + return engine.SyntaxError(AtomSyntaxErrorJSON.Apply(AtomEOF), env) + } + + if err, ok := lo.ErrorsAs[*json.UnmarshalTypeError](err); ok { + return engine.SyntaxError( + AtomSyntaxErrorJSON.Apply(AtomMalformedJSON.Apply(engine.Integer(err.Offset), prolog.StringToAtom(err.Value))), env) + } + + return prolog.WithError( + engine.SyntaxError(AtomSyntaxErrorJSON.Apply(AtomUnknown), env), err, env) +} + +func prologErrorToException(culprit engine.Term, err error, env *engine.Env) engine.Exception { + if _, ok := lo.ErrorsAs[*strconv.NumError](err); ok { + return engine.DomainError(AtomValidJSONNumber, culprit, env) + } + + return prolog.WithError( + engine.SyntaxError(AtomSyntaxErrorJSON.Apply(AtomUnknown), env), err, env) +} + +func nextToken(decoder *json.Decoder, env *engine.Env) (json.Token, error) { + t, err := decoder.Token() + if err != nil { + return nil, jsonErrorToException(err, env) + } + return t, nil +} + +func decodeJSONToTerm(decoder *json.Decoder, env *engine.Env) (engine.Term, error) { + t, err := nextToken(decoder, env) + if errors.Is(err, io.EOF) { return prolog.JSONNull(), nil - case map[string]any: - keys := lo.Keys(v) - sort.Strings(keys) + } + if err != nil { + return nil, err + } - attributes := make([]engine.Term, 0, len(v)) - for _, key := range keys { - attributeValue, err := jsonToTerm(v[key]) + switch t := t.(type) { + case json.Delim: + switch t.String() { + case "{": + term, err := decodeJSONObjectToTerm(decoder, env) if err != nil { return nil, err } - attributes = append(attributes, prolog.AtomPair.Apply(prolog.StringToAtom(key), attributeValue)) - } - return prolog.AtomJSON.Apply(engine.List(attributes...)), nil - case []any: - if len(v) == 0 { - return prolog.JSONEmptyArray(), nil - } - elements := make([]engine.Term, 0, len(v)) - for _, element := range v { - term, err := jsonToTerm(element) + if _, err = decoder.Token(); err != nil { + return nil, err + } + return term, nil + case "[": + term, err := decodeJSONArrayToTerm(decoder, env) if err != nil { return nil, err } - elements = append(elements, term) + if _, err = decoder.Token(); err != nil { + return nil, err + } + return term, nil } + case string: + return prolog.StringToAtom(t), nil + case float64: + return engine.NewFloatFromString(strconv.FormatFloat(t, 'f', -1, 64)) + case bool: + return prolog.JSONBool(t), nil + case nil: + return prolog.JSONNull(), nil + } - return engine.List(elements...), nil - default: - return nil, fmt.Errorf("unsupported type: %T", v) + return nil, jsonErrorToException(fmt.Errorf("unexpected token: %v", t), env) +} + +func decodeJSONArrayToTerm(decoder *json.Decoder, env *engine.Env) (engine.Term, error) { + var terms []engine.Term + for decoder.More() { + value, err := decodeJSONToTerm(decoder, env) + if err != nil { + return nil, err + } + terms = append(terms, value) + } + + if len(terms) == 0 { + return prolog.JSONEmptyArray(), nil } + + return engine.List(terms...), nil +} + +func decodeJSONObjectToTerm(decoder *json.Decoder, env *engine.Env) (engine.Term, error) { + var terms []engine.Term + for decoder.More() { + keyToken, err := nextToken(decoder, env) + if err != nil { + return nil, err + } + key := keyToken.(string) + value, err := decodeJSONToTerm(decoder, env) + if err != nil { + return nil, err + } + terms = append(terms, prolog.AtomPair.Apply(prolog.StringToAtom(key), value)) + } + + return prolog.AtomJSON.Apply(engine.List(terms...)), nil } diff --git a/x/logic/prolog/error.go b/x/logic/prolog/error.go index 8ed3cf01..d335ba79 100644 --- a/x/logic/prolog/error.go +++ b/x/logic/prolog/error.go @@ -46,7 +46,7 @@ var ( // AtomTypePair is the term used to indicate the pair type. AtomTypePair = engine.NewAtom("pair") // AtomTypeJSON is the term used to indicate the json type. - AtomTypeJSON = AtomJSON + AtomTypeJSON = engine.NewAtom("json") // AtomTypeURIComponent is the term used to represent the URI component type. AtomTypeURIComponent = engine.NewAtom("uri_component") ) @@ -56,7 +56,7 @@ var ( // The valid encoding atom is a compound with the name of the encoding which is a valid encoding with // regard to the predicate where it is used. // - // For instance: valid_encoding(utf8), valid_encoding(hex). + // For instance: encoding(utf8), encoding(hex). AtomValidEncoding = engine.NewAtom("encoding") // AtomValidEmptyList is the atom denoting a valid empty list. AtomValidEmptyList = engine.NewAtom("empty_list") diff --git a/x/logic/prolog/json.go b/x/logic/prolog/json.go index fc92d718..e5cd8537 100644 --- a/x/logic/prolog/json.go +++ b/x/logic/prolog/json.go @@ -4,62 +4,42 @@ import ( "github.com/axone-protocol/prolog/engine" ) +var ( + nullTerm = AtomAt.Apply(AtomNull) + emptyArrayTerm = AtomAt.Apply(AtomEmptyArray) + trueTerm = AtomAt.Apply(AtomTrue) + falseTerm = AtomAt.Apply(AtomFalse) +) + // JSONNull returns the compound term @(null). // It is used to represent the null value in json objects. func JSONNull() engine.Term { - return AtomAt.Apply(AtomNull) + return nullTerm } // JSONBool returns the compound term @(true) if b is true, otherwise @(false). func JSONBool(b bool) engine.Term { if b { - return AtomAt.Apply(AtomTrue) + return trueTerm } - return AtomAt.Apply(AtomFalse) + return falseTerm } // JSONEmptyArray returns is the compound term @([]). // It is used to represent the empty array in json objects. func JSONEmptyArray() engine.Term { - return AtomAt.Apply(AtomEmptyArray) + return emptyArrayTerm } -// ExtractJSONTerm is a utility function that would extract all attribute of a JSON object -// that is represented in prolog with the `json` atom. -// -// This function will ensure the json atom follow our json object representation in prolog. -// -// A JSON object is represented like this : -// -// ``` -// json([foo-bar]) -// ``` -// -// That give a JSON object: `{"foo": "bar"}` -// Returns the map of all attributes with its term value. -func ExtractJSONTerm(term engine.Compound, env *engine.Env) (map[string]engine.Term, error) { - if term.Functor() != AtomJSON || term.Arity() != 1 { - return nil, engine.TypeError(AtomTypeJSON, term, env) - } - - iter, err := ListIterator(term.Arg(0), env) - if err != nil { - return nil, err - } - terms := make(map[string]engine.Term, 0) - for iter.Next() { - current := iter.Current() - pair, ok := current.(engine.Compound) - if !ok || pair.Functor() != AtomPair || pair.Arity() != 2 { - return nil, engine.TypeError(AtomTypePair, current, env) - } - - key, ok := pair.Arg(0).(engine.Atom) - if !ok { - return nil, engine.TypeError(AtomTypeAtom, pair.Arg(0), env) +// AssertJSON resolves a term as a JSON object and returns it as engine.Compound. +// If conversion fails, the function returns nil and the error. +func AssertJSON(term engine.Term, env *engine.Env) (engine.Compound, error) { + if compound, ok := env.Resolve(term).(engine.Compound); ok { + if compound.Functor() == AtomJSON && compound.Arity() == 1 { + return compound, nil } - terms[key.String()] = pair.Arg(1) } - return terms, nil + + return nil, engine.TypeError(AtomTypeJSON, term, env) } diff --git a/x/logic/prolog/list.go b/x/logic/prolog/list.go index a61cacba..309138bd 100644 --- a/x/logic/prolog/list.go +++ b/x/logic/prolog/list.go @@ -1,6 +1,8 @@ package prolog -import "github.com/axone-protocol/prolog/engine" +import ( + "github.com/axone-protocol/prolog/engine" +) // ListHead returns the first element of the given list. func ListHead(list engine.Term, env *engine.Env) engine.Term { @@ -18,3 +20,30 @@ func ListIterator(list engine.Term, env *engine.Env) (engine.ListIterator, error } return engine.ListIterator{List: list, Env: env}, nil } + +// ForEach iterates over the elements of the given list and calls the given function for each element. +func ForEach(list engine.Term, env *engine.Env, f func(v engine.Term, hasNext bool) error) error { + iter, err := ListIterator(list, env) + if err != nil { + return err + } + + if !iter.Next() { + return nil + } + + for { + elem := iter.Current() + hasNext := iter.Next() + + if err := f(elem, hasNext); err != nil { + return err + } + + if !hasNext { + break + } + } + + return nil +}