diff --git a/docs/predicate/json_prolog_2.md b/docs/predicate/json_prolog_2.md index bd803abf..49ca6d65 100644 --- a/docs/predicate/json_prolog_2.md +++ b/docs/predicate/json_prolog_2.md @@ -7,7 +7,7 @@ sidebar_position: 15 ## Description -`json_prolog/2` is a predicate that will unify a JSON string into prolog terms and vice versa. +`json_prolog/2` is a predicate that unifies a JSON into a prolog term and vice versa. The signature is as follows: @@ -17,10 +17,19 @@ json_prolog(?Json, ?Term) is det Where: -- Json is the string representation of the json -- Term is an Atom that would be unified by the JSON representation as Prolog terms. +- Json is the textual representation of the JSON, as either an atom, a list of character codes, or a list of characters. +- Term is the Prolog term that represents the JSON structure. -In addition, when passing Json and Term, this predicate return true if both result match. +## JSON canonical representation + +The canonical representation for Term is: + +- A JSON object is mapped to a Prolog term json\(NameValueList\), where NameValueList is a list of Name\-Value pairs. Name is an atom created from the JSON string. +- A JSON array is mapped to a Prolog list of JSON values. +- A JSON string is mapped to a Prolog atom. +- A JSON number is mapped to a Prolog number. +- The JSON constants true and false are mapped to @\(true\) and @\(false\). +- The JSON constant null is mapped to the Prolog term @\(null\). ## Examples diff --git a/x/logic/predicate/json.go b/x/logic/predicate/json.go index 39ae1304..8fdbf727 100644 --- a/x/logic/predicate/json.go +++ b/x/logic/predicate/json.go @@ -2,31 +2,40 @@ package predicate import ( "encoding/json" + "errors" "fmt" "sort" + "strconv" "strings" "github.com/axone-protocol/prolog/engine" "github.com/samber/lo" - "cosmossdk.io/math" - sdk "github.com/cosmos/cosmos-sdk/types" "github.com/axone-protocol/axoned/v10/x/logic/prolog" ) -// JSONProlog is a predicate that will unify a JSON string into prolog terms and vice versa. +// JSONProlog is a predicate that unifies a JSON into a prolog term and vice versa. // // The signature is as follows: // // json_prolog(?Json, ?Term) is det // // Where: -// - Json is the string representation of the json -// - Term is an Atom that would be unified by the JSON representation as Prolog terms. +// - Json is the textual representation of the JSON, as either an atom, a list of character codes, or a list of characters. +// - Term is the Prolog term that represents the JSON structure. +// +// # JSON canonical representation // -// In addition, when passing Json and Term, this predicate return true if both result match. +// The canonical representation for Term is: +// - A JSON object is mapped to a Prolog term json(NameValueList), where NameValueList is a list of Name-Value pairs. +// Name is an atom created from the JSON string. +// - A JSON array is mapped to a Prolog list of JSON values. +// - A JSON string is mapped to a Prolog atom. +// - A JSON number is mapped to a Prolog number. +// - The JSON constants true and false are mapped to @(true) and @(false). +// - The JSON constant null is mapped to the Prolog term @(null). // // # Examples: // @@ -37,14 +46,12 @@ func JSONProlog(vm *engine.VM, j, term engine.Term, cont engine.Cont, env *engin switch t1 := env.Resolve(j).(type) { case engine.Variable: - case engine.Atom: - terms, err := jsonStringToTerms(t1, env) + default: + terms, err := decodeJSONToTerm(t1, env) if err != nil { return engine.Error(err) } result = terms - default: - return engine.Error(engine.TypeError(prolog.AtomTypeAtom, j, env)) } switch t2 := env.Resolve(term).(type) { @@ -54,7 +61,7 @@ func JSONProlog(vm *engine.VM, j, term engine.Term, cont engine.Cont, env *engin } return engine.Unify(vm, term, result, cont, env) default: - b, err := termsToJSON(t2, env) + b, err := encodeTermToJSON(t2, env) if err != nil { return engine.Error(err) } @@ -65,14 +72,20 @@ func JSONProlog(vm *engine.VM, j, term engine.Term, cont engine.Cont, env *engin prolog.WithError( engine.DomainError(prolog.ValidEncoding("json"), term, env), err, env)) } - var r engine.Term = engine.NewAtom(string(b)) + var r engine.Term = prolog.BytesToAtom(b) return engine.Unify(vm, j, r, cont, env) } } -func jsonStringToTerms(j engine.Atom, env *engine.Env) (engine.Term, error) { +// 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(j.String())) + 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 { @@ -80,7 +93,7 @@ func jsonStringToTerms(j engine.Atom, env *engine.Env) (engine.Term, error) { engine.DomainError(prolog.ValidEncoding("json"), j, env), err, env) } - term, err := jsonToTerms(values) + term, err := jsonToTerm(values) if err != nil { return nil, prolog.WithError( engine.DomainError(prolog.ValidEncoding("json"), j, env), err, env) @@ -89,83 +102,89 @@ func jsonStringToTerms(j engine.Atom, env *engine.Env) (engine.Term, error) { return term, nil } -func termsToJSON(term engine.Term, env *engine.Env) ([]byte, error) { - asDomainError := func(bs []byte, err error) ([]byte, error) { - if err != nil { - return bs, prolog.WithError( - engine.DomainError(prolog.ValidEncoding("json"), term, env), err, env) - } - return bs, err +// 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 bs, err +} + +func termToJSON(term engine.Term, env *engine.Env) ([]byte, error) { switch t := term.(type) { case engine.Atom: - return asDomainError(json.Marshal(t.String())) + return json.Marshal(t.String()) case engine.Integer: - return asDomainError(json.Marshal(t)) + return json.Marshal(t) + case engine.Float: + float, err := strconv.ParseFloat(t.String(), 64) + if err != nil { + return nil, err + } + + return json.Marshal(float) case engine.Compound: - switch { - case t.Functor() == prolog.AtomDot: - iter, err := prolog.ListIterator(t, env) + return compoundToJSON(t, env) + } + + return nil, engine.TypeError(prolog.AtomTypeJSON, term, env) +} + +func compoundToJSON(term engine.Compound, env *engine.Env) ([]byte, error) { + switch { + case term.Functor() == prolog.AtomDot: + iter, err := prolog.ListIterator(term, env) + if err != nil { + return nil, 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) + } + return json.Marshal(elements) + case term.Functor() == prolog.AtomJSON: + terms, err := prolog.ExtractJSONTerm(term, env) + if err != nil { + return nil, err + } - elements := make([]json.RawMessage, 0) - for iter.Next() { - element, err := termsToJSON(env.Resolve(iter.Current()), env) - if err != nil { - return nil, err - } - elements = append(elements, element) - } - return asDomainError(json.Marshal(elements)) - case t.Functor() == prolog.AtomJSON: - terms, err := prolog.ExtractJSONTerm(t, env) + 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 := make(map[string]json.RawMessage, len(terms)) - for key, term := range terms { - raw, err := termsToJSON(env.Resolve(term), env) - if err != nil { - return nil, err - } - attributes[key] = raw - } - return asDomainError(json.Marshal(attributes)) - case prolog.JSONBool(true).Compare(t, env) == 0: - return asDomainError(json.Marshal(true)) - case prolog.JSONBool(false).Compare(t, env) == 0: - return asDomainError(json.Marshal(false)) - case prolog.JSONEmptyArray().Compare(t, env) == 0: - return asDomainError(json.Marshal([]json.RawMessage{})) - case prolog.JSONNull().Compare(t, env) == 0: - return asDomainError(json.Marshal(nil)) - default: - // no-op + attributes[key] = raw } - default: - // no-op + 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, engine.TypeError(prolog.AtomTypeJSON, term, env) } -func jsonToTerms(value any) (engine.Term, error) { +func jsonToTerm(value any) (engine.Term, error) { switch v := value.(type) { case string: - var r engine.Term = engine.NewAtom(v) - return r, nil + return prolog.StringToAtom(v), nil case json.Number: - r, ok := math.NewIntFromString(string(v)) - if !ok { - return nil, fmt.Errorf("could not convert number '%s' into integer term, decimal number is not handled yet", v) - } - if !r.IsInt64() { - return nil, fmt.Errorf("could not convert number '%s' into integer term, overflow", v) - } - return engine.Integer(r.Int64()), nil + return engine.NewFloatFromString(v.String()) case bool: return prolog.JSONBool(v), nil case nil: @@ -176,26 +195,26 @@ func jsonToTerms(value any) (engine.Term, error) { attributes := make([]engine.Term, 0, len(v)) for _, key := range keys { - attributeValue, err := jsonToTerms(v[key]) + attributeValue, err := jsonToTerm(v[key]) if err != nil { return nil, err } - attributes = append(attributes, prolog.AtomPair.Apply(engine.NewAtom(key), attributeValue)) + attributes = append(attributes, prolog.AtomPair.Apply(prolog.StringToAtom(key), attributeValue)) } return prolog.AtomJSON.Apply(engine.List(attributes...)), nil case []any: - elements := make([]engine.Term, 0, len(v)) if len(v) == 0 { return prolog.JSONEmptyArray(), nil } - + elements := make([]engine.Term, 0, len(v)) for _, element := range v { - term, err := jsonToTerms(element) + term, err := jsonToTerm(element) if err != nil { return nil, err } elements = append(elements, term) } + return engine.List(elements...), nil default: return nil, fmt.Errorf("unsupported type: %T", v) diff --git a/x/logic/predicate/json_test.go b/x/logic/predicate/json_test.go index 3efe45ea..6820f5e0 100644 --- a/x/logic/predicate/json_test.go +++ b/x/logic/predicate/json_test.go @@ -3,7 +3,6 @@ package predicate import ( "fmt" - "strings" "testing" "github.com/axone-protocol/prolog/engine" @@ -39,10 +38,10 @@ func TestJsonProlog(t *testing.T) { wantError: fmt.Errorf("error(instantiation_error,json_prolog/2)"), }, { - description: "two variable", + description: "incorrect 1st argument", query: `json_prolog(ooo(r), Term).`, wantSuccess: false, - wantError: fmt.Errorf("error(type_error(atom,ooo(r)),json_prolog/2)"), + wantError: fmt.Errorf("error(type_error(text,ooo(r)),json_prolog/2)"), }, // ** JSON -> Prolog ** @@ -73,6 +72,15 @@ func TestJsonProlog(t *testing.T) { }}, wantSuccess: true, }, + { + description: "convert json object (given as string) into prolog", + query: `json_prolog("{\"foo\": \"bar\"}" + , Term).`, + wantResult: []testutil.TermResults{{ + "Term": "json([foo-bar])", + }}, + wantSuccess: true, + }, { description: "convert json object with multiple attribute into prolog", query: `json_prolog('{"foo": "bar", "foobar": "bar foo"}', Term).`, @@ -100,26 +108,42 @@ func TestJsonProlog(t *testing.T) { // ** JSON -> Prolog ** // Number { - description: "convert json number into prolog", + description: "convert json 0 number into prolog", + query: `json_prolog('0', Term).`, + wantResult: []testutil.TermResults{{ + "Term": "0.0", + }}, + wantSuccess: true, + }, + { + description: "convert json 10 number into prolog", query: `json_prolog('10', Term).`, wantResult: []testutil.TermResults{{ - "Term": "10", + "Term": "10.0", + }}, + wantSuccess: true, + }, + { + description: "convert json -10.9 number into prolog", + query: `json_prolog('-10.9', Term).`, + wantResult: []testutil.TermResults{{ + "Term": "-10.9", }}, wantSuccess: true, }, { description: "convert large json number into prolog", query: `json_prolog('100000000000000000000', Term).`, - wantSuccess: false, - wantError: fmt.Errorf("error(domain_error(encoding(json),100000000000000000000),[%s],json_prolog/2)", - strings.Join(strings.Split("could not convert number '100000000000000000000' into integer term, overflow", ""), ",")), + wantResult: []testutil.TermResults{{ + "Term": "100000000000000000000.0", + }}, + wantSuccess: true, }, { - description: "decimal number not compatible yet", - query: `json_prolog('10.4', Term).`, + description: "convert large json number with ridonculous exponent into prolog", + query: `json_prolog('1E30923434', Term).`, wantSuccess: false, - wantError: fmt.Errorf("error(domain_error(encoding(json),10.4),[%s],json_prolog/2)", - strings.Join(strings.Split("could not convert number '10.4' into integer term, decimal number is not handled yet", ""), ",")), + wantError: fmt.Errorf("error(domain_error(encoding(json),1E30923434),[u,n,d,e,f,i,n,e,d],json_prolog/2)"), }, // ** JSON -> Prolog ** // Bool @@ -183,7 +207,6 @@ func TestJsonProlog(t *testing.T) { }}, wantSuccess: true, }, - // ** Prolog -> JSON ** // String { @@ -281,7 +304,15 @@ func TestJsonProlog(t *testing.T) { // ** Prolog -> JSON ** // Number { - description: "convert json number from prolog", + description: "convert prolog 0 number", + query: `json_prolog(Json, 0).`, + wantResult: []testutil.TermResults{{ + "Json": "'0'", + }}, + wantSuccess: true, + }, + { + description: "convert prolog 10 number", query: `json_prolog(Json, 10).`, wantResult: []testutil.TermResults{{ "Json": "'10'", @@ -289,10 +320,42 @@ func TestJsonProlog(t *testing.T) { wantSuccess: true, }, { - description: "decimal number not compatible yet", + description: "convert prolog decimal 10.4 number", query: `json_prolog(Json, 10.4).`, + wantResult: []testutil.TermResults{{ + "Json": "'10.4'", + }}, + wantSuccess: true, + }, + { + description: "convert prolog decimal -10.4 number", + query: `json_prolog(Json, -10.4).`, + wantResult: []testutil.TermResults{{ + "Json": "'-10.4'", + }}, + wantSuccess: true, + }, + { + description: "convert big prolog decimal", + query: `json_prolog(Json, 100000000000000000000.0).`, + wantResult: []testutil.TermResults{{ + "Json": "'100000000000000000000'", + }}, + wantSuccess: true, + }, + { + description: "convert prolog decimal with exponent", + query: `json_prolog(Json, 1.0E99).`, + wantResult: []testutil.TermResults{{ + "Json": "'1e+99'", + }}, + wantSuccess: true, + }, + { + description: "convert prolog decimal with ridonculous exponent", + query: `json_prolog(Json, 1.8e308).`, wantSuccess: false, - wantError: fmt.Errorf("error(type_error(json,10.4),json_prolog/2)"), + wantError: fmt.Errorf("error(domain_error(encoding(json),1.8e+308),[s,t,r,c,o,n,v,.,P,a,r,s,e,F,l,o,a,t,:, ,p,a,r,s,i,n,g, ,\",1,.,8,e,+,3,0,8,\",:, ,v,a,l,u,e, ,o,u,t, ,o,f, ,r,a,n,g,e],json_prolog/2)"), }, // ** Prolog -> Json ** // Array @@ -445,7 +508,7 @@ func TestJsonPrologWithMoreComplexStructBidirectional(t *testing.T) { }, { json: "'{\"employee\":{\"age\":30,\"city\":\"New York\",\"name\":\"John\"}}'", - term: "json([employee-json([age-30,city-'New York',name-'John'])])", + term: "json([employee-json([age-30.0,city-'New York',name-'John'])])", wantSuccess: true, }, { @@ -455,7 +518,7 @@ func TestJsonPrologWithMoreComplexStructBidirectional(t *testing.T) { }, { json: "'{\"object\":{\"array\":[1,2,3],\"arrayobject\":[{\"name\":\"toto\"},{\"name\":\"tata\"}],\"bool\":true,\"boolean\":false,\"null\":null}}'", - term: "json([object-json([array-[1,2,3],arrayobject-[json([name-toto]),json([name-tata])],bool- @(true),boolean- @(false),null- @(null)])])", + term: "json([object-json([array-[1.0,2.0,3.0],arrayobject-[json([name-toto]),json([name-tata])],bool- @(true),boolean- @(false),null- @(null)])])", wantSuccess: true, }, { diff --git a/x/logic/prolog/byte.go b/x/logic/prolog/byte.go index 30f5bbb0..499472ea 100644 --- a/x/logic/prolog/byte.go +++ b/x/logic/prolog/byte.go @@ -30,3 +30,8 @@ func BytesToByteListTerm(in []byte) engine.Term { } return engine.List(terms...) } + +// BytesToAtom converts a given golang []byte into an Atom. +func BytesToAtom(in []byte) engine.Atom { + return engine.NewAtom(string(in)) +} diff --git a/x/logic/prolog/byte_test.go b/x/logic/prolog/byte_test.go new file mode 100644 index 00000000..3d0bf9bc --- /dev/null +++ b/x/logic/prolog/byte_test.go @@ -0,0 +1,48 @@ +package prolog + +import ( + "fmt" + "testing" + + "github.com/axone-protocol/prolog/engine" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestBytesToAtom(t *testing.T) { + Convey("Given the BytesToAtom function", t, func() { + Convey("It should correctly convert byte slices to atoms", func() { + cases := []struct { + bytes []byte + want engine.Atom + }{ + { + bytes: []byte(""), + want: engine.NewAtom(""), + }, + { + bytes: []byte("foo bar"), + want: engine.NewAtom("foo bar"), + }, + { + bytes: []byte("こんにちは"), + want: engine.NewAtom("こんにちは"), + }, + { + bytes: []byte{0xF0, 0x9F, 0x98, 0x80}, + want: engine.NewAtom("😀"), + }, + } + + for _, tc := range cases { + Convey(fmt.Sprintf("When converting '%s", tc.bytes), func() { + got := BytesToAtom(tc.bytes) + + Convey("Then the result should match the expected value", func() { + So(got, ShouldEqual, tc.want) + }) + }) + } + }) + }) +}