diff --git a/docs/predicate/json_prolog_2.md b/docs/predicate/json_prolog_2.md index 49ca6d65..d5dbdea7 100644 --- a/docs/predicate/json_prolog_2.md +++ b/docs/predicate/json_prolog_2.md @@ -24,7 +24,7 @@ Where: 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 object is mapped to a Prolog term json\(NameValueList\), where NameValueList is a list of Name=Value key values. 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. @@ -35,5 +35,5 @@ The canonical representation for Term is: ```text # JSON conversion to Prolog. -- json_prolog('{"foo": "bar"}', json([foo-bar])). +- json_prolog('{"foo": "bar"}', json([foo=bar])). ``` diff --git a/x/logic/predicate/json.go b/x/logic/predicate/json.go index 8fdbf727..8cb34a3c 100644 --- a/x/logic/predicate/json.go +++ b/x/logic/predicate/json.go @@ -1,21 +1,37 @@ package predicate import ( + "bytes" "encoding/json" "errors" "fmt" - "sort" + "io" "strconv" "strings" "github.com/axone-protocol/prolog/engine" "github.com/samber/lo" - sdk "github.com/cosmos/cosmos-sdk/types" - "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: @@ -29,7 +45,7 @@ import ( // # 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. +// - A JSON object is mapped to a Prolog term json(NameValueList), where NameValueList is a list of Name=Value key values. // 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. @@ -40,183 +56,248 @@ import ( // # Examples: // // # JSON conversion to Prolog. -// - json_prolog('{"foo": "bar"}', json([foo-bar])). -func JSONProlog(vm *engine.VM, j, term engine.Term, cont engine.Cont, env *engine.Env) *engine.Promise { - var result engine.Term - - switch t1 := env.Resolve(j).(type) { - case engine.Variable: - default: - terms, err := decodeJSONToTerm(t1, env) +// - json_prolog('{"foo": "bar"}', json([foo=bar])). +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 engine.Error(err) + return nil, err } - result = terms - } - switch t2 := env.Resolve(term).(type) { - case engine.Variable: - if result == nil { - return engine.Error(engine.InstantiationError(env)) - } - return engine.Unify(vm, term, result, cont, env) - default: - b, err := encodeTermToJSON(t2, env) + decoder := json.NewDecoder(strings.NewReader(payload)) + term, err := decodeJSONToTerm(decoder, env) if err != nil { - return engine.Error(err) + return nil, err } - - b, err = sdk.SortJSON(b) - if err != nil { - return engine.Error( - prolog.WithError( - engine.DomainError(prolog.ValidEncoding("json"), term, 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) } - var r engine.Term = prolog.BytesToAtom(b) - return engine.Unify(vm, j, r, 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) + 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 + } - term, err := jsonToTerm(values) - if err != nil { - return nil, prolog.WithError( - engine.DomainError(prolog.ValidEncoding("json"), j, env), err, env) - } - - 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()) + if term == prolog.AtomEmptyList { + buf.Write([]byte("[]")) + } else { + return marshalToBuffer(t.String(), term, buf, env) + } case engine.Integer: - return json.Marshal(t) + return marshalToBuffer(t, term, buf, env) case engine.Float: float, err := strconv.ParseFloat(t.String(), 64) if err != nil { - return nil, err + return prologErrorToException(t, err, env) } - - return json.Marshal(float) + return marshalToBuffer(float, term, buf, env) 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 marshalToBuffer(data any, term engine.Term, buf *bytes.Buffer, env *engine.Env) error { + bs, err := json.Marshal(data) + if err != nil { + return prologErrorToException(term, err, env) + } + buf.Write(bs) + + return nil +} + +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.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.AssertKeyValue(t, env) if err != nil { - return nil, err + return err + } + if err := marshalToBuffer(k.String(), term, buf, env); err != nil { + return err + } + 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) } + + 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.AtomKeyValue.Apply(prolog.StringToAtom(key), value)) + } + + return prolog.AtomJSON.Apply(engine.List(terms...)), nil } diff --git a/x/logic/predicate/json_test.go b/x/logic/predicate/json_test.go index 6820f5e0..813cb67d 100644 --- a/x/logic/predicate/json_test.go +++ b/x/logic/predicate/json_test.go @@ -43,7 +43,6 @@ func TestJsonProlog(t *testing.T) { wantSuccess: false, wantError: fmt.Errorf("error(type_error(text,ooo(r)),json_prolog/2)"), }, - // ** JSON -> Prolog ** // String { @@ -68,16 +67,15 @@ func TestJsonProlog(t *testing.T) { description: "convert json object into prolog", query: `json_prolog('{"foo": "bar"}', Term).`, wantResult: []testutil.TermResults{{ - "Term": "json([foo-bar])", + "Term": "json([foo=bar])", }}, wantSuccess: true, }, { description: "convert json object (given as string) into prolog", - query: `json_prolog("{\"foo\": \"bar\"}" - , Term).`, + query: `json_prolog("{\"foo\": \"bar\"}", Term).`, wantResult: []testutil.TermResults{{ - "Term": "json([foo-bar])", + "Term": "json([foo=bar])", }}, wantSuccess: true, }, @@ -85,7 +83,7 @@ func TestJsonProlog(t *testing.T) { description: "convert json object with multiple attribute into prolog", query: `json_prolog('{"foo": "bar", "foobar": "bar foo"}', Term).`, wantResult: []testutil.TermResults{{ - "Term": "json([foo-bar,foobar-'bar foo'])", + "Term": "json([foo=bar,foobar='bar foo'])", }}, wantSuccess: true, }, @@ -93,15 +91,15 @@ func TestJsonProlog(t *testing.T) { description: "convert json object with attribute with a space into prolog", query: `json_prolog('{"string with space": "bar"}', Term).`, wantResult: []testutil.TermResults{{ - "Term": "json(['string with space'-bar])", + "Term": "json(['string with space'=bar])", }}, wantSuccess: true, }, { - description: "ensure determinism on object attribute key sorted alphabetically", + description: "ensure prolog encoded json follows same order as json", query: `json_prolog('{"b": "a", "a": "b"}', Term).`, wantResult: []testutil.TermResults{{ - "Term": "json([a-b,b-a])", + "Term": "json([b=a,a=b])", }}, wantSuccess: true, }, @@ -139,12 +137,6 @@ func TestJsonProlog(t *testing.T) { }}, wantSuccess: true, }, - { - 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),1E30923434),[u,n,d,e,f,i,n,e,d],json_prolog/2)"), - }, // ** JSON -> Prolog ** // Bool { @@ -179,7 +171,7 @@ func TestJsonProlog(t *testing.T) { description: "convert empty json array into prolog", query: `json_prolog('[]', Term).`, wantResult: []testutil.TermResults{{ - "Term": "@([])", + "Term": "[]", }}, wantSuccess: true, }, @@ -207,6 +199,50 @@ func TestJsonProlog(t *testing.T) { }}, wantSuccess: true, }, + // ** JSON -> Prolog ** + // Pathological + { + description: "convert an object with an invalid key type (numeric) to Prolog", + query: `json_prolog('{5:"bar"}', Term).`, + wantError: fmt.Errorf("error(syntax_error(json(malformed_json(1))),json_prolog/2)"), + wantSuccess: false, + }, + { + description: "convert incorrect json into prolog", + query: `json_prolog('@wtf!', Term).`, + wantError: fmt.Errorf("error(syntax_error(json(malformed_json(1))),json_prolog/2)"), + wantSuccess: false, + }, + { + description: "convert large json number with ridonculous exponent into prolog", + query: `json_prolog('1E30923434', Term).`, + wantSuccess: false, + wantError: fmt.Errorf("error(syntax_error(json(malformed_json(11,number 1E30923434))),json_prolog/2)"), + }, + { + description: "convert unfinished json into prolog", + query: `json_prolog('{"foo": ', Term).`, + wantError: fmt.Errorf("error(syntax_error(json(eof)),json_prolog/2)"), + wantSuccess: false, + }, + { + description: "check json array is well formed", + query: `json_prolog('[&', Term).`, + wantError: fmt.Errorf("error(syntax_error(json(malformed_json(1))),json_prolog/2)"), + wantSuccess: false, + }, + { + description: "check json object is well formed (1)", + query: `json_prolog('{"foo": "bar"}{"foo": "bar"}', Term).`, + wantError: fmt.Errorf("error(syntax_error(json(malformed_json(15))),json_prolog/2)"), + wantSuccess: false, + }, + { + description: "check json object is well formed (2)", + query: `json_prolog('{&', Term).`, + wantError: fmt.Errorf("error(syntax_error(json(malformed_json(1))),json_prolog/2)"), + wantSuccess: false, + }, // ** Prolog -> JSON ** // String { @@ -241,19 +277,11 @@ func TestJsonProlog(t *testing.T) { }}, wantSuccess: true, }, - { - description: "convert empty-list atom term to json", - query: `json_prolog(Json, []).`, - wantResult: []testutil.TermResults{{ - "Json": "'\"[]\"'", - }}, - wantSuccess: true, - }, // ** Prolog -> JSON ** // Object { description: "convert json object from prolog", - query: `json_prolog(Json, json([foo-bar])).`, + query: `json_prolog(Json, json([foo=bar])).`, wantResult: []testutil.TermResults{{ "Json": "'{\"foo\":\"bar\"}'", }}, @@ -261,7 +289,7 @@ func TestJsonProlog(t *testing.T) { }, { description: "convert json object with multiple attribute from prolog", - query: `json_prolog(Json, json([foo-bar,foobar-'bar foo'])).`, + query: `json_prolog(Json, json([foo=bar,foobar='bar foo'])).`, wantResult: []testutil.TermResults{{ "Json": "'{\"foo\":\"bar\",\"foobar\":\"bar foo\"}'", }}, @@ -269,25 +297,25 @@ func TestJsonProlog(t *testing.T) { }, { description: "convert json object with attribute with a space into prolog", - query: `json_prolog(Json, json(['string with space'-bar])).`, + query: `json_prolog(Json, json(['string with space'=bar])).`, wantResult: []testutil.TermResults{{ "Json": "'{\"string with space\":\"bar\"}'", }}, wantSuccess: true, }, { - description: "ensure determinism on object attribute key sorted alphabetically", - query: `json_prolog(Json, json([b-a,a-b])).`, + description: "ensure json follows same order as prolog encoded", + query: `json_prolog(Json, json([b=a,a=b])).`, wantResult: []testutil.TermResults{{ - "Json": "'{\"a\":\"b\",\"b\":\"a\"}'", + "Json": "'{\"b\":\"a\",\"a\":\"b\"}'", }}, wantSuccess: true, }, { description: "invalid json term compound", - query: `json_prolog(Json, foo([a-b])).`, + query: `json_prolog(Json, foo([a=b])).`, wantSuccess: false, - wantError: fmt.Errorf("error(type_error(json,foo([-(a,b)])),json_prolog/2)"), + wantError: fmt.Errorf("error(type_error(json,foo([=(a,b)])),json_prolog/2)"), }, { description: "convert json term object from prolog with error inside", @@ -297,10 +325,28 @@ func TestJsonProlog(t *testing.T) { }, { description: "convert json term object from prolog with error inside another object", - query: `json_prolog(Json, ['string with space',json([key-json(error)])]).`, + query: `json_prolog(Json, ['string with space',json([key=json(error)])]).`, wantSuccess: false, wantError: fmt.Errorf("error(type_error(list,error),json_prolog/2)"), }, + { + description: "convert json term object which incorrectly defines key/value pair", + query: `json_prolog(Json, json([not_a_key_value(key,value)])).`, + wantSuccess: false, + wantError: fmt.Errorf("error(type_error(key_value,not_a_key_value(key,value)),json_prolog/2)"), + }, + { + description: "convert json term object which uses a non atom for key", + query: `json_prolog(Json, json([=(42,value)])).`, + wantSuccess: false, + wantError: fmt.Errorf("error(type_error(atom,42),json_prolog/2)"), + }, + { + description: "convert json term object with arity > 2", + query: `json_prolog(Json, json(a,b,c)).`, + wantSuccess: false, + wantError: fmt.Errorf("error(type_error(json,json(a,b,c)),json_prolog/2)"), + }, // ** Prolog -> JSON ** // Number { @@ -311,6 +357,14 @@ func TestJsonProlog(t *testing.T) { }}, wantSuccess: true, }, + { + description: "convert prolog array of numbers", + query: `json_prolog(Json, [1, 2, 3]).`, + wantResult: []testutil.TermResults{{ + "Json": "'[1,2,3]'", + }}, + wantSuccess: true, + }, { description: "convert prolog 10 number", query: `json_prolog(Json, 10).`, @@ -355,13 +409,13 @@ func TestJsonProlog(t *testing.T) { description: "convert prolog decimal with ridonculous exponent", query: `json_prolog(Json, 1.8e308).`, wantSuccess: false, - 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)"), + wantError: fmt.Errorf("error(domain_error(json_number,1.8e+308),json_prolog/2)"), }, // ** Prolog -> Json ** // Array { description: "convert empty json array from prolog", - query: `json_prolog(Json, @([])).`, + query: `json_prolog(Json, []).`, wantResult: []testutil.TermResults{{ "Json": "[]", }}, @@ -425,6 +479,13 @@ func TestJsonProlog(t *testing.T) { }}, wantSuccess: true, }, + // ** JSON <-> Prolog ** + { + description: "ensure unification doesn't depend on formatting", + query: `json_prolog('{\n\t"foo": "bar"\n}', json( [ foo = bar ] )).`, + wantResult: []testutil.TermResults{{}}, + wantSuccess: true, + }, } for nc, tc := range cases { Convey(fmt.Sprintf("Given the query #%d: %s", nc, tc.query), func() { @@ -493,42 +554,42 @@ func TestJsonPrologWithMoreComplexStructBidirectional(t *testing.T) { }{ { json: "'{\"foo\":\"bar\"}'", - term: "json([foo-bar])", + term: "json([foo=bar])", wantSuccess: true, }, { json: "'{\"foo\":\"null\"}'", - term: "json([foo-null])", + term: "json([foo=null])", wantSuccess: true, }, { json: "'{\"foo\":null}'", - term: "json([foo- @(null)])", + term: "json([foo= @(null)])", wantSuccess: true, }, { json: "'{\"employee\":{\"age\":30,\"city\":\"New York\",\"name\":\"John\"}}'", - term: "json([employee-json([age-30.0,city-'New York',name-'John'])])", + term: "json([employee=json([age=30.0,city='New York',name='John'])])", wantSuccess: true, }, { json: "'{\"cosmos\":[\"axone\",{\"name\":\"localnet\"}]}'", - term: "json([cosmos-[axone,json([name-localnet])]])", + term: "json([cosmos=[axone,json([name=localnet])]])", wantSuccess: true, }, { json: "'{\"object\":{\"array\":[1,2,3],\"arrayobject\":[{\"name\":\"toto\"},{\"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)])])", + 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, }, { json: "'{\"foo\":\"bar\"}'", - term: "json([a-b])", + term: "json([a=b])", wantSuccess: false, }, { json: `'{"key1":null,"key2":[],"key3":{"nestedKey1":null,"nestedKey2":[],"nestedKey3":["a",null,null]}}'`, - term: `json([key1- @(null),key2- @([]),key3-json([nestedKey1- @(null),nestedKey2- @([]),nestedKey3-[a,@(null),@(null)]])])`, + term: `json([key1= @(null),key2=[],key3=json([nestedKey1= @(null),nestedKey2=[],nestedKey3=[a,@(null),@(null)]])])`, wantSuccess: true, }, } diff --git a/x/logic/prolog/assert.go b/x/logic/prolog/assert.go index c61e24cd..fb6b5d4d 100644 --- a/x/logic/prolog/assert.go +++ b/x/logic/prolog/assert.go @@ -154,15 +154,40 @@ func AssertList(term engine.Term, env *engine.Env) (engine.Term, error) { // AssertPair resolves a term as a pair and returns the pair components. // If conversion fails, the function returns nil and the error. func AssertPair(term engine.Term, env *engine.Env) (engine.Term, engine.Term, error) { + return assertTuple2WithFunctor(term, AtomPair, AtomTypePair, env) +} + +// AssertKeyValue resolves a term as a key-value and returns its components, the key as an atom, +// and the value as a term. +// If conversion fails, the function returns nil and the error. +func AssertKeyValue(term engine.Term, env *engine.Env) (engine.Atom, engine.Term, error) { + k, v, err := assertTuple2WithFunctor(term, AtomKeyValue, AtomTypeKeyValue, env) + if err != nil { + return AtomEmpty, nil, err + } + + key, err := AssertAtom(k, env) + if err != nil { + return AtomEmpty, nil, err + } + + return key, v, err +} + +// assertTuple2WithFunctor resolves a term as a tuple and returns the tuple components based on the given functor. +// If conversion fails, the function returns nil and an error. +func assertTuple2WithFunctor( + term engine.Term, functor engine.Atom, functorType engine.Atom, env *engine.Env, +) (engine.Term, engine.Term, error) { term, err := AssertIsGround(term, env) if err != nil { return nil, nil, err } - if term, ok := term.(engine.Compound); ok && term.Functor() == AtomPair && term.Arity() == 2 { - return term.Arg(0), term.Arg(1), nil + if compound, ok := term.(engine.Compound); ok && compound.Functor() == functor && compound.Arity() == 2 { + return compound.Arg(0), compound.Arg(1), nil } - return nil, nil, engine.TypeError(AtomTypePair, term, env) + return nil, nil, engine.TypeError(functorType, term, env) } // AssertURIComponent resolves a term as a URI component and returns it as an URIComponent. diff --git a/x/logic/prolog/assert_test.go b/x/logic/prolog/assert_test.go index 5a97d020..9947667d 100644 --- a/x/logic/prolog/assert_test.go +++ b/x/logic/prolog/assert_test.go @@ -314,3 +314,175 @@ func TestAssertIsGround(t *testing.T) { }) }) } + +func TestAssertPair(t *testing.T) { + X := engine.NewVariable() + + Convey("Given a test cases", t, func() { + cases := []struct { + name string + term engine.Term + wantFirst engine.Term + wantSecond engine.Term + wantError error + }{ + { + name: "a valid pair", + term: AtomPair.Apply(StringToAtom("foo"), StringToAtom("bar")), + wantFirst: StringToAtom("foo"), + wantSecond: StringToAtom("bar"), + }, + { + name: "a pair with bounded variable", + term: AtomPair.Apply(X, engine.Integer(42)), + wantFirst: StringToAtom("x"), + wantSecond: engine.Integer(42), + }, + { + name: "a pair with unbounded variable", + term: AtomPair.Apply(engine.NewVariable(), StringToAtom("bar")), + wantError: fmt.Errorf("error(instantiation_error,root)"), + }, + { + name: "an atom", + term: StringToAtom("x"), + wantError: fmt.Errorf("error(type_error(pair,x),root)"), + }, + { + name: "an integer", + term: engine.Integer(42), + wantError: fmt.Errorf("error(type_error(pair,42),root)"), + }, + { + name: "a compound", + term: engine.NewAtom("foo").Apply(engine.NewAtom("bar")), + wantError: fmt.Errorf("error(type_error(pair,foo(bar)),root)"), + }, + { + name: "a pair with arity 1", + term: AtomPair.Apply(StringToAtom("foo")), + wantError: fmt.Errorf("error(type_error(pair,-(foo)),root)"), + }, + { + name: "a pair with arity > 1", + term: AtomPair.Apply(engine.Integer(1), engine.Integer(2), engine.Integer(3)), + wantError: fmt.Errorf("error(type_error(pair,-(1,2,3)),root)"), + }, + } + + Convey("and an environment", func() { + env, _ := engine.NewEnv().Unify(X, engine.NewAtom("x")) + for nc, tc := range cases { + Convey( + fmt.Sprintf("Given the test case %s (#%d)", tc.name, nc), func() { + Convey("When the function AssertPair() is called", func() { + first, second, err := AssertPair(tc.term, env) + Convey("Then it should return the expected output", func() { + if tc.wantError == nil { + So(first, ShouldEqual, tc.wantFirst) + So(second, ShouldEqual, tc.wantSecond) + So(err, ShouldBeNil) + } else { + So(err, ShouldBeError, tc.wantError) + } + }) + }) + }) + } + }) + }) +} + +func TestAssertKeyValue(t *testing.T) { + X := engine.NewVariable() + + Convey("Given a test cases", t, func() { + cases := []struct { + name string + term engine.Term + wantKey engine.Atom + wantValue engine.Term + wantError error + }{ + { + name: "a valid key-value pair", + term: AtomKeyValue.Apply(StringToAtom("key"), StringToAtom("value")), + wantKey: StringToAtom("key"), + wantValue: StringToAtom("value"), + }, + { + name: "a key-value pair with bounded variable key", + term: AtomKeyValue.Apply(X, engine.Integer(42)), + wantKey: StringToAtom("x"), + wantValue: engine.Integer(42), + }, + { + name: "a key-value pair with bounded variable value", + term: AtomKeyValue.Apply(StringToAtom("key"), X), + wantKey: StringToAtom("key"), + wantValue: StringToAtom("x"), + }, + { + name: "a key-value pair with non-atom key", + term: AtomKeyValue.Apply(engine.Integer(42), StringToAtom("value")), + wantError: fmt.Errorf("error(type_error(atom,42),root)"), + }, + { + name: "a key-value pair with unbounded variable key", + term: AtomKeyValue.Apply(engine.NewVariable(), StringToAtom("value")), + wantError: fmt.Errorf("error(instantiation_error,root)"), + }, + { + name: "a key-value pair with unbounded variable value", + term: AtomKeyValue.Apply(StringToAtom("key"), engine.NewVariable()), + wantError: fmt.Errorf("error(instantiation_error,root)"), + }, + { + name: "an atom", + term: StringToAtom("x"), + wantError: fmt.Errorf("error(type_error(key_value,x),root)"), + }, + { + name: "an integer", + term: engine.Integer(42), + wantError: fmt.Errorf("error(type_error(key_value,42),root)"), + }, + { + name: "a compound", + term: engine.NewAtom("foo").Apply(engine.NewAtom("bar")), + wantError: fmt.Errorf("error(type_error(key_value,foo(bar)),root)"), + }, + { + name: "a key-value pair with arity 1", + term: AtomKeyValue.Apply(StringToAtom("key")), + wantError: fmt.Errorf("error(type_error(key_value,=(key)),root)"), + }, + { + name: "a key-value pair with arity > 2", + term: AtomKeyValue.Apply(engine.Integer(1), engine.Integer(2), engine.Integer(3)), + wantError: fmt.Errorf("error(type_error(key_value,=(1,2,3)),root)"), + }, + } + + Convey("and an environment", func() { + env, _ := engine.NewEnv().Unify(X, engine.NewAtom("x")) + for nc, tc := range cases { + Convey( + fmt.Sprintf("Given the test case %s (#%d)", tc.name, nc), func() { + Convey("When the function AssertKeyValue() is called", func() { + key, value, err := AssertKeyValue(tc.term, env) + Convey("Then it should return the expected output", func() { + if tc.wantError == nil { + So(key, ShouldEqual, tc.wantKey) + So(value, ShouldEqual, tc.wantValue) + So(err, ShouldBeNil) + } else { + So(err, ShouldBeError, tc.wantError) + } + }) + }) + }) + } + }) + }) +} diff --git a/x/logic/prolog/atom.go b/x/logic/prolog/atom.go index 303b8558..7e71085e 100644 --- a/x/logic/prolog/atom.go +++ b/x/logic/prolog/atom.go @@ -13,8 +13,6 @@ var ( AtomDot = engine.NewAtom(".") // AtomEmpty is the term used to represent empty. AtomEmpty = engine.NewAtom("") - // AtomEmptyArray is the term []. - AtomEmptyArray = engine.NewAtom("[]") // AtomEmptyList is the term used to represent an empty list. AtomEmptyList = engine.NewAtom("[]") // AtomEncoding is the term used to indicate the encoding type option. @@ -38,6 +36,9 @@ var ( // AtomPair are terms with principal functor (-)/2. // For example, the term -(A, B) denotes the pair of elements A and B. AtomPair = engine.NewAtom("-") + // AtomKeyValue are terms with principal functor (=)/2. + // For example, the term =(A, B) denotes the mapping of key A with value B. + AtomKeyValue = engine.NewAtom("=") // AtomPath is the term used to indicate the path component. AtomPath = engine.NewAtom("path") // AtomQueryValue is the term used to indicate the query value component. diff --git a/x/logic/prolog/error.go b/x/logic/prolog/error.go index 8ed3cf01..d36e8ae1 100644 --- a/x/logic/prolog/error.go +++ b/x/logic/prolog/error.go @@ -45,8 +45,10 @@ var ( AtomTypeOption = engine.NewAtom("option") // AtomTypePair is the term used to indicate the pair type. AtomTypePair = engine.NewAtom("pair") + // AtomTypeKeyValue is the term used to indicate the key-value type. + AtomTypeKeyValue = engine.NewAtom("key_value") // 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 +58,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..9fbdf708 100644 --- a/x/logic/prolog/json.go +++ b/x/logic/prolog/json.go @@ -4,62 +4,35 @@ import ( "github.com/axone-protocol/prolog/engine" ) +var ( + nullTerm = AtomAt.Apply(AtomNull) + 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) -} - -// 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) +// 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 } - - key, ok := pair.Arg(0).(engine.Atom) - if !ok { - return nil, engine.TypeError(AtomTypeAtom, pair.Arg(0), env) - } - terms[key.String()] = pair.Arg(1) } - return terms, nil + + return nil, engine.TypeError(AtomTypeJSON, term, env) } diff --git a/x/logic/prolog/json_test.go b/x/logic/prolog/json_test.go index 7aec0b66..d3b27776 100644 --- a/x/logic/prolog/json_test.go +++ b/x/logic/prolog/json_test.go @@ -9,71 +9,86 @@ import ( . "github.com/smartystreets/goconvey/convey" ) -func TestExtractJsonTerm(t *testing.T) { - Convey("Given a test cases", t, func() { +func TestJSONNull(t *testing.T) { + Convey("Given an environment", t, func() { + env := engine.NewEnv() + Convey("When calling JSONNull", func() { + got := JSONNull() + want := nullTerm + Convey("Then it should return the JSON null atom", func() { + So(got, ShouldNotBeNil) + So(got.Compare(want, env), ShouldEqual, 0) + }) + }) + }) +} + +func TestJSONBool(t *testing.T) { + Convey("Given a boolean value", t, func() { cases := []struct { - compound engine.Compound - result map[string]engine.Term - wantSuccess bool - wantError error + input bool + want engine.Term }{ { - compound: engine.NewAtom("foo").Apply(engine.NewAtom("bar")).(engine.Compound), - wantSuccess: false, - wantError: fmt.Errorf("error(type_error(json,foo(bar)),root)"), + input: true, + want: trueTerm, }, { - compound: engine.NewAtom("json").Apply(engine.NewAtom("bar"), engine.NewAtom("foobar")).(engine.Compound), - wantSuccess: false, - wantError: fmt.Errorf("error(type_error(json,json(bar,foobar)),root)"), - }, - { - compound: engine.NewAtom("json").Apply(engine.NewAtom("bar")).(engine.Compound), - wantSuccess: false, - wantError: fmt.Errorf("error(type_error(list,bar),root)"), + input: false, + want: falseTerm, }, + } + + for _, tc := range cases { + Convey(fmt.Sprintf("When calling JSONBool(%v)", tc.input), func() { + got := JSONBool(tc.input) + + Convey("Then the result should be as expected", func() { + So(got, ShouldEqual, tc.want) + }) + }) + } + }) +} + +func TestAssertJSON(t *testing.T) { + Convey("Given test cases", t, func() { + env := engine.NewEnv() + cases := []struct { + description string + input engine.Term + wantError error + wantResult string + }{ { - compound: AtomJSON.Apply(engine.List(AtomPair.Apply(engine.NewAtom("foo"), engine.NewAtom("bar")))).(engine.Compound), - result: map[string]engine.Term{ - "foo": engine.NewAtom("bar"), - }, - wantSuccess: true, + description: "valid JSON object", + input: AtomJSON.Apply(engine.NewAtom("valid")), + wantError: nil, }, { - compound: AtomJSON.Apply(engine.List(engine.NewAtom("foo"), engine.NewAtom("bar"))).(engine.Compound), - wantSuccess: false, - wantError: fmt.Errorf("error(type_error(pair,foo),root)"), + description: "non-compound term", + input: engine.NewAtom("notACompound"), + wantError: fmt.Errorf("error(type_error(json,notACompound),root)"), }, { - compound: AtomJSON.Apply(engine.List(AtomPair.Apply(engine.Integer(10), engine.NewAtom("bar")))).(engine.Compound), - wantSuccess: false, - wantError: fmt.Errorf("error(type_error(atom,10),root)"), + description: "compound term with arity > 1", + input: AtomJSON.Apply(engine.NewAtom("foo"), engine.NewAtom("bar")), + wantError: fmt.Errorf("error(type_error(json,json(foo,bar)),root)"), }, } - for nc, tc := range cases { - Convey(fmt.Sprintf("Given the term compound #%d: %s", nc, tc.compound), func() { - Convey("when extract json term", func() { - env := engine.NewEnv() - result, err := ExtractJSONTerm(tc.compound, env) - if tc.wantSuccess { - Convey("then no error should be thrown", func() { - So(err, ShouldBeNil) - So(result, ShouldNotBeNil) + for _, tc := range cases { + Convey(fmt.Sprintf("When calling AssertJSON(%s)", tc.input), func() { + got, err := AssertJSON(tc.input, env) - Convey("and result should be as expected", func() { - So(result, ShouldResemble, tc.result) - }) - }) + Convey("Then the result should match the expected value", func() { + if tc.wantError != nil { + So(got, ShouldBeNil) + So(err, ShouldBeError, tc.wantError) } else { - Convey("then error should occurs", func() { - So(err, ShouldNotBeNil) - So(tc.wantError, ShouldNotBeNil) - - Convey("and should be as expected", func() { - So(err.Error(), ShouldEqual, tc.wantError.Error()) - }) - }) + So(err, ShouldBeNil) + So(got, ShouldNotBeNil) + So(got.Compare(tc.input, env), ShouldEqual, 0) } }) }) 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 +} diff --git a/x/logic/prolog/list_test.go b/x/logic/prolog/list_test.go new file mode 100644 index 00000000..a1302362 --- /dev/null +++ b/x/logic/prolog/list_test.go @@ -0,0 +1,91 @@ +package prolog + +import ( + "errors" + "fmt" + "testing" + + "github.com/axone-protocol/prolog/engine" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestForEach(t *testing.T) { + Convey("Given test cases", t, func(c C) { + env := engine.NewEnv() + cases := []struct { + description string + list engine.Term + f func(v engine.Term, hasNext bool) error + wantError error + }{ + { + description: "Empty list", + list: engine.NewAtom("[]"), + + f: func(_ engine.Term, _ bool) error { + t.Errorf("Function should not be called for empty list") + return nil + }, + }, + { + description: "Non-list term", + list: engine.NewAtom("not_a_list"), + + f: func(_ engine.Term, _ bool) error { + t.Errorf("Function should not be called for non-list term") + return nil + }, + wantError: fmt.Errorf("error(type_error(list,not_a_list),root)"), + }, + { + description: "List with elements", + list: engine.List(engine.NewAtom("a"), engine.NewAtom("b"), engine.NewAtom("c")), + + f: func() func(v engine.Term, hasNext bool) error { + i := 0 + values := []string{"a", "b", "c"} + return func(v engine.Term, hasNext bool) error { + defer func() { i++ }() + + c.So(i, ShouldBeLessThan, len(values)) + got, err := AssertAtom(v, env) + c.So(err, ShouldBeNil) + c.So(got.String(), ShouldEqual, values[i]) + c.So(hasNext, ShouldEqual, i < len(values)-1) + + return nil + } + }(), + wantError: nil, + }, + { + description: "Function returns error", + list: engine.List(engine.NewAtom("a"), engine.NewAtom("b")), + + f: func() func(v engine.Term, hasNext bool) error { + i := 0 + return func(_ engine.Term, hasNext bool) error { + defer func() { i++ }() + c.So(i, ShouldEqual, 0) + c.So(hasNext, ShouldBeTrue) + + return errors.New("test error") + } + }(), + wantError: fmt.Errorf("test error"), + }, + } + + for tn, tc := range cases { + Convey(fmt.Sprintf("When calling ForEach (case %d)", tn), func() { + err := ForEach(tc.list, env, tc.f) + if tc.wantError != nil { + So(err, ShouldBeError, tc.wantError) + } else { + So(err, ShouldBeNil) + } + }) + } + }) +}