diff --git a/docs/table-of-contents.json b/docs/table-of-contents.json index 1180612..5a9ca0c 100644 --- a/docs/table-of-contents.json +++ b/docs/table-of-contents.json @@ -23,7 +23,8 @@ "v2": { "title": "Version 2 (not yet released)", "pages": [ - ["pagination", "Pagination"] + ["pagination", "Pagination"], + ["working-with-arrays", "Working with arrays"] ] } } diff --git a/docs/v2/working-with-arrays.md b/docs/v2/working-with-arrays.md new file mode 100644 index 0000000..822646a --- /dev/null +++ b/docs/v2/working-with-arrays.md @@ -0,0 +1,119 @@ +# Working with arrays + +The LDkit library provides a simple and intuitive way to work with arrays in +linked data contexts. This section focuses on the manipulation of array +elements, including adding and removing elements. + +## Initializing and Defining Models + +Before working with arrays, initialize your data source and define your data +schema. Examples below make use of a Director schema, having a director name and +a list of movies: + +```typescript +import { type Context, createLens, createNamespace } from "ldkit"; + +// Create a custom namespace +const ns = createNamespace( + { + "iri": "http://ns/", + "prefix": "ns", + "terms": [ + "name", + "movie", + ], + } as const, +); + +// Create a schema +const DirectorSchema = { + name: ns.name, + movies: { + "@id": ns.movie, + "@array": true, + }, +} as const; + +const Directors = createLens(DirectorSchema); + +// Add a director with a an empty list of movies +await Directors.insert({ + $id: "https://Quentin_Tarantino", + name: "Quentin Tarantino", + movies: [], +}); +``` + +## Updating arrays + +To modify array elements, use the `Lens.update` method. This method supports +different operations. + +1. **Setting an Array**: Replace the entine array with a new set of elements + +```typescript +await Directors.update({ + $id: "https://Quentin_Tarantino", + movies: { + $set: ["Pulp Fiction", "Reservoir Dogs"], + }, +}); +``` + +2. **Adding Elements**: Append new elements to the array + +```typescript +await Directors.update({ + $id: "https://Quentin_Tarantino", + movies: { + $add: ["Kill Bill", "Kill Bill 2"], + }, +}); // The `movies` is now ["Pulp Fiction", "Reservoir Dogs", "Kill Bill", "Kill Bill 2"] +``` + +3. **Removing Elements**: Remove specific elements from the array + +```typescript +await Directors.update({ + $id: "https://Quentin_Tarantino", + movies: { + $remove: ["Reservoir Dogs"], + }, +}); // The `movies` is now ["Pulp Fiction", "Kill Bill", "Kill Bill 2"] +``` + +4. **Setting an Empty Array**: Clear all elements from the array + +```typescript +await Directors.update({ + $id: "https://Quentin_Tarantino", + movies: { + $set: [], // Remove all movies + }, +}); +``` + +## Working with Multiple Entitites + +You can also perform array updates on multiple entities simultaneously using a +single SPARQL query. + +```typescript +await Directors.insert({ + $id: "https://David_Fincher", + name: "David Fincher", + movies: ["Fight Club", "The Social Network"], +}); + +await Directors.update({ + $id: "https://Quentin_Tarantino", + movies: { + $set: ["Inglorious Basterds"], + }, +}, { + $id: "https://David_Fincher", + movies: { + $add: ["The Curious Case of Benjamin Button"], + }, +}); +``` diff --git a/examples/basic/arrays.ts b/examples/basic/arrays.ts new file mode 100644 index 0000000..dc17b4d --- /dev/null +++ b/examples/basic/arrays.ts @@ -0,0 +1,87 @@ +import { type Context, createLens, createNamespace } from "ldkit"; +import { DataFactory, N3 } from "ldkit/rdf"; +import { QueryEngine as Comunica } from "npm:@comunica/query-sparql-rdfjs@2.5.2"; + +// Create a custom namespace +const ns = createNamespace( + { + "iri": "http://ns/", + "prefix": "ns", + "terms": [ + "name", + "movie", + ], + } as const, +); + +// Create a schema +const DirectorSchema = { + name: ns.name, + movies: { + "@id": ns.movie, + "@array": true, + }, +} as const; + +// Create in memory data store and context for query engine +const store = new N3.Store(undefined, { + factory: new DataFactory(), +}); +const context: Context = { + sources: [store], +}; +const engine = new Comunica(); + +// Create a resource using the data schema and context above +const Directors = createLens(DirectorSchema, context, engine); + +// Add a director with a list of some movies +await Directors.insert({ + $id: "https://Quentin_Tarantino", + name: "Quentin Tarantino", + movies: ["Pulp Fiction", "Reservoir Dogs"], +}); + +// Add a movie to the list of movies +await Directors.update({ + $id: "https://Quentin_Tarantino", + movies: { + $add: ["Kill Bill", "Kill Bill 2"], + }, +}); + +// Remove a movie from the list of movies +await Directors.update({ + $id: "https://Quentin_Tarantino", + movies: { + $remove: ["Reservoir Dogs"], + }, +}); + +// Print the list of movies +const tarantino = await Directors.findByIri("https://Quentin_Tarantino"); +console.log("Tarantino movies", tarantino?.movies); + +// Add another director with a list of some movies +await Directors.insert({ + $id: "https://David_Fincher", + name: "David Fincher", + movies: ["Fight Club", "The Social Network"], +}); + +// Modify the list of movies for both directors +await Directors.update({ + $id: "https://Quentin_Tarantino", + movies: { + $set: [], // Remove all movies + }, +}, { + $id: "https://David_Fincher", + movies: { + $add: ["The Curious Case of Benjamin Button"], + }, +}); + +// Print the list of movies of the other director +const fincher = await Directors.findByIri("https://David_Fincher"); +console.log("Fincher movies", fincher?.movies); diff --git a/examples/basic/deno.json b/examples/basic/deno.json index 34e3555..da58fbe 100644 --- a/examples/basic/deno.json +++ b/examples/basic/deno.json @@ -1,7 +1,8 @@ { "importMap": "../import_map.json", "tasks": { - "main": "deno run --allow-net --allow-env ./main.ts" + "main": "deno run --allow-net --allow-env ./main.ts", + "arrays": "deno run -A ./arrays.ts" }, "lock": false } diff --git a/library/encoder.ts b/library/encoder.ts index c5664bf..60a5070 100644 --- a/library/encoder.ts +++ b/library/encoder.ts @@ -11,13 +11,21 @@ export const encode = ( node: DecodedNode, schema: Schema, context: Context, + includeType = true, variableInitCounter = 0, ) => { - return Encoder.encode(node, schema, context, variableInitCounter); + return Encoder.encode( + node, + schema, + context, + includeType, + variableInitCounter, + ); }; class Encoder { - private context: Context; + private readonly context: Context; + private readonly includeType: boolean; private df: DataFactory = new DataFactory({ blankNodePrefix: "b", @@ -27,8 +35,13 @@ class Encoder { private output: RDF.Quad[] = []; - private constructor(context: Context, variableInitCounter: number) { + private constructor( + context: Context, + includeType: boolean, + variableInitCounter: number, + ) { this.context = context; + this.includeType = includeType; this.variableCounter = variableInitCounter; } @@ -36,9 +49,13 @@ class Encoder { node: DecodedNode, schema: Schema, context: Context, + includeType: boolean, variableInitCounter: number, ) { - return new Encoder(context, variableInitCounter).encode(node, schema); + return new Encoder(context, includeType, variableInitCounter).encode( + node, + schema, + ); } encode(node: DecodedNode, schema: Schema) { @@ -71,7 +88,9 @@ class Encoder { } encodeNode(node: DecodedNode, schema: Schema, nodeId: NodeId) { - this.encodeNodeType(node, schema["@type"], nodeId); + if (this.includeType) { + this.encodeNodeType(node, schema["@type"], nodeId); + } Object.keys(schema).forEach((key) => { if (key === "@type") { diff --git a/library/lens/lens.ts b/library/lens/lens.ts index da364c0..743b659 100644 --- a/library/lens/lens.ts +++ b/library/lens/lens.ts @@ -6,6 +6,7 @@ import { type SchemaInterface, type SchemaInterfaceIdentity, type SchemaPrototype, + type SchemaUpdateInterface, } from "../schema/mod.ts"; import { decode } from "../decoder.ts"; @@ -39,7 +40,11 @@ export const createResource = ( engine?: IQueryEngine, ) => new Lens(schema, context, engine); -export class Lens> { +export class Lens< + S extends SchemaPrototype, + I = SchemaInterface, + U = SchemaUpdateInterface, +> { private readonly schema: Schema; private readonly context: Context; private readonly engine: QueryEngineProxy; @@ -113,9 +118,9 @@ export class Lens> { return this.updateQuery(q); } - update(...entities: Entity[]) { - const q = this.queryBuilder.updateQuery(entities); - + update(...entities: U[]) { + const q = this.queryBuilder.updateQuery(entities as Entity[]); + // TODO: console.log(q); return this.updateQuery(q); } diff --git a/library/lens/query_builder.ts b/library/lens/query_builder.ts index 664f949..7759d12 100644 --- a/library/lens/query_builder.ts +++ b/library/lens/query_builder.ts @@ -14,7 +14,7 @@ import rdf from "../namespaces/rdf.ts"; import { encode } from "../encoder.ts"; import { type Entity } from "./types.ts"; -import { QueryHelper } from "./query_helper.ts"; +import { UpdateHelper } from "./update_helper.ts"; export class QueryBuilder { private readonly schema: Schema; @@ -163,23 +163,13 @@ export class QueryBuilder { } updateQuery(entities: Entity[]) { - const deleteQuads: RDF.Quad[] = []; - const insertQuads: RDF.Quad[] = []; - const whereQuads: RDF.Quad[] = []; - - entities.forEach((entity, index) => { - const helper = new QueryHelper( - entity, - this.schema, - this.context, - 1000 * index, - ); - deleteQuads.push(...helper.getDeleteQuads()); - insertQuads.push(...helper.getInsertQuads()); - whereQuads.push(...helper.getWhereQuads()); - }); - - return DELETE`${deleteQuads}`.INSERT`${insertQuads}` - .WHERE`${deleteQuads}`.build(); + const helper = new UpdateHelper(this.schema, this.context); + + for (const entity of entities) { + helper.process(entity); + } + + return DELETE`${helper.deleteQuads}`.INSERT`${helper.insertQuads}` + .WHERE`${helper.deleteQuads}`.build(); } } diff --git a/library/lens/query_helper.ts b/library/lens/query_helper.ts index 1b7f110..6b8c049 100644 --- a/library/lens/query_helper.ts +++ b/library/lens/query_helper.ts @@ -3,6 +3,10 @@ import type { Property, Schema } from "../schema/mod.ts"; import { encode } from "../encoder.ts"; import type { Entity } from "./types.ts"; +/** + * @deprecated + * TODO: Remove this class + */ export class QueryHelper { private readonly entity: Entity; private readonly schema: Schema; @@ -30,6 +34,7 @@ export class QueryHelper { this.entity, this.schema, this.context, + true, this.variableInitCounter, ); } @@ -42,6 +47,7 @@ export class QueryHelper { this.getEntityWithReplacedVariables(), this.schema, this.context, + true, this.variableInitCounter, ); } diff --git a/library/lens/update_helper.ts b/library/lens/update_helper.ts new file mode 100644 index 0000000..3b6ee39 --- /dev/null +++ b/library/lens/update_helper.ts @@ -0,0 +1,186 @@ +import type { Context, RDF } from "../rdf.ts"; +import { + getSchemaProperties, + type Property, + type Schema, +} from "../schema/mod.ts"; +import { encode } from "../encoder.ts"; +import type { Entity } from "./types.ts"; + +export class UpdateHelper { + private readonly schema: Schema; + private readonly properties: Record; + private readonly context: Context; + + private variableCounter = 0; + + public readonly deleteQuads: RDF.Quad[] = []; + public readonly insertQuads: RDF.Quad[] = []; + public readonly whereQuads: RDF.Quad[] = []; + + constructor( + schema: Schema, + context: Context, + variableInitCounter = 0, + ) { + this.schema = schema; + this.properties = getSchemaProperties(schema); + this.context = context; + this.variableCounter = variableInitCounter; + } + + public process(entity: Entity) { + for (const name in this.properties) { + this.processProperty(entity, name, this.properties[name]); + } + } + + private processProperty( + entity: Entity, + propertyName: string, + property: Property, + ) { + this.variableCounter++; + + const value = entity[propertyName]; + + if (value === undefined) { + return; + } + + if (property["@array"]) { + this.processArrayProperty(entity, propertyName, value); + } else { + this.processSingleProperty(entity, propertyName, value, property); + } + } + + private processSingleProperty( + entity: Entity, + propertyName: string, + propertyValue: unknown, + property: Property, + ) { + const deletePattern = { + $id: entity.$id, + [propertyName]: null, + }; + const quadsToDelete = this.encode(deletePattern); + this.deleteQuads.push(...quadsToDelete); + + if (property["@optional"] && propertyValue === null) { + // The intention was to delete a value of an optional property, nothing to insert + return; + } + + const insertPattern = { + $id: entity.$id, + [propertyName]: propertyValue, + }; + const quadsToInsert = this.encode(insertPattern); + this.insertQuads.push(...quadsToInsert); + } + + private processArrayProperty( + entity: Entity, + propertyName: string, + propertyValue: unknown, + ) { + const config = this.parseArrayUpdateConfig(propertyValue); + if (config.$set) { + this.processArraySet(entity, propertyName, config.$set); + } else { + this.processArrayAddRemove( + entity, + propertyName, + config.$add, + config.$remove, + ); + } + } + + private processArraySet( + entity: Entity, + propertyName: string, + propertyValue: unknown[], + ) { + const deletePattern = { + $id: entity.$id, + [propertyName]: null, + }; + const quadsToDelete = this.encode(deletePattern); + this.deleteQuads.push(...quadsToDelete); + + const insertPattern = { + $id: entity.$id, + [propertyName]: propertyValue, + }; + const quadsToInsert = this.encode(insertPattern); + this.insertQuads.push(...quadsToInsert); + } + + private processArrayAddRemove( + entity: Entity, + propertyName: string, + $add: unknown[] | undefined, + $remove: unknown[] | undefined, + ) { + if ($remove) { + const deletePattern = { + $id: entity.$id, + [propertyName]: $remove, + }; + const quadsToDelete = this.encode(deletePattern); + this.deleteQuads.push(...quadsToDelete); + } + + if ($add) { + const insertPattern = { + $id: entity.$id, + [propertyName]: $add, + }; + const quadsToInsert = this.encode(insertPattern); + this.insertQuads.push(...quadsToInsert); + } + } + + private parseArrayUpdateConfig( + config: unknown, + ): Record<"$add" | "$set" | "$remove", unknown[] | undefined> { + if (Array.isArray(config)) { + return { $set: config, $add: undefined, $remove: undefined }; + } + if (config == null || typeof config !== "object") { + throw new Error( + "Invalid array update query, expected an array, or an object with $add, $set, or $remove properties", + ); + } + + const { $add, $set, $remove } = config as Record< + string, + unknown[] | undefined + >; + + switch (true) { + case Array.isArray($set) && $add === undefined && $remove === undefined: + case $set === undefined && Array.isArray($add) && $remove === undefined: + case $set === undefined && $add === undefined && Array.isArray($remove): + case $set === undefined && Array.isArray($add) && Array.isArray($remove): + return { $add, $set, $remove }; + } + + throw new Error( + "Invalid array update query, expected an array, or an object with $add, $set, or $remove properties", + ); + } + + private encode(entity: Entity) { + return encode( + entity, + this.schema, + this.context, + false, + this.variableCounter, + ); + } +} diff --git a/library/schema/interface.ts b/library/schema/interface.ts index 71be94e..8c9fc18 100644 --- a/library/schema/interface.ts +++ b/library/schema/interface.ts @@ -1,6 +1,18 @@ import type { SupportedDataTypes } from "./data_types.ts"; import type { PropertyPrototype, SchemaPrototype } from "./schema.ts"; +type Prettify = + & { + [K in keyof T]: T[K]; + } + // deno-lint-ignore ban-types + & {}; + +type Unite = T extends Record ? { + [Key in keyof T]: T[Key]; + } + : T; + type IsOptional = Property extends { "@optional": true; } ? true @@ -18,26 +30,25 @@ type IsMultilang = Property extends { type ValidPropertyDefinition = PropertyPrototype | string; -type ConvertPropertyType = T extends { - "@context": SchemaPrototype; -} - // embedded schema - ? SchemaInterface - // type specified - : T extends { "@type": unknown } ? T["@type"] extends keyof SupportedDataTypes - // type is built-int - ? SupportedDataTypes[T["@type"]] - // type is invalid - : never +type ConvertPropertyType = T extends + { "@type": unknown } ? T["@type"] extends keyof SupportedDataTypes + // type is built-int + ? SupportedDataTypes[T["@type"]] + // type is invalid + : never // no type -> defaults to string : string; +type ConvertPropertyContext = T extends + { "@context": SchemaPrototype } ? Unite> + : ConvertPropertyType; + type ConvertPropertyOptional = - IsOptional extends true ? ConvertPropertyType | undefined - : ConvertPropertyType; + IsOptional extends true ? ConvertPropertyContext | null + : ConvertPropertyContext; type ConvertPropertyArray = IsArray extends true - ? ConvertPropertyType[] + ? ConvertPropertyContext[] : ConvertPropertyOptional; type ConvertPropertyMultilang = @@ -61,9 +72,47 @@ export type SchemaInterfaceType = { }; export type SchemaInterface = + & SchemaInterfaceIdentity & { [X in Exclude]: T[X] extends ValidPropertyDefinition ? ConvertProperty : never; - } - & SchemaInterfaceIdentity; + }; + +type ConvertUpdatePropertyContext = T extends + { "@context": SchemaPrototype } ? Unite> + : ConvertPropertyType; + +type ConvertUpdatePropertyOptional = + IsOptional extends true ? ConvertPropertyContext | null + : ConvertUpdatePropertyContext; + +type CreateArrayUpdateInterface = { + $set?: ConvertPropertyType[]; + $add?: ConvertPropertyType[]; + $remove?: ConvertPropertyType[]; +} | ConvertPropertyType[]; + +type ConvertUpdatePropertyArray = + IsArray extends true ? CreateArrayUpdateInterface + : ConvertUpdatePropertyOptional; + +type ConvertUpdatePropertyMultilang = + IsMultilang extends true + ? IsArray extends true ? Record> + : Record> + : ConvertUpdatePropertyArray; + +type ConvertUpdatePropertyObject = + ConvertUpdatePropertyMultilang; + +type ConvertUpdateProperty = T extends + PropertyPrototype ? ConvertUpdatePropertyObject : string; + +export type SchemaUpdateInterface = + & SchemaInterfaceIdentity + & { + [X in Exclude]?: T[X] extends ValidPropertyDefinition + ? ConvertUpdateProperty + : never; + }; diff --git a/library/schema/mod.ts b/library/schema/mod.ts index 99c2efd..da2a041 100644 --- a/library/schema/mod.ts +++ b/library/schema/mod.ts @@ -2,6 +2,7 @@ export type { SchemaInterface, SchemaInterfaceIdentity, SchemaInterfaceType, + SchemaUpdateInterface, } from "./interface.ts"; export type { diff --git a/tests/e2e/arrays.test.ts b/tests/e2e/arrays.test.ts new file mode 100644 index 0000000..23d465d --- /dev/null +++ b/tests/e2e/arrays.test.ts @@ -0,0 +1,139 @@ +import { Comunica } from "../test_deps.ts"; + +import { initStore, ttl, x } from "../test_utils.ts"; + +import { createLens } from "ldkit"; + +const engine = new Comunica(); + +const Director = { + name: x.name, + movies: { + "@id": x.movie, + "@array": true, + }, +} as const; + +const defaultStoreContent = ttl(` + x:QuentinTarantino + x:name "Quentin Tarantino" ; + x:movie "Pulp Fiction", "Reservoir Dogs" . + `); + +const init = () => { + const { store, context, assertStore, empty } = initStore(); + store.addQuads(defaultStoreContent); + const Directors = createLens(Director, context, engine); + return { Directors, assertStore, empty }; +}; + +Deno.test("E2E / Array / Set shortcut", async () => { + const { Directors, assertStore } = init(); + + await Directors.update({ + $id: x.QuentinTarantino, + movies: ["Kill Bill", "Inglorious Basterds"], + }); + + assertStore(` + x:QuentinTarantino + x:name "Quentin Tarantino" ; + x:movie "Kill Bill", "Inglorious Basterds" . + `); +}); + +Deno.test("E2E / Array / Set full", async () => { + const { Directors, assertStore } = init(); + + await Directors.update({ + $id: x.QuentinTarantino, + movies: { + $set: ["Kill Bill", "Inglorious Basterds"], + }, + }); + + assertStore(` + x:QuentinTarantino + x:name "Quentin Tarantino" ; + x:movie "Kill Bill", "Inglorious Basterds" . + `); +}); + +Deno.test("E2E / Array / Set empty array", async () => { + const { Directors, assertStore } = init(); + + await Directors.update({ + $id: x.QuentinTarantino, + movies: [], + }); + + assertStore(` + x:QuentinTarantino + x:name "Quentin Tarantino" . + `); +}); + +Deno.test("E2E / Array / Add", async () => { + const { Directors, assertStore } = init(); + + await Directors.update({ + $id: x.QuentinTarantino, + movies: { + $add: ["Kill Bill", "Inglorious Basterds"], + }, + }); + + assertStore(` + x:QuentinTarantino + x:name "Quentin Tarantino" ; + x:movie "Pulp Fiction", "Reservoir Dogs", "Kill Bill", "Inglorious Basterds" . + `); +}); + +Deno.test("E2E / Array / Remove", async () => { + const { Directors, assertStore } = init(); + + await Directors.update({ + $id: x.QuentinTarantino, + movies: { + $remove: ["Pulp Fiction"], + }, + }); + + assertStore(` + x:QuentinTarantino + x:name "Quentin Tarantino" ; + x:movie "Reservoir Dogs" . + `); +}); + +Deno.test("E2E / Array / Update multiple entities", async () => { + const { Directors, assertStore } = init(); + + await Directors.insert({ + $id: x.StanleyKubrick, + name: "Stanley Kubrick", + movies: ["2001: A Space Odyssey", "The Shining"], + }); + + await Directors.update({ + $id: x.QuentinTarantino, + movies: { + $remove: ["Reservoir Dogs"], + }, + }, { + $id: x.StanleyKubrick, + movies: { + $add: ["A Clockwork Orange"], + }, + }); + + assertStore(` + x:QuentinTarantino + x:name "Quentin Tarantino" ; + x:movie "Pulp Fiction" . + x:StanleyKubrick + x:name "Stanley Kubrick" ; + x:movie "2001: A Space Odyssey", "The Shining", "A Clockwork Orange" . + `); +}); diff --git a/tests/schema.test.ts b/tests/schema.test.ts index e6732f8..a107ffe 100644 --- a/tests/schema.test.ts +++ b/tests/schema.test.ts @@ -1,5 +1,10 @@ -import { assertEquals, assertThrows, assertTypeSafe } from "./test_deps.ts"; -import { Equals, x } from "./test_utils.ts"; +import { + assertEquals, + assertThrows, + assertTypeSafe, + type Equals, +} from "./test_deps.ts"; +import { x } from "./test_utils.ts"; import { expandSchema, @@ -7,128 +12,407 @@ import { type Schema, type SchemaInterface, type SchemaPrototype, + type SchemaUpdateInterface, } from "../library/schema/mod.ts"; -import { xsd } from "../library/namespaces/mod.ts"; -import rdf from "../library/namespaces/rdf.ts"; - -type ThingType = { - $id: string; - required: string; - optional: string | undefined; - array: string[]; - multilang: Record; - multilangArray: Record; - number: number; - boolean: boolean; - date: Date; - nested: { +import { rdf, xsd } from "../namespaces.ts"; + +type ArrayUpdate = { + $set?: T[]; + $add?: T[]; + $remove?: T[]; +} | T[]; + +Deno.test("Schema / Full schema", () => { + type ThingType = { + $id: string; + required: string; + optional: string | null; + array: string[]; + multilang: Record; + multilangArray: Record; + number: number; + boolean: boolean; + date: Date; + nested: { + $id: string; + nestedValue: string; + }; + }; + + type ThingUpdateType = { $id: string; - nestedValue: string; - }; -}; - -const Thing = { - "@type": x.X, - required: x.required, - optional: { - "@id": x.optional, - "@optional": true, - }, - array: { - "@id": x.array, - "@array": true, - }, - multilang: { - "@id": x.multilang, - "@multilang": true, - }, - multilangArray: { - "@id": x.multilangArray, - "@multilang": true, - "@array": true, - }, - number: { - "@id": x.number, - "@type": xsd.integer, - }, - boolean: { - "@id": x.boolean, - "@type": xsd.boolean, - }, - date: { - "@id": x.date, - "@type": xsd.date, - }, - nested: { - "@id": x.nested, - "@context": { - "@type": x.Nested, - nestedValue: x.nestedValue, - }, - }, -} as const; - -const ThingSchema: Schema = { - "@type": [x.X], - required: { - "@id": x.required, - "@type": xsd.string, - }, - optional: { - "@id": x.optional, - "@type": xsd.string, - "@optional": true, - }, - array: { - "@id": x.array, - "@type": xsd.string, - "@array": true, - }, - multilang: { - "@id": x.multilang, - "@type": xsd.string, - "@multilang": true, - }, - multilangArray: { - "@id": x.multilangArray, - "@type": xsd.string, - "@multilang": true, - "@array": true, - }, - number: { - "@id": x.number, - "@type": xsd.integer, - }, - boolean: { - "@id": x.boolean, - "@type": xsd.boolean, - }, - date: { - "@id": x.date, - "@type": xsd.date, - }, - nested: { - "@id": x.nested, - "@context": { - "@type": [x.Nested], - nestedValue: { - "@id": x.nestedValue, - "@type": xsd.string, + required?: string; + optional?: string | null; + array?: ArrayUpdate; + multilang?: Record; + multilangArray?: Record>; + number?: number; + boolean?: boolean; + date?: Date; + nested?: { + $id: string; + nestedValue?: string; + }; + }; + + const Thing = { + "@type": x.X, + required: x.required, + optional: { + "@id": x.optional, + "@optional": true, + }, + array: { + "@id": x.array, + "@array": true, + }, + multilang: { + "@id": x.multilang, + "@multilang": true, + }, + multilangArray: { + "@id": x.multilangArray, + "@multilang": true, + "@array": true, + }, + number: { + "@id": x.number, + "@type": xsd.integer, + }, + boolean: { + "@id": x.boolean, + "@type": xsd.boolean, + }, + date: { + "@id": x.date, + "@type": xsd.date, + }, + nested: { + "@id": x.nested, + "@context": { + "@type": x.Nested, + nestedValue: x.nestedValue, }, }, - }, -}; + } as const; -Deno.test("Schema / accepts schema prototype as schema interface creates schema interface from schema prototype", () => { + const ThingSchema: Schema = { + "@type": [x.X], + required: { + "@id": x.required, + "@type": xsd.string, + }, + optional: { + "@id": x.optional, + "@type": xsd.string, + "@optional": true, + }, + array: { + "@id": x.array, + "@type": xsd.string, + "@array": true, + }, + multilang: { + "@id": x.multilang, + "@type": xsd.string, + "@multilang": true, + }, + multilangArray: { + "@id": x.multilangArray, + "@type": xsd.string, + "@multilang": true, + "@array": true, + }, + number: { + "@id": x.number, + "@type": xsd.integer, + }, + boolean: { + "@id": x.boolean, + "@type": xsd.boolean, + }, + date: { + "@id": x.date, + "@type": xsd.date, + }, + nested: { + "@id": x.nested, + "@context": { + "@type": [x.Nested], + nestedValue: { + "@id": x.nestedValue, + "@type": xsd.string, + }, + }, + }, + }; const expandedSchema = expandSchema(Thing); type I = SchemaInterface; + type U = SchemaUpdateInterface; - assertTypeSafe, ThingType>>(); + assertTypeSafe>(); + assertTypeSafe>(); assertEquals(expandedSchema, ThingSchema); }); +Deno.test("Schema / Basic datatypes", () => { + const Prototype = { + default: x.default, + string: { + "@id": x.string, + "@type": xsd.string, + }, + number: { + "@id": x.number, + "@type": xsd.integer, + }, + boolean: { + "@id": x.boolean, + "@type": xsd.boolean, + }, + date: { + "@id": x.date, + "@type": xsd.date, + }, + } as const; + + type PrototypeInterface = { + $id: string; + default: string; + string: string; + number: number; + boolean: boolean; + date: Date; + }; + + type PrototypeUpdateInterface = { + $id: string; + default?: string; + string?: string; + number?: number; + boolean?: boolean; + date?: Date; + }; + + const PrototypeSchema: Schema = { + "@type": [], + default: { + "@id": x.default, + "@type": xsd.string, + }, + string: { + "@id": x.string, + "@type": xsd.string, + }, + number: { + "@id": x.number, + "@type": xsd.integer, + }, + boolean: { + "@id": x.boolean, + "@type": xsd.boolean, + }, + date: { + "@id": x.date, + "@type": xsd.date, + }, + }; + + type I = SchemaInterface; + type U = SchemaUpdateInterface; + + assertTypeSafe>(); + assertTypeSafe>(); + + assertEquals(expandSchema(Prototype), PrototypeSchema); +}); + +Deno.test("Schema / Optional", () => { + const Prototype = { + optional: { + "@id": x.optional, + "@optional": true, + }, + } as const; + + type PrototypeInterface = { + $id: string; + optional: string | null; + }; + + type PrototypeUpdateInterface = { + $id: string; + optional?: string | null; + }; + + const PrototypeSchema: Schema = { + "@type": [], + optional: { + "@id": x.optional, + "@type": xsd.string, + "@optional": true, + }, + }; + + type I = SchemaInterface; + type U = SchemaUpdateInterface; + + assertTypeSafe>(); + assertTypeSafe>(); + + assertEquals(expandSchema(Prototype), PrototypeSchema); +}); + +Deno.test("Schema / Array", () => { + const Prototype = { + array: { + "@id": x.array, + "@array": true, + }, + optionalArray: { + "@id": x.optionalArray, + "@array": true, + "@optional": true, + }, + } as const; + + type PrototypeInterface = { + $id: string; + array: string[]; + optionalArray: string[]; + }; + + type PrototypeUpdateInterface = { + $id: string; + array?: ArrayUpdate; + optionalArray?: ArrayUpdate; + }; + + const PrototypeSchema: Schema = { + "@type": [], + array: { + "@id": x.array, + "@type": xsd.string, + "@array": true, + }, + optionalArray: { + "@id": x.optionalArray, + "@type": xsd.string, + "@array": true, + "@optional": true, + }, + }; + + type I = SchemaInterface; + type U = SchemaUpdateInterface; + + assertTypeSafe>(); + assertTypeSafe>(); + + assertEquals(expandSchema(Prototype), PrototypeSchema); +}); + +Deno.test("Schema / Multilang", () => { + const Prototype = { + multilang: { + "@id": x.multilang, + "@multilang": true, + }, + multilangArray: { + "@id": x.multilangArray, + "@multilang": true, + "@array": true, + }, + } as const; + + type PrototypeInterface = { + $id: string; + multilang: Record; + multilangArray: Record; + }; + + type PrototypeUpdateInterface = { + $id: string; + multilang?: Record; + multilangArray?: Record>; + }; + + const PrototypeSchema: Schema = { + "@type": [], + multilang: { + "@id": x.multilang, + "@type": xsd.string, + "@multilang": true, + }, + multilangArray: { + "@id": x.multilangArray, + "@type": xsd.string, + "@multilang": true, + "@array": true, + }, + }; + + type I = SchemaInterface; + type U = SchemaUpdateInterface; + + assertTypeSafe>(); + assertTypeSafe>(); + + assertEquals(expandSchema(Prototype), PrototypeSchema); +}); + +Deno.test("Schema / Nested schema", () => { + const Prototype = { + nested: { + "@id": x.nested, + "@context": { + "@type": x.Nested, + nestedValue: x.nestedValue, + }, + }, + } as const; + + type PrototypeInterface = { + $id: string; + nested: { + $id: string; + nestedValue: string; + }; + }; + + type PrototypeUpdateInterface = { + $id: string; + nested?: { + $id: string; + nestedValue?: string; + }; + }; + + const PrototypeSchema: Schema = { + "@type": [], + nested: { + "@id": x.nested, + "@context": { + "@type": [x.Nested], + nestedValue: { + "@id": x.nestedValue, + "@type": xsd.string, + }, + }, + }, + }; + + type I = SchemaInterface; + type U = SchemaUpdateInterface; + + assertTypeSafe>(); + assertTypeSafe>(); + + assertEquals(expandSchema(Prototype), PrototypeSchema); +}); + Deno.test("Schema / should have at least one property or @type restriction", () => { assertThrows(() => { expandSchema(undefined as unknown as SchemaPrototype); diff --git a/tests/test_deps.ts b/tests/test_deps.ts index a686ae2..15bb484 100644 --- a/tests/test_deps.ts +++ b/tests/test_deps.ts @@ -5,8 +5,8 @@ export { assertStrictEquals, assertThrows, equal, -} from "https://deno.land/std@0.179.0/testing/asserts.ts"; +} from "$std/assert/mod.ts"; -export { assert as assertTypeSafe } from "npm:tsafe@1.4.3"; +export { assert as assertTypeSafe, type Equals } from "npm:tsafe@1.4.3"; export { QueryEngine as Comunica } from "npm:@comunica/query-sparql-rdfjs@2.5.2";