diff --git a/__tests__/integration/query.test.ts b/__tests__/integration/query.test.ts index 52234e09..15a1dbb1 100644 --- a/__tests__/integration/query.test.ts +++ b/__tests__/integration/query.test.ts @@ -499,4 +499,70 @@ describe("query can encode / decode QueryValue correctly", () => { } } }); + + it("symbol arguments throw a TypeError", async () => { + expect.assertions(2); + // whack in a symbol + // @ts-expect-error Type 'symbol' is not assignable to type 'QueryValue' + let symbolValue: QueryValue = Symbol("foo"); + try { + await client.query(fql`{ foo: ${symbolValue} }`); + } catch (e) { + if (e instanceof TypeError) { + expect(e.name).toBe("TypeError"); + expect(e.message).toBe( + "Passing symbol as a QueryValue is not supported" + ); + } + } + }); + + it("function arguments throw a TypeError", async () => { + expect.assertions(2); + // whack in a function + let fnValue: QueryValue = () => {}; + try { + await client.query(fql`{ foo: ${fnValue} }`); + } catch (e) { + if (e instanceof TypeError) { + expect(e.name).toBe("TypeError"); + expect(e.message).toBe( + "Passing function as a QueryValue is not supported" + ); + } + } + }); + + it("symbol arguments throw a TypeError in arguments", async () => { + expect.assertions(2); + // whack in a symbol + // @ts-expect-error Type 'symbol' is not assignable to type 'QueryValue' + let symbolValue: QueryValue = Symbol("foo"); + try { + await client.query(fql`foo`, { arguments: { foo: symbolValue } }); + } catch (e: any) { + if (e instanceof ClientError && e.cause instanceof TypeError) { + expect(e.cause.name).toBe("TypeError"); + expect(e.cause.message).toBe( + "Passing symbol as a QueryValue is not supported" + ); + } + } + }); + + it("function arguments throw a TypeError in arguments", async () => { + expect.assertions(2); + // whack in a function + let fnValue: QueryValue = () => {}; + try { + await client.query(fql`foo`, { arguments: { foo: fnValue } }); + } catch (e: any) { + if (e instanceof ClientError && e.cause instanceof TypeError) { + expect(e.cause.name).toBe("TypeError"); + expect(e.cause.message).toBe( + "Passing function as a QueryValue is not supported" + ); + } + } + }); }); diff --git a/__tests__/integration/template-format.test.ts b/__tests__/integration/template-format.test.ts index 71bfb60a..264f2eba 100644 --- a/__tests__/integration/template-format.test.ts +++ b/__tests__/integration/template-format.test.ts @@ -109,6 +109,64 @@ describe("query using template format", () => { expect(response.data).toBe(true); }); + it("succeeds with deep nested expressions - example 2", async () => { + const str = "foo"; + const otherStr = "bar"; + const num = 6; + const otherNum = 3; + const deepFirst = fql`(${str} + ${otherStr})`; + const deeperBuilder = fql`(${num} + 3)`; + const innerQuery = fql`(${deeperBuilder} + ${otherNum})`; + const queryBuilder = fql`${deepFirst}.length + ${innerQuery}`; + const response = await client.query(queryBuilder); + expect(response.data).toBe(18); + }); + + it("succeeds with expressions nested within objects", async () => { + const arg = { + a: fql`1`, + b: fql`2`, + }; + const queryBuilder = fql`${arg}`; + const response = await client.query(queryBuilder); + expect(response.data).toStrictEqual({ a: 1, b: 2 }); + }); + + it("succeeds with expressions nested within arrays", async () => { + const arg = [fql`1`, fql`2`]; + const queryBuilder = fql`${arg}`; + const response = await client.query(queryBuilder); + expect(response.data).toEqual([1, 2]); + }); + + it("succeeds with expressions nested within arrays and objects combined", async () => { + const arg = [ + [fql`1`], + { + a: fql`1`, + b: fql`2`, + }, + ]; + const queryBuilder = fql`${arg}`; + const response = await client.query(queryBuilder); + expect(response.data).toEqual([[1], { a: 1, b: 2 }]); + }); + + it("succeeds with multiple layers of nesting of arrays and objects", async () => { + const other = { a: fql`3`, b: fql`4` }; + const arg = [ + [fql`1 + ${fql`2`}`], + { + a: fql`1`, + b: fql`2`, + c: other, + }, + ]; + const queryBuilder = fql`${arg}`; + const response = await client.query(queryBuilder); + expect(response.data).toEqual([[3], { a: 1, b: 2, c: { a: 3, b: 4 } }]); + }); + it("succeeds with FQL string interpolation", async () => { const codeName = "Alice"; const queryBuilder = fql` diff --git a/__tests__/unit/query-builder.test.ts b/__tests__/unit/query-builder.test.ts index 701b872d..68204c84 100644 --- a/__tests__/unit/query-builder.test.ts +++ b/__tests__/unit/query-builder.test.ts @@ -5,7 +5,7 @@ describe("fql method producing Querys", () => { const queryBuilder = fql`'foo'.length`; const queryRequest = queryBuilder.toQuery(); expect(queryRequest.query).toEqual({ fql: ["'foo'.length"] }); - expect(queryRequest.arguments).toStrictEqual({}); + expect(queryRequest.arguments).toBeUndefined(); }); it("parses with a string variable", () => { @@ -15,7 +15,7 @@ describe("fql method producing Querys", () => { expect(queryRequest.query).toEqual({ fql: [{ value: "foo" }, ".length"], }); - expect(queryRequest.arguments).toStrictEqual({}); + expect(queryRequest.arguments).toBeUndefined(); }); it("parses with a number variable", () => { @@ -25,7 +25,7 @@ describe("fql method producing Querys", () => { expect(queryRequest.query).toEqual({ fql: ["'foo'.length == ", { value: { "@int": "8" } }], }); - expect(queryRequest.arguments).toStrictEqual({}); + expect(queryRequest.arguments).toBeUndefined(); }); it("parses with a boolean variable", () => { @@ -35,7 +35,7 @@ describe("fql method producing Querys", () => { expect(queryRequest.query).toEqual({ fql: ["val.enabled == ", { value: true }], }); - expect(queryRequest.arguments).toStrictEqual({}); + expect(queryRequest.arguments).toBeUndefined(); }); it("parses with a null variable", () => { @@ -44,7 +44,7 @@ describe("fql method producing Querys", () => { expect(queryRequest.query).toEqual({ fql: ["value: ", { value: null }], }); - expect(queryRequest.arguments).toStrictEqual({}); + expect(queryRequest.arguments).toBeUndefined(); }); it("parses with an object variable", () => { @@ -52,9 +52,12 @@ describe("fql method producing Querys", () => { const queryBuilder = fql`value: ${obj}`; const queryRequest = queryBuilder.toQuery(); expect(queryRequest.query).toEqual({ - fql: ["value: ", { value: { bar: "baz", foo: "bar" } }], + fql: [ + "value: ", + { object: { bar: { value: "baz" }, foo: { value: "bar" } } }, + ], }); - expect(queryRequest.arguments).toStrictEqual({}); + expect(queryRequest.arguments).toBeUndefined(); }); it("parses with an object variable having a toQuery property", () => { @@ -62,9 +65,18 @@ describe("fql method producing Querys", () => { const queryBuilder = fql`value: ${obj}`; const queryRequest = queryBuilder.toQuery(); expect(queryRequest.query).toEqual({ - fql: ["value: ", { value: { bar: "baz", foo: "bar", toQuery: "hehe" } }], + fql: [ + "value: ", + { + object: { + bar: { value: "baz" }, + foo: { value: "bar" }, + toQuery: { value: "hehe" }, + }, + }, + ], }); - expect(queryRequest.arguments).toStrictEqual({}); + expect(queryRequest.arguments).toBeUndefined(); }); it("parses with an array variable", () => { @@ -74,10 +86,16 @@ describe("fql method producing Querys", () => { expect(queryRequest.query).toEqual({ fql: [ "value: ", - { value: [{ "@int": "1" }, { "@int": "2" }, { "@int": "3" }] }, + { + array: [ + { value: { "@int": "1" } }, + { value: { "@int": "2" } }, + { value: { "@int": "3" } }, + ], + }, ], }); - expect(queryRequest.arguments).toStrictEqual({}); + expect(queryRequest.arguments).toBeUndefined(); }); it("parses with multiple variables", () => { @@ -88,7 +106,7 @@ describe("fql method producing Querys", () => { expect(queryRequest.query).toEqual({ fql: [{ value: "bar" }, ".length == ", { value: { "@int": "20" } }], }); - expect(queryRequest.arguments).toStrictEqual({}); + expect(queryRequest.arguments).toBeUndefined(); }); it("parses nested expressions", () => { @@ -104,7 +122,7 @@ describe("fql method producing Querys", () => { { fql: ["Math.add(", { value: { "@int": "17" } }, ", 3)"] }, ], }); - expect(queryRequest.arguments).toStrictEqual({}); + expect(queryRequest.arguments).toBeUndefined(); }); it("parses deep nested expressions", () => { @@ -132,38 +150,7 @@ describe("fql method producing Querys", () => { }, ], }); - expect(queryRequest.arguments).toStrictEqual({}); - }); - - it("adds headers if passed in", () => { - const str = "baz"; - const num = 17; - const innerQuery = fql`Math.add(${num}, 3)`; - const queryBuilder = fql`${str}.length == ${innerQuery}`; - const queryRequest = queryBuilder.toQuery({ - linearized: true, - query_timeout_ms: 600, - max_contention_retries: 4, - query_tags: { a: "tag" }, - traceparent: "00-750efa5fb6a131eb2cf4db39f28366cb-5669e71839eca76b-00", - typecheck: false, - }); - expect(queryRequest).toMatchObject({ - linearized: true, - query_timeout_ms: 600, - max_contention_retries: 4, - query_tags: { a: "tag" }, - traceparent: "00-750efa5fb6a131eb2cf4db39f28366cb-5669e71839eca76b-00", - typecheck: false, - }); - expect(queryRequest.query).toEqual({ - fql: [ - { value: "baz" }, - ".length == ", - { fql: ["Math.add(", { value: { "@int": "17" } }, ", 3)"] }, - ], - }); - expect(queryRequest.arguments).toStrictEqual({}); + expect(queryRequest.arguments).toBeUndefined(); }); it("parses with FQL string interpolation", async () => { @@ -180,6 +167,6 @@ describe("fql method producing Querys", () => { '\n "Hello, #{name}"\n ', ], }); - expect(queryRequest.arguments).toStrictEqual({}); + expect(queryRequest.arguments).toBeUndefined(); }); }); diff --git a/__tests__/unit/tagged-format.test.ts b/__tests__/unit/tagged-format.test.ts index 573181f1..bdff8862 100644 --- a/__tests__/unit/tagged-format.test.ts +++ b/__tests__/unit/tagged-format.test.ts @@ -13,6 +13,7 @@ import { TimeStub, EmbeddedSet, } from "../../src"; +import { ObjectFragment, ValueFragment } from "../../src/wire-protocol"; describe.each` long_type @@ -142,94 +143,196 @@ describe.each` const bugs_mod = new Module("Bugs"); const collection_mod = new Module("Collection"); - const result = JSON.stringify( - TaggedTypeFormat.encode({ + const encoded = TaggedTypeFormat.encode({ + // literals + double: 4.14, + int: 32, + name: "Hello, World", + null: null, + number: 48, + // objects and arrays + child: { more: { itsworking: DateStub.from("1983-04-15") } }, + extra: [ + { + id: 1, + time: new Date(), + }, + { + id: 2, + time: new Date(), + }, + ], + "@foobar": { + date: DateStub.from("1888-08-08"), + }, + // dates and times + date: DateStub.from("1923-05-13"), + time: TimeStub.from("2023-03-20T00:00:00Z"), + datetime: new Date("2023-03-20T00:00:00Z"), + // Document types + mod: bugs_mod, + docReference: new DocumentReference({ coll: bugs_mod, id: "123" }), + doc: new Document({ + coll: bugs_mod, + id: "123", + ts: TimeStub.from("2023-03-20T00:00:00Z"), + }), + namedDocReference: new NamedDocumentReference({ + coll: collection_mod, + name: "Bugs", + }), + namedDoc: new NamedDocument({ + coll: collection_mod, + name: "Bugs", + ts: TimeStub.from("2023-03-20T00:00:00Z"), + }), + nullDoc: new NullDocument( + new DocumentReference({ coll: bugs_mod, id: "123" }), + "not found" + ), + // Set types + // TODO: uncomment to add test once core accepts `@set` tagged values + // page: new Page({ data: ["a", "b"] }), + // TODO: uncomment to add test once core accepts `@set` tagged values + // page_string: new Page({ after: "abc123" }), + }); + + expect(encoded).toMatchObject({ + "@object": { // literals - double: 4.14, - int: 32, + double: { "@double": "4.14" }, + int: { "@int": "32" }, name: "Hello, World", null: null, - number: 48, // objects and arrays - child: { more: { itsworking: DateStub.from("1983-04-15") } }, - extra: [ - { - id: 1, - time: new Date(), - }, - { - id: 2, - time: new Date(), - }, - ], - "@foobar": { - date: DateStub.from("1888-08-08"), - }, - // dates and times - date: DateStub.from("1923-05-13"), - time: TimeStub.from("2023-03-20T00:00:00Z"), - datetime: new Date("2023-03-20T00:00:00Z"), + child: { more: { itsworking: { "@date": "1983-04-15" } } }, + extra: [{ id: { "@int": "1" } }, { id: { "@int": "2" } }], + "@foobar": { date: { "@date": "1888-08-08" } }, // Document types - mod: bugs_mod, - docReference: new DocumentReference({ coll: bugs_mod, id: "123" }), - doc: new Document({ - coll: bugs_mod, - id: "123", - ts: TimeStub.from("2023-03-20T00:00:00Z"), - }), - namedDocReference: new NamedDocumentReference({ - coll: collection_mod, - name: "Bugs", - }), - namedDoc: new NamedDocument({ - coll: collection_mod, - name: "Bugs", - ts: TimeStub.from("2023-03-20T00:00:00Z"), - }), - nullDoc: new NullDocument( - new DocumentReference({ coll: bugs_mod, id: "123" }), - "not found" - ), - // Set types - // TODO: uncomment to add test once core accepts `@set` tagged values - // page: new Page({ data: ["a", "b"] }), - // TODO: uncomment to add test once core accepts `@set` tagged values - // page_string: new Page({ after: "abc123" }), - }) - ); + mod: { "@mod": "Bugs" }, + docReference: { "@ref": { coll: { "@mod": "Bugs" }, id: "123" } }, + doc: { "@ref": { coll: { "@mod": "Bugs" }, id: "123" } }, + namedDocReference: { + "@ref": { coll: { "@mod": "Collection" }, name: "Bugs" }, + }, + namedDoc: { "@ref": { coll: { "@mod": "Collection" }, name: "Bugs" } }, + nullDoc: { "@ref": { coll: { "@mod": "Bugs" }, id: "123" } }, + }, + // Set types + // TODO: expect set types to be encoded as `@set` tagged values + }); + }); - const backToObj = JSON.parse(result)["@object"]; + it("can be encoded as interpolation query", () => { + const bugs_mod = new Module("Bugs"); + const collection_mod = new Module("Collection"); - // literals - expect(backToObj.double).toStrictEqual({ "@double": "4.14" }); - expect(backToObj.null).toBeNull(); - // objects and arrays - expect(backToObj.child.more.itsworking).toStrictEqual({ - "@date": "1983-04-15", - }); - expect(backToObj.extra).toHaveLength(2); - // Document types - expect(backToObj.mod).toStrictEqual({ "@mod": "Bugs" }); - expect(backToObj.docReference).toStrictEqual({ - "@ref": { coll: { "@mod": "Bugs" }, id: "123" }, - }); - expect(backToObj.doc).toStrictEqual({ - "@ref": { coll: { "@mod": "Bugs" }, id: "123" }, - }); - expect(backToObj.namedDocReference).toStrictEqual({ - "@ref": { coll: { "@mod": "Collection" }, name: "Bugs" }, - }); - expect(backToObj.namedDoc).toStrictEqual({ - "@ref": { coll: { "@mod": "Collection" }, name: "Bugs" }, + const encoded = TaggedTypeFormat.encodeInterpolation({ + // literals + double: 4.14, + int: 32, + name: "Hello, World", + null: null, + number: 48, + // objects and arrays + child: { more: { itsworking: DateStub.from("1983-04-15") } }, + extra: [ + { + id: 1, + time: new Date(), + }, + { + id: 2, + time: new Date(), + }, + ], + "@foobar": { + date: DateStub.from("1888-08-08"), + }, + // dates and times + date: DateStub.from("1923-05-13"), + time: TimeStub.from("2023-03-20T00:00:00Z"), + datetime: new Date("2023-03-20T00:00:00Z"), + // Document types + mod: bugs_mod, + docReference: new DocumentReference({ coll: bugs_mod, id: "123" }), + doc: new Document({ + coll: bugs_mod, + id: "123", + ts: TimeStub.from("2023-03-20T00:00:00Z"), + }), + namedDocReference: new NamedDocumentReference({ + coll: collection_mod, + name: "Bugs", + }), + namedDoc: new NamedDocument({ + coll: collection_mod, + name: "Bugs", + ts: TimeStub.from("2023-03-20T00:00:00Z"), + }), + nullDoc: new NullDocument( + new DocumentReference({ coll: bugs_mod, id: "123" }), + "not found" + ), + // Set types + // TODO: uncomment to add test once core accepts `@set` tagged values + // page: new Page({ data: ["a", "b"] }), + // TODO: uncomment to add test once core accepts `@set` tagged values + // page_string: new Page({ after: "abc123" }), }); - expect(backToObj.nullDoc).toStrictEqual({ - "@ref": { coll: { "@mod": "Bugs" }, id: "123" }, + + expect(encoded).toMatchObject({ + object: { + // literals + double: { value: { "@double": "4.14" } }, + int: { value: { "@int": "32" } }, + name: { value: "Hello, World" }, + null: { value: null }, + // objects and arrays + child: { + object: { + more: { + object: { itsworking: { value: { "@date": "1983-04-15" } } }, + }, + }, + }, + extra: { array: expect.arrayContaining([]) }, + "@foobar": { + object: { + date: { value: { "@date": "1888-08-08" } }, + }, + }, + // Document types + mod: { value: { "@mod": "Bugs" } }, + docReference: { + value: { + "@ref": { coll: { "@mod": "Bugs" }, id: "123" }, + }, + }, + doc: { + value: { + "@ref": { coll: { "@mod": "Bugs" }, id: "123" }, + }, + }, + namedDocReference: { + value: { + "@ref": { coll: { "@mod": "Collection" }, name: "Bugs" }, + }, + }, + namedDoc: { + value: { + "@ref": { coll: { "@mod": "Collection" }, name: "Bugs" }, + }, + }, + nullDoc: { + value: { + "@ref": { coll: { "@mod": "Bugs" }, id: "123" }, + }, + }, + }, + // Set types + // TODO: expect set types to be encoded as `@set` tagged values }); - // Set types - // TODO: uncomment to add test once core accepts `@set` tagged values - // expect(backToObj.page).toStrictEqual({ "@set": { data: ["a", "b"] } }); - // TODO: uncomment to add test once core accepts `@set` tagged values - // expect(backToObj.page_string).toStrictEqual({ "@set": "abc123" }); }); it("handles conflicts", () => { @@ -239,46 +342,97 @@ describe.each` int: { "@int": 1 }, long: { "@long": BigInt("99999999999999999") }, double: { "@double": 1.99 }, + }) as ObjectFragment; + + expect(result).toMatchObject({ + date: { "@object": { "@date": { "@date": "2022-11-01" } } }, + time: { "@object": { "@time": { "@time": "2022-11-02T05:00:00.000Z" } } }, + int: { "@object": { "@int": { "@int": "1" } } }, + long: { "@object": { "@long": { "@long": "99999999999999999" } } }, + double: { "@object": { "@double": { "@double": "1.99" } } }, }); - expect(result["date"]["@object"]["@date"]).toStrictEqual({ - "@date": "2022-11-01", - }); - expect(result["time"]["@object"]["@time"]).toStrictEqual({ - "@time": "2022-11-02T05:00:00.000Z", + }); + + it("handles conflicts in interpolation queries", () => { + const result = TaggedTypeFormat.encodeInterpolation({ + date: { "@date": DateStub.from("2022-11-01") }, + time: { "@time": TimeStub.from("2022-11-02T05:00:00.000Z") }, + int: { "@int": 1 }, + long: { "@long": BigInt("99999999999999999") }, + double: { "@double": 1.99 }, + }) as ObjectFragment; + + expect(result).toMatchObject({ + object: { + date: { object: { "@date": { value: { "@date": "2022-11-01" } } } }, + time: { + object: { + "@time": { value: { "@time": "2022-11-02T05:00:00.000Z" } }, + }, + }, + int: { object: { "@int": { value: { "@int": "1" } } } }, + long: { + object: { "@long": { value: { "@long": "99999999999999999" } } }, + }, + double: { object: { "@double": { value: { "@double": "1.99" } } } }, + }, }); - expect(result["int"]["@object"]["@int"]).toStrictEqual({ "@int": "1" }); - expect(result["long"]["@object"]["@long"]).toStrictEqual({ - "@long": "99999999999999999", + }); + + it("handles nested conflict types", () => { + const encoded = TaggedTypeFormat.encode({ + "@date": { + "@date": { + "@time": new Date("2022-12-02T02:00:00.000Z"), + }, + }, }); - expect(result["double"]["@object"]["@double"]).toEqual({ - "@double": "1.99", + + expect(encoded).toMatchObject({ + "@object": { + "@date": { + "@object": { + "@date": { + "@object": { + "@time": { "@time": "2022-12-02T02:00:00.000Z" }, + }, + }, + }, + }, + }, }); }); - it("handles nested conflict types", () => { - expect( - JSON.stringify( - TaggedTypeFormat.encode({ - "@date": { + it("handles nested conflict types in interpolation queries", () => { + const encoded = TaggedTypeFormat.encodeInterpolation({ + "@date": { + "@date": { + "@time": new Date("2022-12-02T02:00:00.000Z"), + }, + }, + }); + + expect(encoded).toMatchObject({ + object: { + "@date": { + object: { "@date": { - "@time": new Date("2022-12-02T02:00:00.000Z"), + object: { + "@time": { value: { "@time": "2022-12-02T02:00:00.000Z" } }, + }, }, }, - }) - ) - ).toEqual( - '{"@object":{"@date":{"@object":{"@date":{"@object":{"@time":{"@time":"2022-12-02T02:00:00.000Z"}}}}}}}' - ); + }, + }, + }); }); it("wraps user-provided `@` fields", () => { - expect( - JSON.stringify( - TaggedTypeFormat.encode({ - "@foo": true, - }) - ) - ).toEqual('{"@object":{"@foo":true}}'); + const encoded = TaggedTypeFormat.encodeInterpolation({ + "@foo": true, + }); + + expect(encoded).toMatchObject({ object: { "@foo": { value: true } } }); }); it.each` @@ -313,11 +467,13 @@ describe.each` } } testCase; - const encoded = TaggedTypeFormat.encode(input); - const encodedKey = Object.keys(encoded)[0]; + const encoded = TaggedTypeFormat.encodeInterpolation( + input + ) as ValueFragment; + const encodedKey = Object.keys(encoded.value as Object)[0]; expect(encodedKey).toEqual(tag); const decoded = TaggedTypeFormat.decode( - JSON.stringify(encoded), + JSON.stringify(encoded.value), decodeOptions ); expect(typeof decoded).toBe(expectedType); @@ -332,6 +488,6 @@ describe.each` ${Number.NEGATIVE_INFINITY} | ${"NEGATIVE_INFINITY"} ${Number.POSITIVE_INFINITY} | ${"POSITIVE_INFINITY"} `("Throws if BigInt value is $testCase", async ({ input }) => { - expect(() => TaggedTypeFormat.encode(input)).toThrow(); + expect(() => TaggedTypeFormat.encodeInterpolation(input)).toThrow(); }); }); diff --git a/package.json b/package.json index 2c6ad045..2b24ce93 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fauna", - "version": "1.2.0", + "version": "1.3.0", "description": "A driver to query Fauna databases in browsers, Node.js, and other Javascript runtimes", "homepage": "https://fauna.com", "bugs": { diff --git a/src/client.ts b/src/client.ts index 624ee986..0171daac 100644 --- a/src/client.ts +++ b/src/client.ts @@ -34,6 +34,7 @@ import { type QueryOptions, type QuerySuccess, type QueryValue, + QueryValueObject, } from "./wire-protocol"; type RequiredClientConfig = ClientConfiguration & @@ -435,7 +436,11 @@ in an environmental variable named FAUNA_SECRET or pass it to the Client\ const queryArgs = requestConfig.arguments ? isTaggedFormat - ? TaggedTypeFormat.encode(requestConfig.arguments) + ? // Type cast safety: requestConfig.arguments is an object, so + // encoding creates a subtype of QueryValueObject + (TaggedTypeFormat.encode( + requestConfig.arguments + ) as QueryValueObject) : requestConfig.arguments : undefined; diff --git a/src/query-builder.ts b/src/query-builder.ts index e8353379..d9ce39dd 100644 --- a/src/query-builder.ts +++ b/src/query-builder.ts @@ -5,6 +5,7 @@ import type { QueryInterpolation, QueryRequest, QueryOptions, + FQLFragment, } from "./wire-protocol"; /** @@ -69,42 +70,37 @@ export class Query { * { query: { fql: ["'foo'.length == ", { value: { "@int": "8" } }, ""] }} * ``` */ - toQuery(requestHeaders: QueryOptions = {}): QueryRequest { - return { ...this.#render(requestHeaders), ...requestHeaders }; + toQuery(requestHeaders: QueryOptions = {}): QueryRequest { + return { + query: this.#render_query(), + arguments: requestHeaders.arguments, + }; } - #render(requestHeaders: QueryOptions): QueryRequest { + #render_query(): FQLFragment { if (this.#queryFragments.length === 1) { - return { query: { fql: [this.#queryFragments[0]] }, arguments: {} }; + return { fql: [this.#queryFragments[0]] }; } - let resultArgs: QueryValueObject = {}; - const renderedFragments: (string | QueryInterpolation)[] = + let renderedFragments: (string | QueryInterpolation)[] = this.#queryFragments.flatMap((fragment, i) => { // There will always be one more fragment than there are arguments if (i === this.#queryFragments.length - 1) { return fragment === "" ? [] : [fragment]; } + // arguments in the template format must always be encoded, regardless + // of the "x-format" request header + // TODO: catch and rethrow Errors, indicating bad user input const arg = this.#queryArgs[i]; - let subQuery: string | QueryInterpolation; - if (arg instanceof Query) { - const request = arg.toQuery(requestHeaders); - subQuery = request.query; - resultArgs = { ...resultArgs, ...request.arguments }; - } else { - // arguments in the template format must always be encoded, regardless - // of the "x-format" request header - // TODO: catch and rethrow Errors, indicating bad user input - subQuery = { value: TaggedTypeFormat.encode(arg) }; - } + const encoded = TaggedTypeFormat.encodeInterpolation(arg); - return [fragment, subQuery].filter((x) => x !== ""); + return [fragment, encoded]; }); - return { - query: { fql: renderedFragments }, - arguments: resultArgs, - }; + // We don't need to send empty-string fragments over the wire + renderedFragments = renderedFragments.filter((x) => x !== ""); + + return { fql: renderedFragments }; } } diff --git a/src/tagged-type.ts b/src/tagged-type.ts index c7f29a87..e74a11a7 100644 --- a/src/tagged-type.ts +++ b/src/tagged-type.ts @@ -1,4 +1,5 @@ import { ClientError } from "./errors"; +import { Query } from "./query-builder"; import { DateStub, Document, @@ -11,7 +12,15 @@ import { NullDocument, EmbeddedSet, } from "./values"; -import { QueryValueObject, QueryValue } from "./wire-protocol"; +import { + QueryValueObject, + QueryValue, + FQLFragment, + ObjectFragment, + ArrayFragment, + QueryInterpolation, + ValueFragment, +} from "./wire-protocol"; export interface DecodeOptions { long_type: "number" | "bigint"; @@ -22,13 +31,23 @@ export interface DecodeOptions { */ export class TaggedTypeFormat { /** - * Encode the Object to the Tagged Type format for Fauna + * Encode the value to the Tagged Type format for Fauna * - * @param obj - Object that will be encoded + * @param input - value that will be encoded * @returns Map of result */ - static encode(obj: any): any { - return encode(obj); + static encode(input: QueryValue): TaggedType { + return encode(input); + } + + /** + * Encode the value to a QueryInterpolation to send to Fauna + * + * @param input - value that will be encoded + * @returns Map of result + */ + static encodeInterpolation(input: QueryValue): QueryInterpolation { + return encodeInterpolation(input); } /** @@ -107,7 +126,7 @@ type TaggedDouble = { "@double": string }; type TaggedInt = { "@int": string }; type TaggedLong = { "@long": string }; type TaggedMod = { "@mod": string }; -type TaggedObject = { "@object": QueryValueObject }; +type TaggedObject = { "@object": EncodedObject }; type TaggedRef = { "@ref": { id: string; coll: TaggedMod } | { name: string; coll: TaggedMod }; }; @@ -115,6 +134,22 @@ type TaggedRef = { // type TaggedSet = { "@set": { data: QueryValue[]; after?: string } }; type TaggedTime = { "@time": string }; +type EncodedObject = { [key: string]: TaggedType }; +type TaggedType = + | string + | boolean + | null + | TaggedDate + | TaggedDouble + | TaggedInt + | TaggedLong + | TaggedMod + | TaggedObject + | TaggedRef + | TaggedTime + | EncodedObject + | TaggedType[]; + export const LONG_MIN = BigInt("-9223372036854775808"); export const LONG_MAX = BigInt("9223372036854775807"); export const INT_MIN = -(2 ** 31); @@ -158,9 +193,9 @@ const encodeMap = { string: (value: string): string => { return value; }, - object: (input: QueryValueObject): TaggedObject | QueryValueObject => { + object: (input: QueryValueObject): TaggedObject | EncodedObject => { let wrapped = false; - const _out: QueryValueObject = {}; + const _out: EncodedObject = {}; for (const k in input) { if (k.startsWith("@")) { @@ -172,11 +207,7 @@ const encodeMap = { } return wrapped ? { "@object": _out } : _out; }, - array: (input: Array): Array => { - const _out: QueryValue = []; - for (const i in input) _out.push(encode(input[i])); - return _out; - }, + array: (input: QueryValue[]): TaggedType[] => input.map(encode), date: (dateValue: Date): TaggedTime => ({ "@time": dateValue.toISOString(), }), @@ -211,10 +242,7 @@ const encodeMap = { }, }; -const encode = (input: QueryValue): QueryValue => { - if (input === undefined) { - throw new TypeError("Passing undefined as a QueryValue is not supported"); - } +const encode = (input: QueryValue): TaggedType => { switch (typeof input) { case "bigint": return encodeMap["bigint"](input); @@ -253,9 +281,78 @@ const encode = (input: QueryValue): QueryValue => { return encodeMap["set"](input); } else if (input instanceof EmbeddedSet) { return encodeMap["set"](input); + } else if (input instanceof Query) { + throw new TypeError( + "Cannot encode instance of type 'Query'. Try using TaggedTypeFormat.encodeInterpolation instead." + ); } else { return encodeMap["object"](input); } + default: + // catch "undefined", "symbol", and "function" + throw new TypeError( + `Passing ${typeof input} as a QueryValue is not supported` + ); } // anything here would be unreachable code }; + +const encodeInterpolation = (input: QueryValue): QueryInterpolation => { + switch (typeof input) { + case "bigint": + case "string": + case "number": + case "boolean": + return encodeValueInterpolation(encode(input)); + case "object": + if ( + input == null || + input instanceof Date || + input instanceof DateStub || + input instanceof TimeStub || + input instanceof Module || + input instanceof DocumentReference || + input instanceof NamedDocumentReference || + input instanceof Page || + input instanceof EmbeddedSet + ) { + return encodeValueInterpolation(encode(input)); + } else if (input instanceof NullDocument) { + return encodeInterpolation(input.ref); + } else if (input instanceof Query) { + return encodeQueryInterpolation(input); + } else if (Array.isArray(input)) { + return encodeArrayInterpolation(input); + } else { + return encodeObjectInterpolation(input); + } + default: + // catch "undefined", "symbol", and "function" + throw new TypeError( + `Passing ${typeof input} as a QueryValue is not supported` + ); + } +}; + +const encodeObjectInterpolation = (input: QueryValueObject): ObjectFragment => { + const _out: QueryValueObject = {}; + + for (const k in input) { + if (input[k] !== undefined) { + _out[k] = encodeInterpolation(input[k]); + } + } + return { object: _out }; +}; + +const encodeArrayInterpolation = (input: Array): ArrayFragment => { + const encodedItems = input.map(encodeInterpolation); + return { array: encodedItems }; +}; + +const encodeQueryInterpolation = (value: Query): FQLFragment => + value.toQuery().query; + +const encodeValueInterpolation = (value: QueryValue): ValueFragment => ({ + value, +}); diff --git a/src/util/package-version.ts b/src/util/package-version.ts index c238a575..079a0731 100644 --- a/src/util/package-version.ts +++ b/src/util/package-version.ts @@ -1,4 +1,4 @@ //THIS FILE IS AUTOGENERATED. DO NOT EDIT. SEE .husky/pre-commit /** The current package version. */ -export const packageVersion = "1.2.0"; +export const packageVersion = "1.3.0"; diff --git a/src/wire-protocol.ts b/src/wire-protocol.ts index 8682b1f7..644e3dd3 100644 --- a/src/wire-protocol.ts +++ b/src/wire-protocol.ts @@ -1,5 +1,5 @@ // eslint-disable-next-line @typescript-eslint/no-unused-vars -import { fql } from "./query-builder"; +import { Query, fql } from "./query-builder"; import { DateStub, Document, @@ -16,9 +16,9 @@ import { /** * A request to make to Fauna. */ -export interface QueryRequest { +export interface QueryRequest { /** The query */ - query: string | QueryInterpolation; + query: T; /** Optional arguments. Variables in the query will be initialized to the * value associated with an argument key. @@ -218,7 +218,11 @@ export const isQueryResponse = (res: any): res is QueryResponse => * @see {@link ValueFragment} and {@link FQLFragment} for additional * information */ -export type QueryInterpolation = FQLFragment | ValueFragment; +export type QueryInterpolation = + | FQLFragment + | ValueFragment + | ObjectFragment + | ArrayFragment; /** * A piece of an interpolated query that represents an actual value. Arguments @@ -240,6 +244,40 @@ export type QueryInterpolation = FQLFragment | ValueFragment; */ export type ValueFragment = { value: QueryValue }; +/** + * A piece of an interpolated query that represents an object. Arguments + * are passed to fauna using ObjectFragments so that query arguments can be + * nested within javascript objects. + * + * ObjectFragments must always be encoded with tags, regardless of the "x-format" + * request header sent. + * @example + * ```typescript + * const arg = { startDate: DateStub.from("2023-09-01") }; + * const query = fql`${arg})`; + * // produces + * { fql: [{ object: { startDate: { "@date": "2023-09-01" } } }] } + * ``` + */ +export type ObjectFragment = { object: QueryValueObject }; + +/** + * A piece of an interpolated query that represents an array. Arguments + * are passed to fauna using ArrayFragments so that query arguments can be + * nested within javascript arrays. + * + * ArrayFragments must always be encoded with tags, regardless of the "x-format" + * request header sent. + * @example + * ```typescript + * const arg = [1, 2]; + * const query = fql`${arg})`; + * // produces + * { fql: [{ array: [{ "@int": 1 }, { "@int": 2 }] }] } + * ``` + */ +export type ArrayFragment = { array: QueryValue[] }; + /** * A piece of an interpolated query. Interpolated Queries can be safely composed * together without concern of query string injection. @@ -306,6 +344,7 @@ export type QueryValue = | boolean | QueryValueObject | Array + | Date // client-provided classes | DateStub | TimeStub @@ -316,4 +355,5 @@ export type QueryValue = | NamedDocumentReference | NullDocument | Page - | EmbeddedSet; + | EmbeddedSet + | Query;