@arkiv-network/sdk
Version:
TypeScript client library for Arkiv's blockchains interactions
325 lines (292 loc) • 11.2 kB
text/typescript
import { describe, expect, it } from "bun:test"
import {
addr,
bytes,
dec,
defineSchema,
InvalidAttributeNameError,
i32,
SchemaMismatchError,
str,
u256,
} from "../schema"
import { Entity } from "../types/entity"
import { projectSelection, toRow, toRpcSelect } from "./selection"
const EVERY_FIELD = {
key: true,
owner: true,
creator: true,
createdAt: true,
updatedAt: true,
expiresAt: true,
creationFlags: true,
attributeSchema: false,
attributes: true,
}
describe("toRpcSelect", () => {
it("selects every field and attribute for no argument or a star, but not the schema", () => {
// The attribute schema describes an entity's shape rather than the entity, so it is only
// returned when asked for by name.
expect(toRpcSelect()).toEqual(EVERY_FIELD)
expect(toRpcSelect("*")).toEqual(EVERY_FIELD)
expect(toRpcSelect({ $key: true, $attributeSchema: true })).toMatchObject({
attributeSchema: true,
})
})
it("sends every field, so what was asked for is legible in the request", () => {
// The node defaults everything but `key` to off. Spelling out the false ones costs nothing and
// pins the answer even if those defaults move.
expect(toRpcSelect({ $owner: true })).toEqual({
key: false,
owner: true,
creator: false,
createdAt: false,
updatedAt: false,
expiresAt: false,
creationFlags: false,
attributeSchema: false,
attributes: false,
})
})
it("asks for any name without a $ as an attribute, by name", () => {
expect(toRpcSelect({ $key: true, title: true, body: true })).toMatchObject({
key: true,
attributes: { title: true, body: true },
})
expect(toRpcSelect({ title: true, body: false })).toMatchObject({ attributes: { title: true } })
expect(() => toRpcSelect({ "not a name": true })).toThrow(InvalidAttributeNameError)
})
it("reads a name an entity field has, written without the $, as an attribute", () => {
// `owner` and `attributes` are ordinary attribute names; only `$owner` is the entity field.
expect(toRpcSelect({ owner: true, attributes: true })).toMatchObject({
owner: false,
attributes: { owner: true, attributes: true },
})
})
it("asks for every attribute with $attributes, and their types with $attributeSchema", () => {
expect(toRpcSelect({ $key: true, $attributes: true, $attributeSchema: true })).toMatchObject({
key: true,
attributes: true,
attributeSchema: true,
})
})
it("rejects a map for $attributes, pointing at selecting attributes by name", () => {
expect(() => toRpcSelect({ $attributes: { title: true } } as never)).toThrow(
/select each by name/,
)
})
it("rejects a $ name that is not an entity field", () => {
expect(() => toRpcSelect({ $title: true })).toThrow(/"\$title", which is not an entity field/)
// The fields are written with the $, so the bare RPC spelling is not one of them either.
expect(() => toRpcSelect({ $schema: true })).toThrow(/not an entity field/)
})
it("rejects a selection that asks for nothing", () => {
expect(() => toRpcSelect({})).toThrow(/at least one field/)
// The type system permits an explicit `false`; the node would answer it with empty rows.
expect(() => toRpcSelect({ $key: false })).toThrow(/at least one field/)
expect(() => toRpcSelect({ $key: false, title: false })).toThrow(/at least one field/)
})
it("counts an attribute alone as a selection", () => {
expect(() => toRpcSelect({ title: true })).not.toThrow()
})
it("treats a field set to undefined as not selected", () => {
expect(toRpcSelect({ $key: true, $owner: undefined })).toMatchObject({
key: true,
owner: false,
})
expect(() => toRpcSelect({ $key: undefined })).toThrow(/at least one field/)
})
})
const Posts = defineSchema({
author: addr().indexed(),
score: dec().indexed(),
title: str(),
body: bytes(),
thumbnail: bytes().optional(),
})
const KEY = `0x${"ab".repeat(32)}` as const
const AUTHOR = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
describe("projectSelection", () => {
it("asks for the selected entity fields, and the selected columns by name", () => {
const projection = projectSelection(
{ $key: true, title: true, body: true, score: false },
Posts,
)
expect(projection.select).toEqual({
key: true,
owner: false,
creator: false,
createdAt: false,
updatedAt: false,
expiresAt: false,
creationFlags: false,
attributeSchema: false,
attributes: { title: true, body: true },
})
expect(projection.fields).toEqual(["$key"])
expect(projection.columns.map(([name]) => name)).toEqual(["title", "body"])
})
it("selects every entity field and every column for nothing or a star", () => {
for (const selection of [undefined, "*"] as const) {
const { select } = projectSelection(selection, Posts)
expect(select).toMatchObject({ key: true, owner: true, creationFlags: true })
// Named, not `true`: attributes beyond the schema stay on the node.
expect(select.attributes).toEqual({
author: true,
score: true,
title: true,
body: true,
thumbnail: true,
})
}
})
it("asks for no attributes when only entity fields are selected", () => {
expect(projectSelection({ $owner: true }, Posts).select.attributes).toBe(false)
})
it("rejects a name that is not a column", () => {
expect(() => projectSelection({ $key: true, nope: true }, Posts)).toThrow(
/"nope", which is not a column/,
)
})
it("reads attributes as a whole the same way through a schema as without one", () => {
const projection = projectSelection({ $key: true, title: true, $attributes: true }, Posts)
expect(projection.select.attributes).toBe(true)
const entity = new Entity({ key: KEY, attributes: { title: str("Hi"), extra: i32(1) } })
expect(toRow(entity, projection)).toEqual({
$key: KEY,
title: str("Hi"),
$attributes: { title: str("Hi"), extra: i32(1) },
})
})
it("rejects a selection that asks for nothing, or a map where a boolean belongs", () => {
expect(() => projectSelection({ $key: false }, Posts)).toThrow(/at least one field/)
expect(() => projectSelection({ title: { x: true } } as never, Posts)).toThrow(/true or false/)
})
})
describe("toRow", () => {
const projection = projectSelection(
{ $key: true, author: true, title: true, thumbnail: true },
Posts,
)
it("lays out the selected fields and columns side by side", () => {
const row = toRow(
new Entity({
key: KEY,
owner: AUTHOR,
attributes: {
author: addr(AUTHOR),
title: str("Hi"),
thumbnail: bytes("0x01"),
extra: i32(1),
},
}),
projection,
)
expect(row).toEqual({
$key: KEY,
author: addr(AUTHOR),
title: str("Hi"),
thumbnail: bytes("0x01"),
})
})
it("keeps a column named like an entity field apart from the field", () => {
const Owned = defineSchema({ owner: addr(), attributes: str() })
const row = toRow(
new Entity({
key: KEY,
owner: AUTHOR,
attributes: { owner: addr(KEY.slice(0, 42)), attributes: str("mine") },
}),
projectSelection({ $owner: true, owner: true, attributes: true }, Owned),
)
expect(row).toEqual({
$owner: AUTHOR,
owner: addr(KEY.slice(0, 42)),
attributes: str("mine"),
})
})
it("gives an optional column the entity lacks as undefined", () => {
const row = toRow(
new Entity({ key: KEY, attributes: { author: addr(AUTHOR), title: str("Hi") } }),
projection,
)
expect(row).toHaveProperty("thumbnail", undefined)
})
it("rejects an entity missing a required column, naming the entity", () => {
const decode = () =>
toRow(new Entity({ key: KEY, attributes: { author: addr(AUTHOR) } }), projection)
expect(decode).toThrow(SchemaMismatchError)
expect(decode).toThrow(new RegExp(`"title".*entity ${KEY} has no value`))
})
it("rejects an entity whose column holds another type than the schema declares", () => {
const decode = () =>
toRow(
new Entity({ key: KEY, attributes: { author: addr(AUTHOR), title: i32(1) } }),
projection,
)
expect(decode).toThrow(/holds a i32, not the str the schema declares/)
})
it("reads a column named after an Object.prototype key as absent when the entity lacks it", () => {
const Odd = defineSchema({ constructor: str().optional(), toString: str().optional() })
const row = toRow(new Entity({ key: KEY, attributes: {} }), projectSelection("*", Odd))
expect(row).toHaveProperty("constructor", undefined)
expect(row).toHaveProperty("toString", undefined)
})
})
describe("a named schema", () => {
const Tagged = defineSchema("posts", { title: str(), score: dec().optional() })
const projection = projectSelection({ $key: true, title: true }, Tagged)
it("fetches the tag alongside the columns, even when no column is selected", () => {
expect(projection.select.attributes).toEqual({ title: true, "arkiv.schema": true })
expect(projectSelection({ $key: true }, Tagged).select.attributes).toEqual({
"arkiv.schema": true,
})
})
it("checks the tag, but leaves it out of the row", () => {
const row = toRow(
new Entity({ key: KEY, attributes: { title: str("Hi"), "arkiv.schema": str("posts") } }),
projection,
)
expect(row).toEqual({ $key: KEY, title: str("Hi") })
})
it("rejects an entity tagged by another schema, or not tagged at all", () => {
const other = () =>
toRow(
new Entity({ key: KEY, attributes: { title: str("Hi"), "arkiv.schema": str("comments") } }),
projection,
)
expect(other).toThrow(SchemaMismatchError)
expect(other).toThrow(/tagged "comments", not "posts"/)
expect(() =>
toRow(new Entity({ key: KEY, attributes: { title: str("Hi") } }), projection),
).toThrow(/not tagged with a schema/)
})
it("leaves the tag out of the attributes map and the attribute schema too", () => {
const whole = projectSelection(
{ $key: true, $attributes: true, $attributeSchema: true },
Tagged,
)
const entity = new Entity({
key: KEY,
attributes: { title: str("Hi"), "arkiv.schema": str("posts") },
attributeSchema: {
title: { type: "str", indexed: false },
"arkiv.schema": { type: "str", indexed: true },
},
})
expect(toRow(entity, whole)).toEqual({
$key: KEY,
$attributes: { title: str("Hi") },
$attributeSchema: { title: { type: "str", indexed: false } },
})
})
it("rejects an entity whose tag is not a str, whatever its type", () => {
const decode = () =>
toRow(
new Entity({ key: KEY, attributes: { title: str("Hi"), "arkiv.schema": u256(5n) } }),
projection,
)
expect(decode).toThrow(SchemaMismatchError)
expect(decode).toThrow(/tagged with a value of type u256, not "posts"/)
})
})