UNPKG

@arkiv-network/sdk

Version:

TypeScript client library for Arkiv's blockchains interactions

383 lines (336 loc) • 15.5 kB
import { describe, expect, it } from "bun:test" import { addr, bytes, dec, defineSchema, i32, key, str, u64, u256 } from "../schema" import { InvalidAttributeNameError } from "../schema/errors" import { InvalidPredicateError, UnsupportedOperatorError } from "./errors" import { and, between, checkAgainstSchema, eq, exists, gt, gte, hasType, lt, lte, or, render, startsWith, } from "./expression" const ENTITY_KEY = `0x${"ab".repeat(32)}` as const const ACCOUNT = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" describe("literals", () => { it("writes every type in its query spelling", () => { expect(String(eq("level", i32(10)))).toBe("level = i32(10)") expect(String(eq("level", i32(-42)))).toBe("level = i32(-42)") expect(String(eq("balance", u256(1_000_000n)))).toBe("balance = u256(1000000)") expect(String(eq("score", dec("3.5")))).toBe("score = dec(3.5)") expect(String(eq("name", str("Bob")))).toBe("name = str('Bob')") expect(String(eq("parent", key(ENTITY_KEY)))).toBe(`parent = key(${ENTITY_KEY})`) expect(String(eq("who", addr(ACCOUNT)))).toBe(`who = addr(${ACCOUNT})`) }) it("writes bool bare — it is the one type with no wrapper", () => { expect(String(eq("flagged", true))).toBe("flagged = true") expect(String(eq("flagged", false))).toBe("flagged = false") }) it("tags what the language would have inferred", () => { // An untagged number literal defaults to i32, so `level = 10` would parse the same. Writing it // out keeps the exact-type assertion — the thing that decides whether a u256 `level` matches — // visible in the query that actually went over the wire. expect(String(eq("level", 10))).toBe("level = i32(10)") }) it("gives bare values the same types they take when written", () => { expect(String(eq("level", 10))).toBe("level = i32(10)") expect(String(eq("balance", 10n))).toBe("balance = u256(10)") expect(String(eq("name", "Bob"))).toBe("name = str('Bob')") expect(String(eq("flagged", true))).toBe("flagged = true") }) it("escapes a quote by doubling it", () => { expect(String(eq("name", "O'Brien"))).toBe("name = str('O''Brien')") expect(String(eq("name", "''"))).toBe("name = str('''''')") }) it("keeps the checksummed form of an address", () => { expect(String(eq("$owner", addr(ACCOUNT.toLowerCase())))).toBe(`$owner = addr(${ACCOUNT})`) }) }) describe("comparisons", () => { it("renders each operator", () => { expect(String(eq("a", 1))).toBe("a = i32(1)") expect(String(gt("a", 1))).toBe("a > i32(1)") expect(String(gte("a", 1))).toBe("a >= i32(1)") expect(String(lt("a", 1))).toBe("a < i32(1)") expect(String(lte("a", 1))).toBe("a <= i32(1)") }) it("allows range operators on every indexable type", () => { expect(String(gt("level", i32(1)))).toBe("level > i32(1)") expect(String(gt("balance", u256(1n)))).toBe("balance > u256(1)") expect(String(gt("score", dec("1.5")))).toBe("score > dec(1.5)") // Ordered as the index orders them: false < true, and unsigned bytes for the rest. expect(String(gte("flagged", true))).toBe("flagged >= true") expect(String(lte("name", "Bob"))).toBe("name <= str('Bob')") expect(String(lt("who", addr(ACCOUNT)))).toBe(`who < addr(${ACCOUNT})`) expect(String(gt("parent", key(ENTITY_KEY)))).toBe(`parent > key(${ENTITY_KEY})`) }) it("carries the offending operator and type on the error", () => { try { startsWith("level", i32(1) as never) expect.unreachable() } catch (error) { expect(error).toBeInstanceOf(UnsupportedOperatorError) const { attributeName, operator, tag } = error as UnsupportedOperatorError expect({ attributeName, operator, tag }).toEqual({ attributeName: "level", operator: "STARTSWITH", tag: "i32", }) } }) it("validates the attribute name against the write-path grammar", () => { expect(() => eq("2fast", 1)).toThrow(InvalidAttributeNameError) expect(() => eq("has space", 1)).toThrow(InvalidAttributeNameError) // Reserved by the query language: `not = i32(1)` could never be parsed as a comparison. expect(() => eq("not", 1)).toThrow(InvalidAttributeNameError) expect(() => eq("str", 1)).toThrow(InvalidAttributeNameError) expect(() => eq("a".repeat(33), 1)).toThrow(InvalidAttributeNameError) }) }) describe("system attributes", () => { it("filters on the four that carry an index", () => { expect(String(eq("$key", key(ENTITY_KEY)))).toBe(`$key = key(${ENTITY_KEY})`) expect(String(eq("$owner", addr(ACCOUNT)))).toBe(`$owner = addr(${ACCOUNT})`) expect(String(eq("$creator", addr(ACCOUNT)))).toBe(`$creator = addr(${ACCOUNT})`) expect(String(lt("$expiresAt", u64(1_200_000n)))).toBe("$expiresAt < u64(1200000)") }) it("holds each one to its fixed type", () => { // The trap this closes: a bare string is a `str`, so `eq("$owner", "0xabc…")` would otherwise // send `$owner = str('0xabc…')` and match nothing at all. expect(() => eq("$owner", ACCOUNT)).toThrow(InvalidPredicateError) expect(() => eq("$owner", ACCOUNT)).toThrow(/is a addr, but the value is a str/) expect(() => eq("$key", ENTITY_KEY)).toThrow(/write key\(\.\.\.\) around the value/) expect(() => eq("$expiresAt", 1_200_000)).toThrow(/is a u64, but the value is a i32/) expect(() => eq("$expiresAt", 1_200_000n)).toThrow(/write u64\(\.\.\.\) around the value/) }) it("allows range comparisons on every one", () => { expect(String(gt("$owner", addr(ACCOUNT)))).toBe(`$owner > addr(${ACCOUNT})`) expect(String(lte("$key", key(ENTITY_KEY)))).toBe(`$key <= key(${ENTITY_KEY})`) }) it("rejects the ones that are returned but not indexed", () => { for (const name of ["$createdAt", "$updatedAt", "$creationFlags"]) { expect(() => eq(name, 1)).toThrow(/carries no index, so it cannot be filtered on/) // A selection names the entity field, which has no `$`. expect(() => eq(name, 1)).toThrow(`Select it as \`${name.slice(1)}\``) } }) it("rejects a $ name that is not a system attribute at all", () => { expect(() => eq("$nope", 1)).toThrow(/is not a system attribute/) expect(() => eq("$nope", 1)).toThrow(/\$key, \$owner, \$creator, \$expiresAt/) }) it("rejects EXISTS and TYPEOF on them, which would be constants", () => { expect(() => exists("$owner")).toThrow(/set on every entity/) expect(() => hasType("$owner", "addr")).toThrow(/always a addr/) }) it("rejects STARTSWITH — none of them is a str", () => { expect(() => startsWith("$owner", "0xd8")).toThrow(UnsupportedOperatorError) }) }) describe("between", () => { it("renders an inclusive range with both bounds tagged", () => { expect(String(between("score", dec("3.5"), dec("5")))).toBe("score BETWEEN dec(3.5) AND dec(5)") expect(String(between("level", 1, 5))).toBe("level BETWEEN i32(1) AND i32(5)") expect(String(between("name", "a", "m"))).toBe("name BETWEEN str('a') AND str('m')") expect(String(between("$expiresAt", u64(100n), u64(200n)))).toBe( "$expiresAt BETWEEN u64(100) AND u64(200)", ) }) it("is a leaf: its own AND needs no parentheses", () => { expect(String(and(between("a", 1, 5), eq("b", 2)))).toBe( "a BETWEEN i32(1) AND i32(5) AND b = i32(2)", ) expect(String(or(between("a", 1, 5), eq("b", 2)))).toBe( "a BETWEEN i32(1) AND i32(5) OR b = i32(2)", ) }) it("rejects bounds of different types", () => { // @ts-expect-error - both bounds must have one type expect(() => between("score", 1, dec("5"))).toThrow(InvalidPredicateError) expect(() => between("score", 1, dec("5") as never)).toThrow(/a i32 and a dec bound/) }) it("applies the checks every comparison does", () => { expect(() => between("$owner", ACCOUNT, ACCOUNT)).toThrow(/is a addr, but the value is a str/) expect(() => between("blob", bytes("0x01") as never, bytes("0x02") as never)).toThrow( /bytes are stored but never indexed/, ) expect(() => between("not", 1, 2)).toThrow(InvalidAttributeNameError) }) it("is checked against a schema like a comparison", () => { const Posts = defineSchema({ score: dec().indexed(), title: str() }) expect(() => checkAgainstSchema(between("score", dec("1"), dec("2")), Posts)).not.toThrow() expect(() => checkAgainstSchema(between("score", 1, 2), Posts)).toThrow(/asserts a i32/) expect(() => checkAgainstSchema(between("title", "a", "b"), Posts)).toThrow(/not indexed/) }) }) describe("startsWith", () => { it("takes a bare string or a str value", () => { expect(String(startsWith("description", "ab"))).toBe("description STARTSWITH str('ab')") expect(String(startsWith("description", str("ab")))).toBe("description STARTSWITH str('ab')") }) it("escapes the prefix like any other string literal", () => { expect(String(startsWith("description", "O'"))).toBe("description STARTSWITH str('O''')") }) it("rejects a non-str value", () => { // @ts-expect-error - only a str has a prefix index expect(() => startsWith("level", i32(1))).toThrow(UnsupportedOperatorError) }) }) describe("exists and hasType", () => { it("renders the function-call forms", () => { expect(String(exists("reviewedBy"))).toBe("EXISTS(reviewedBy)") expect(String(hasType("age", "i32"))).toBe("TYPEOF(age) = i32") }) it("rejects a type that cannot be set on an attribute", () => { // @ts-expect-error - bytes is never indexed, so TYPEOF can never see it expect(() => hasType("blob", "bytes")).toThrow(/is not an indexable attribute type/) // @ts-expect-error - not a type at all expect(() => hasType("age", "int")).toThrow(InvalidPredicateError) }) it("does not mistake an inherited Object key for a type tag", () => { // `tag in TYPE_IDS` walks the prototype chain, so `hasType("a", "toString")` rendered // `TYPEOF(a) = toString` — a query the node can only answer with a parse error. for (const inherited of ["constructor", "toString", "valueOf", "hasOwnProperty"]) { // @ts-expect-error - not a type tag expect(() => hasType("age", inherited)).toThrow(/is not an indexable attribute type/) } }) it("validates the name", () => { expect(() => exists("2fast")).toThrow(InvalidAttributeNameError) expect(() => hasType("2fast", "i32")).toThrow(InvalidAttributeNameError) }) }) describe("combinators", () => { it("joins with the keyword operators", () => { expect(String(and(eq("a", 1), eq("b", 2)))).toBe("a = i32(1) AND b = i32(2)") expect(String(or(eq("a", 1), eq("b", 2)))).toBe("a = i32(1) OR b = i32(2)") }) it("takes an array as well as separate arguments", () => { expect(String(and([eq("a", 1), eq("b", 2)]))).toBe("a = i32(1) AND b = i32(2)") expect(String(or([eq("a", 1), eq("b", 2)]))).toBe("a = i32(1) OR b = i32(2)") }) it("returns a lone expression unwrapped", () => { const one = eq("a", 1) expect(and(one)).toBe(one) expect(or([one])).toBe(one) }) it("refuses to combine nothing", () => { expect(() => and()).toThrow(InvalidPredicateError) expect(() => or([])).toThrow(/needs at least one expression/) }) }) describe("precedence", () => { it("parenthesises an OR inside an AND, and nothing else", () => { expect(String(and(eq("a", 1), or(eq("b", 2), eq("c", 3))))).toBe( "a = i32(1) AND (b = i32(2) OR c = i32(3))", ) // AND binds tighter than OR, so the nesting the other way round needs no parentheses. expect(String(or(eq("a", 1), and(eq("b", 2), eq("c", 3))))).toBe( "a = i32(1) OR b = i32(2) AND c = i32(3)", ) }) it("flattens same-operator nesting rather than bracketing it", () => { expect(String(and(eq("a", 1), and(eq("b", 2), eq("c", 3))))).toBe( "a = i32(1) AND b = i32(2) AND c = i32(3)", ) }) it("renders the spec's worked example", () => { const q = and( gte("level", i32(10)), gte("score", dec("3.5")), lte("score", dec("5")), eq("parent", key(ENTITY_KEY)), eq("flagged", true), startsWith("description", str("ab")), or(eq("status", str("open")), exists("reviewedBy")), lt("$expiresAt", u64(1_200_000n)), ) expect(q.toString()).toBe( "level >= i32(10) AND score >= dec(3.5) AND score <= dec(5) AND " + `parent = key(${ENTITY_KEY}) AND flagged = true AND description STARTSWITH str('ab') AND ` + "(status = str('open') OR EXISTS(reviewedBy)) AND $expiresAt < u64(1200000)", ) }) }) describe("the expression tree", () => { it("is inspectable, frozen data", () => { const expression = eq("level", i32(10)) expect(expression).toEqual({ kind: "comparison", name: "level", operator: "=", value: i32(10), }) expect(Object.isFrozen(expression)).toBe(true) }) it("renders the same string through toString, String() and render()", () => { const expression = and(eq("a", 1), exists("b")) expect(expression.toString()).toBe(render(expression)) expect(`${expression}`).toBe(render(expression)) }) }) describe("bytes", () => { it("cannot be filtered on, bare or tagged — it is never indexed", () => { expect(() => eq("body", new Uint8Array([1]) as never)).toThrow( /bytes are stored but never indexed/, ) expect(() => gt("body", bytes("0x01") as never)).toThrow(InvalidPredicateError) }) }) describe("checkAgainstSchema", () => { const Posts = defineSchema({ author: addr().indexed(), category: str().indexed(), score: dec().indexed(), title: str(), body: bytes(), }) it("accepts indexed columns compared against their own type, and system attributes", () => { expect(() => checkAgainstSchema( and( eq("category", "docs"), gt("score", dec("4")), or(exists("author"), startsWith("category", "d")), hasType("score", "dec"), eq("$owner", addr(ACCOUNT)), ), Posts, ), ).not.toThrow() }) it("rejects an unindexed column, saying how to index it", () => { expect(() => checkAgainstSchema(eq("title", "x"), Posts)).toThrow( /"title" is not indexed.*str\(\)\.indexed\(\)/, ) // Wherever it hides in the tree. expect(() => checkAgainstSchema( and(eq("category", "docs"), or(eq("$owner", addr(ACCOUNT)), exists("title"))), Posts, ), ).toThrow(InvalidPredicateError) }) it("rejects a bytes column without suggesting it be indexed, which bytes never can be", () => { const check = () => checkAgainstSchema(exists("body"), Posts) expect(check).toThrow(InvalidPredicateError) expect(check).toThrow(/"body" is a bytes column, and bytes are stored but never indexed/) expect(check).toThrow(/Index a hash of it, as a hex str, instead/) expect(check).not.toThrow(/\.indexed\(\)/) }) it("rejects a name that is not a column", () => { expect(() => checkAgainstSchema(eq("nope", 1), Posts)).toThrow(/"nope" is not a column/) }) it("rejects a comparison against a value of another type, which could never match", () => { expect(() => checkAgainstSchema(eq("score", 5), Posts)).toThrow( /"score" is a dec column, but the predicate asserts a i32/, ) expect(() => checkAgainstSchema(hasType("score", "i32"), Posts)).toThrow(InvalidPredicateError) }) })