diff --git a/__tests__/integration/query-limits.test.ts b/__tests__/integration/query-limits.test.ts index d823dbe..82cdfc7 100644 --- a/__tests__/integration/query-limits.test.ts +++ b/__tests__/integration/query-limits.test.ts @@ -1,5 +1,5 @@ -import { Client, fql, Module } from "../../src"; -import { getClient, getDefaultSecretAndEndpoint } from "../client"; +import { Client, fql } from "../../src"; +import { getClient } from "../client"; const rootClient = getClient(); const clients = new Array(); diff --git a/__tests__/integration/query.test.ts b/__tests__/integration/query.test.ts index 3ceac26..65372bf 100644 --- a/__tests__/integration/query.test.ts +++ b/__tests__/integration/query.test.ts @@ -518,7 +518,73 @@ describe("query can encode / decode QueryValue correctly", () => { if (e instanceof TypeError) { expect(e.name).toBe("TypeError"); expect(e.message).toBe( - "Passing undefined as a QueryValue is not supported", + "Passing undefined as a QueryArgument is not supported", + ); + } + } + }); + + 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 QueryArgument 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 QueryArgument 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) { + if (e instanceof TypeError) { + expect(e.name).toBe("TypeError"); + expect(e.message).toBe( + "Passing symbol as a QueryArgument 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) { + if (e instanceof TypeError) { + expect(e.name).toBe("TypeError"); + expect(e.message).toBe( + "Passing function as a QueryArgument is not supported", ); } } diff --git a/__tests__/integration/template-format.test.ts b/__tests__/integration/template-format.test.ts index 8651666..b3d1c7d 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 701b872..6f014c2 100644 --- a/__tests__/unit/query-builder.test.ts +++ b/__tests__/unit/query-builder.test.ts @@ -3,92 +3,102 @@ import { fql } from "../../src"; describe("fql method producing Querys", () => { it("parses with no variables", () => { const queryBuilder = fql`'foo'.length`; - const queryRequest = queryBuilder.toQuery(); - expect(queryRequest.query).toEqual({ fql: ["'foo'.length"] }); - expect(queryRequest.arguments).toStrictEqual({}); + const fragment = queryBuilder.encode(); + expect(fragment).toEqual({ fql: ["'foo'.length"] }); }); it("parses with a string variable", () => { const str = "foo"; const queryBuilder = fql`${str}.length`; - const queryRequest = queryBuilder.toQuery(); - expect(queryRequest.query).toEqual({ + const fragment = queryBuilder.encode(); + expect(fragment).toEqual({ fql: [{ value: "foo" }, ".length"], }); - expect(queryRequest.arguments).toStrictEqual({}); }); it("parses with a number variable", () => { const num = 8; const queryBuilder = fql`'foo'.length == ${num}`; - const queryRequest = queryBuilder.toQuery(); - expect(queryRequest.query).toEqual({ + const fragment = queryBuilder.encode(); + expect(fragment).toEqual({ fql: ["'foo'.length == ", { value: { "@int": "8" } }], }); - expect(queryRequest.arguments).toStrictEqual({}); }); it("parses with a boolean variable", () => { const bool = true; const queryBuilder = fql`val.enabled == ${bool}`; - const queryRequest = queryBuilder.toQuery(); - expect(queryRequest.query).toEqual({ + const fragment = queryBuilder.encode(); + expect(fragment).toEqual({ fql: ["val.enabled == ", { value: true }], }); - expect(queryRequest.arguments).toStrictEqual({}); }); it("parses with a null variable", () => { const queryBuilder = fql`value: ${null}`; - const queryRequest = queryBuilder.toQuery(); - expect(queryRequest.query).toEqual({ + const fragment = queryBuilder.encode(); + expect(fragment).toEqual({ fql: ["value: ", { value: null }], }); - expect(queryRequest.arguments).toStrictEqual({}); }); it("parses with an object variable", () => { const obj = { foo: "bar", bar: "baz" }; const queryBuilder = fql`value: ${obj}`; - const queryRequest = queryBuilder.toQuery(); - expect(queryRequest.query).toEqual({ - fql: ["value: ", { value: { bar: "baz", foo: "bar" } }], + const fragment = queryBuilder.encode(); + expect(fragment).toEqual({ + fql: [ + "value: ", + { object: { bar: { value: "baz" }, foo: { value: "bar" } } }, + ], }); - expect(queryRequest.arguments).toStrictEqual({}); }); it("parses with an object variable having a toQuery property", () => { const obj = { foo: "bar", bar: "baz", toQuery: "hehe" }; const queryBuilder = fql`value: ${obj}`; - const queryRequest = queryBuilder.toQuery(); - expect(queryRequest.query).toEqual({ - fql: ["value: ", { value: { bar: "baz", foo: "bar", toQuery: "hehe" } }], + const fragment = queryBuilder.encode(); + expect(fragment).toEqual({ + fql: [ + "value: ", + { + object: { + bar: { value: "baz" }, + foo: { value: "bar" }, + toQuery: { value: "hehe" }, + }, + }, + ], }); - expect(queryRequest.arguments).toStrictEqual({}); }); it("parses with an array variable", () => { const arr = [1, 2, 3]; const queryBuilder = fql`value: ${arr}`; - const queryRequest = queryBuilder.toQuery(); - expect(queryRequest.query).toEqual({ + const fragment = queryBuilder.encode(); + expect(fragment).toEqual({ fql: [ "value: ", - { value: [{ "@int": "1" }, { "@int": "2" }, { "@int": "3" }] }, + { + array: [ + { value: { "@int": "1" } }, + { value: { "@int": "2" } }, + { value: { "@int": "3" } }, + ], + }, + , ], }); - expect(queryRequest.arguments).toStrictEqual({}); }); it("parses with multiple variables", () => { const str = "bar"; const num = 17; const queryBuilder = fql`${str}.length == ${num + 3}`; - const queryRequest = queryBuilder.toQuery(); - expect(queryRequest.query).toEqual({ + const fragment = queryBuilder.encode(); + expect(fragment).toEqual({ fql: [{ value: "bar" }, ".length == ", { value: { "@int": "20" } }], }); - expect(queryRequest.arguments).toStrictEqual({}); }); it("parses nested expressions", () => { @@ -96,15 +106,14 @@ describe("fql method producing Querys", () => { const num = 17; const innerQuery = fql`Math.add(${num}, 3)`; const queryBuilder = fql`${str}.length == ${innerQuery}`; - const queryRequest = queryBuilder.toQuery(); - expect(queryRequest.query).toEqual({ + const fragment = queryBuilder.encode(); + expect(fragment).toEqual({ fql: [ { value: "baz" }, ".length == ", { fql: ["Math.add(", { value: { "@int": "17" } }, ", 3)"] }, ], }); - expect(queryRequest.arguments).toStrictEqual({}); }); it("parses deep nested expressions", () => { @@ -116,8 +125,8 @@ describe("fql method producing Querys", () => { const deeperBuilder = fql`Math.add(${num}, 3)`; const innerQuery = fql`Math.add(${deeperBuilder}, ${otherNum})`; const queryBuilder = fql`${deepFirst}.length == ${innerQuery}`; - const queryRequest = queryBuilder.toQuery(); - expect(queryRequest.query).toEqual({ + const fragment = queryBuilder.encode(); + expect(fragment).toEqual({ fql: [ { fql: ["(", { value: "baz" }, " + ", { value: "bar" }, ")"] }, ".length == ", @@ -132,38 +141,6 @@ 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({}); }); it("parses with FQL string interpolation", async () => { @@ -172,14 +149,13 @@ describe("fql method producing Querys", () => { let name = ${codeName} "Hello, #{name}" `; - const queryRequest = queryBuilder.toQuery(); - expect(queryRequest.query).toEqual({ + const fragment = queryBuilder.encode(); + expect(fragment).toEqual({ fql: [ "\n let name = ", { value: "Alice" }, '\n "Hello, #{name}"\n ', ], }); - expect(queryRequest.arguments).toStrictEqual({}); }); }); diff --git a/__tests__/unit/tagged-format.test.ts b/__tests__/unit/tagged-format.test.ts index 6778ca5..dbd336c 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 { TaggedDouble, TaggedInt, TaggedLong } from "../../src/wire-protocol"; const testBytesString = "This is a test string 🚀 with various characters: !@#$%^&*()_+=-`~[]{}|;:'\",./<>?"; @@ -158,155 +159,319 @@ 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, + // 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", + ), + bytes_array_buffer: testArrayBufferU8, + bytes_array_buffer_view_u8: testArrayBufferViewU8, + bytes_from_string: testBuffer, + // 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", - ), - bytes_array_buffer: testArrayBufferU8, - bytes_array_buffer_view_u8: testArrayBufferViewU8, - bytes_from_string: testBuffer, + 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" } }, + bytes_array_buffer: { + "@bytes": Buffer.from(testArrayBufferU8).toString("base64"), + }, + bytes_array_buffer_view_u8: { + "@bytes": Buffer.from(testArrayBufferViewU8).toString("base64"), + }, + bytes_from_string: { "@bytes": testBytesBase64 }, // 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" }), - }), - ); + // 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" }, - }); - expect(backToObj.nullDoc).toStrictEqual({ - "@ref": { coll: { "@mod": "Bugs" }, id: "123" }, - }); - expect(backToObj.bytes_array_buffer).toStrictEqual({ - "@bytes": Buffer.from(testArrayBufferU8).toString("base64"), - }); - expect(backToObj.bytes_array_buffer_view_u8).toStrictEqual({ - "@bytes": Buffer.from(testArrayBufferViewU8).toString("base64"), + 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", + ), + bytes_array_buffer: testArrayBufferU8, + bytes_array_buffer_view_u8: testArrayBufferViewU8, + bytes_from_string: testBuffer, + // 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.bytes_from_string).toStrictEqual({ - "@bytes": testBytesBase64, + + 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" }, + }, + }, + bytes_array_buffer: { + value: { + "@bytes": Buffer.from(testArrayBufferU8).toString("base64"), + }, + }, + bytes_array_buffer_view_u8: { + value: { + "@bytes": Buffer.from(testArrayBufferViewU8).toString("base64"), + }, + }, + bytes_from_string: { value: { "@bytes": testBytesBase64 } }, + }, + // 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", () => { - const result = TaggedTypeFormat.encode({ + const result: any = TaggedTypeFormat.encode({ 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 }, }); - expect(result["date"]["@object"]["@date"]).toStrictEqual({ - "@date": "2022-11-01", - }); - expect(result["time"]["@object"]["@time"]).toStrictEqual({ - "@time": "2022-11-02T05:00:00.000Z", + + 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["int"]["@object"]["@int"]).toStrictEqual({ "@int": "1" }); - expect(result["long"]["@object"]["@long"]).toStrictEqual({ - "@long": "99999999999999999", + }); + + 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 }, }); - expect(result["double"]["@object"]["@double"]).toEqual({ - "@double": "1.99", + + 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" } } } }, + }, }); }); it("handles nested conflict types", () => { - expect( - JSON.stringify( - TaggedTypeFormat.encode({ - "@date": { + const encoded = TaggedTypeFormat.encode({ + "@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": { "@time": "2022-12-02T02:00:00.000Z" }, + }, }, }, - }), - ), - ).toEqual( - '{"@object":{"@date":{"@object":{"@date":{"@object":{"@time":{"@time":"2022-12-02T02:00:00.000Z"}}}}}}}', - ); + }, + }, + }); + }); + + 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": { + object: { + "@time": { value: { "@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` @@ -341,7 +506,10 @@ describe.each` } } testCase; - const encoded = TaggedTypeFormat.encode(input); + const encoded = TaggedTypeFormat.encode(input) as + | TaggedInt + | TaggedLong + | TaggedDouble; const encodedKey = Object.keys(encoded)[0]; expect(encodedKey).toEqual(tag); const decoded = TaggedTypeFormat.decode( diff --git a/src/client.ts b/src/client.ts index 986e8e4..8d8ae34 100644 --- a/src/client.ts +++ b/src/client.ts @@ -26,13 +26,14 @@ import { TaggedTypeFormat } from "./tagged-type"; import { getDriverEnv } from "./util/environment"; import { EmbeddedSet, Page, SetIterator, StreamToken } from "./values"; import { + EncodedObject, isQueryFailure, isQuerySuccess, - QueryInterpolation, + QueryOptions, + QueryRequest, StreamEvent, StreamEventData, StreamEventStatus, - type QueryOptions, type QuerySuccess, type QueryValue, } from "./wire-protocol"; @@ -253,13 +254,17 @@ export class Client { ); } - // QueryInterpolation values must always be encoded. - // TODO: The Query implementation never set the QueryRequest arguments. - // When we separate query building from query encoding we should be able - // to simply do `const queryInterpolation: TaggedTypeFormat.encode(query)` - const queryInterpolation = query.toQuery(options).query; + const request: QueryRequest = { + query: query.encode(), + }; + + if (options?.arguments) { + request.arguments = TaggedTypeFormat.encode( + options.arguments, + ) as EncodedObject; + } - return this.#queryWithRetries(queryInterpolation, options); + return this.#queryWithRetries(request, options); } /** @@ -348,8 +353,8 @@ export class Client { } async #queryWithRetries( - queryInterpolation: string | QueryInterpolation, - options?: QueryOptions, + queryRequest: QueryRequest, + queryOptions?: QueryOptions, attempt = 0, ): Promise> { const maxBackoff = @@ -363,11 +368,11 @@ export class Client { attempt += 1; try { - return await this.#query(queryInterpolation, options, attempt); + return await this.#query(queryRequest, queryOptions, attempt); } catch (error) { if (error instanceof ThrottlingError && attempt < maxAttempts) { await wait(backoffMs); - return this.#queryWithRetries(queryInterpolation, options, attempt); + return this.#queryWithRetries(queryRequest, queryOptions, attempt); } throw error; } @@ -462,14 +467,14 @@ in an environmental variable named FAUNA_SECRET or pass it to the Client\ } async #query( - queryInterpolation: string | QueryInterpolation, - options?: QueryOptions, + queryRequest: QueryRequest, + queryOptions?: QueryOptions, attempt = 0, ): Promise> { try { const requestConfig = { ...this.#clientConfiguration, - ...options, + ...queryOptions, }; const headers = { @@ -479,24 +484,13 @@ in an environmental variable named FAUNA_SECRET or pass it to the Client\ const isTaggedFormat = requestConfig.format === "tagged"; - const queryArgs = requestConfig.arguments - ? isTaggedFormat - ? TaggedTypeFormat.encode(requestConfig.arguments) - : requestConfig.arguments - : undefined; - - const requestData = { - query: queryInterpolation, - arguments: queryArgs, - }; - const client_timeout_ms = requestConfig.query_timeout_ms + this.#clientConfiguration.client_timeout_buffer_ms; const response = await this.#httpClient.request({ client_timeout_ms, - data: requestData, + data: queryRequest, headers, method: "POST", }); diff --git a/src/query-builder.ts b/src/query-builder.ts index b3c2f83..381c12e 100644 --- a/src/query-builder.ts +++ b/src/query-builder.ts @@ -1,18 +1,33 @@ import { TaggedTypeFormat } from "./tagged-type"; import type { - QueryValueObject, + FQLFragment, QueryValue, QueryInterpolation, - QueryRequest, - QueryOptions, } from "./wire-protocol"; +/** + * A QueryArgumentObject is a plain javascript object where each property is a + * valid QueryArgument. + */ +export type QueryArgumentObject = { + [key: string]: QueryArgument; +}; + +/** + * A QueryArgument represents all possible values that can be encoded and passed + * to Fauna as a query argument. + * + * The {@link fql} tagged template function requires all arguments to be of type + * QueryArgument. + */ export type QueryArgument = | QueryValue | Query | Date | ArrayBuffer - | Uint8Array; + | Uint8Array + | Array + | QueryArgumentObject; /** * Creates a new Query. Accepts template literal inputs. @@ -44,7 +59,7 @@ export function fql( */ export class Query { readonly #queryFragments: ReadonlyArray; - readonly #queryArgs: QueryArgument[]; + readonly #interpolatedArgs: QueryArgument[]; constructor( queryFragments: ReadonlyArray, @@ -57,61 +72,46 @@ export class Query { throw new Error("invalid query constructed"); } this.#queryFragments = queryFragments; - this.#queryArgs = queryArgs; + this.#interpolatedArgs = queryArgs; } /** - * Converts this Query to a {@link QueryRequest} you can send + * Converts this Query to an {@link FQLFragment} you can send * to Fauna. - * @param requestHeaders - optional {@link QueryOptions} to include - * in the request (and thus override the defaults in your {@link ClientConfiguration}. - * If not passed in, no headers will be set as overrides. - * @returns a {@link QueryRequest}. + * @returns a {@link FQLFragment}. * @example * ```typescript * const num = 8; * const queryBuilder = fql`'foo'.length == ${num}`; * const queryRequest = queryBuilder.toQuery(); * // produces: - * { query: { fql: ["'foo'.length == ", { value: { "@int": "8" } }, ""] }} + * { fql: ["'foo'.length == ", { value: { "@int": "8" } }, ""] } * ``` */ - toQuery(requestHeaders: QueryOptions = {}): QueryRequest { - return { ...this.#render(requestHeaders), ...requestHeaders }; - } - - #render(requestHeaders: QueryOptions): QueryRequest { + encode(): 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]; } - 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) }; - } + // 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.#interpolatedArgs[i]; + 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 040c22e..2b428ed 100644 --- a/src/tagged-type.ts +++ b/src/tagged-type.ts @@ -14,7 +14,26 @@ import { EmbeddedSet, StreamToken, } from "./values"; -import { QueryValueObject, QueryValue } from "./wire-protocol"; +import { + QueryValue, + QueryInterpolation, + ObjectFragment, + ArrayFragment, + FQLFragment, + ValueFragment, + TaggedType, + TaggedLong, + TaggedInt, + TaggedDouble, + TaggedObject, + EncodedObject, + TaggedTime, + TaggedDate, + TaggedMod, + TaggedRef, + TaggedBytes, +} from "./wire-protocol"; +import { Query, QueryArgument, QueryArgumentObject } from "./query-builder"; export interface DecodeOptions { long_type: "number" | "bigint"; @@ -25,13 +44,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: QueryArgument): 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: QueryArgument): QueryInterpolation { + return encodeInterpolation(input); } /** @@ -109,20 +138,6 @@ Returning as Number with loss of precision. Use long_type 'bigint' instead.`); } } -type TaggedBytes = { "@bytes": string }; -type TaggedDate = { "@date": string }; -type TaggedDouble = { "@double": string }; -type TaggedInt = { "@int": string }; -type TaggedLong = { "@long": string }; -type TaggedMod = { "@mod": string }; -type TaggedObject = { "@object": QueryValueObject }; -type TaggedRef = { - "@ref": { id: string; coll: TaggedMod } | { name: string; coll: TaggedMod }; -}; -// WIP: core does not accept `@set` tagged values -// type TaggedSet = { "@set": { data: QueryValue[]; after?: string } }; -type TaggedTime = { "@time": string }; - export const LONG_MIN = BigInt("-9223372036854775808"); export const LONG_MAX = BigInt("9223372036854775807"); export const INT_MIN = -(2 ** 31); @@ -166,9 +181,9 @@ const encodeMap = { string: (value: string): string => { return value; }, - object: (input: QueryValueObject): TaggedObject | QueryValueObject => { + object: (input: QueryArgumentObject): TaggedObject | EncodedObject => { let wrapped = false; - const _out: QueryValueObject = {}; + const _out: EncodedObject = {}; for (const k in input) { if (k.startsWith("@")) { @@ -180,11 +195,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: QueryArgument[]): TaggedType[] => input.map(encode), date: (dateValue: Date): TaggedTime => ({ "@time": dateValue.toISOString(), }), @@ -225,10 +236,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: QueryArgument): TaggedType => { switch (typeof input) { case "bigint": return encodeMap["bigint"](input); @@ -275,13 +283,89 @@ const encode = (input: QueryValue): QueryValue => { throw new ClientError( "Error encoding TypedArray to Fauna Bytes. Convert your TypedArray to Uint8Array or ArrayBuffer before passing it to Fauna. See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray", ); + } 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 QueryArgument is not supported`, + ); } // anything here would be unreachable code }; +const encodeInterpolation = (input: QueryArgument): 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 || + input instanceof StreamToken || + input instanceof Uint8Array || + input instanceof ArrayBuffer || + ArrayBuffer.isView(input) + ) { + 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 QueryArgument is not supported`, + ); + } +}; + +const encodeObjectInterpolation = ( + input: QueryArgumentObject, +): ObjectFragment => { + const _out: EncodedObject = {}; + + 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.encode(); + +const encodeValueInterpolation = (value: TaggedType): ValueFragment => ({ + value, +}); + function base64toBuffer(value: string): Uint8Array { return base64.toByteArray(value); } diff --git a/src/wire-protocol.ts b/src/wire-protocol.ts index 0c83056..9ee9854 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 { fql, QueryArgumentObject } from "./query-builder"; import { DateStub, Document, @@ -17,14 +17,16 @@ import { /** * A request to make to Fauna. */ -export interface QueryRequest { +export interface QueryRequest< + T extends string | QueryInterpolation = string | QueryInterpolation, +> { /** The query */ - query: string | QueryInterpolation; + query: T; /** Optional arguments. Variables in the query will be initialized to the * value associated with an argument key. */ - arguments?: QueryValueObject; + arguments?: EncodedObject; } /** @@ -35,7 +37,7 @@ export interface QueryOptions { /** Optional arguments. Variables in the query will be initialized to the * value associated with an argument key. */ - arguments?: QueryValueObject; + arguments?: QueryArgumentObject; /** * Determines the encoded format expected for the query `arguments` field, and @@ -147,7 +149,10 @@ export type QueryInfo = { stats?: QueryStats; }; -export type QuerySuccess = QueryInfo & { +/** + * A decoded response from a successful query to Fauna + */ +export type QuerySuccess = QueryInfo & { /** * The result of the query. The data is any valid JSON value. * @remarks @@ -160,7 +165,7 @@ export type QuerySuccess = QueryInfo & { }; /** - * A failed query response. Integrations which only want to report a human + * A decoded response from a failed query to Fauna. Integrations which only want to report a human * readable version of the failure can simply print out the "summary" field. */ export type QueryFailure = QueryInfo & { @@ -198,7 +203,9 @@ export type ConstraintFailure = { paths?: Array; }; -export type QueryResponse = QuerySuccess | QueryFailure; +export type QueryResponse = + | QuerySuccess + | QueryFailure; export const isQuerySuccess = (res: any): res is QuerySuccess => res instanceof Object && "data" in res; @@ -219,7 +226,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 @@ -236,10 +247,63 @@ export type QueryInterpolation = FQLFragment | ValueFragment; * const num = 17; * const query = fql`${num} + 3)`; * // produces - * { fql: [{ value: { "@int": "17" } }, " + 3"] } + * { "fql": [{ "value": { "@int": "17" } }, " + 3"] } + * ``` + */ +export type ValueFragment = { value: TaggedType }; + +/** + * 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": { + * "value": { "@date": "2023-09-01" } // Object field values have type QueryInterpolation + * } + * } + * } + * ] + * } + * ``` + */ +export type ObjectFragment = { object: EncodedObject }; + +/** + * 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": [ + * { "value": { "@int": "1" } }, // Array items have type QueryInterpolation + * { "value": { "@int": "2" } } + * ] + * } + * ] + * } * ``` */ -export type ValueFragment = { value: QueryValue }; +export type ArrayFragment = { array: TaggedType[] }; /** * A piece of an interpolated query. Interpolated Queries can be safely composed @@ -252,7 +316,7 @@ export type ValueFragment = { value: QueryValue }; * const query1 = fql`${num} + 3)`; * const query2 = fql`5 + ${query1})`; * // produces - * { fql: ["5 + ", { fql: [{ value: { "@int": "17" } }, " + 3"] }] } + * { "fql": ["5 + ", { "fql": [{ "value": { "@int": "17" } }, " + 3"] }] } * ``` */ export type FQLFragment = { fql: (string | QueryInterpolation)[] }; @@ -281,22 +345,16 @@ export interface Span { } /** - * A QueryValueObject is a plain javascript object where - * each value is a QueryValue. - * i.e. these objects can be set as values - * in the {@link fql} query creation function and can be - * returned in {@link QuerySuccess}. + * A QueryValueObject is a plain javascript object where each value is a valid + * QueryValue. + * These objects can be returned in {@link QuerySuccess}. */ export type QueryValueObject = { [key: string]: QueryValue; }; /** - * A QueryValue can be sent as a value in a query, - * and received from query output. - * i.e. these are the types you can set as values - * in the {@link fql} query creation function and can be - * returned in {@link QuerySuccess}. + * A QueryValue represents the possible return values in a {@link QuerySuccess}. */ export type QueryValue = // plain javascript values @@ -307,6 +365,7 @@ export type QueryValue = | boolean | QueryValueObject | Array + | Uint8Array // client-provided classes | DateStub | TimeStub @@ -318,8 +377,7 @@ export type QueryValue = | NullDocument | Page | EmbeddedSet - | StreamToken - | Uint8Array; + | StreamToken; export type StreamRequest = { token: string; @@ -343,3 +401,35 @@ export type StreamEvent = | StreamEventStatus | StreamEventData | StreamEventError; + +export type TaggedBytes = { "@bytes": string }; +export type TaggedDate = { "@date": string }; +export type TaggedDouble = { "@double": string }; +export type TaggedInt = { "@int": string }; +export type TaggedLong = { "@long": string }; +export type TaggedMod = { "@mod": string }; +export type TaggedObject = { "@object": QueryValueObject }; +export type TaggedRef = { + "@ref": { id: string; coll: TaggedMod } | { name: string; coll: TaggedMod }; +}; +// WIP: core does not accept `@set` tagged values +// type TaggedSet = { "@set": { data: QueryValue[]; after?: string } }; +export type TaggedTime = { "@time": string }; + +export type EncodedObject = { [key: string]: TaggedType }; + +export type TaggedType = + | string + | boolean + | null + | EncodedObject + | TaggedBytes + | TaggedDate + | TaggedDouble + | TaggedInt + | TaggedLong + | TaggedMod + | TaggedObject + | TaggedRef + | TaggedTime + | TaggedType[];