diff --git a/CHANGELOG b/CHANGELOG index 93be07c5a..719b26eb6 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -25,6 +25,7 @@ This project _loosely_ adheres to [Semantic Versioning](https://semver.org/spec/ - Fix error after spawning AI task [PR#559](https://github.com/coasys/ad4m/pull/559) - Fix some problems with perspective.removeLinks() with a proper implementation [PR#563](https://github.com/coasys/ad4m/pull/563) - Ad4mConnect detects running local ADAM agent on first open - no need to click try again [](https://github.com/coasys/ad4m/pull/570) +- Fix multiple synchronous addSignalHandler calls losing the first handler - fixing usage of NH.sendSignal() [PR#576](https://github.com/coasys/ad4m/pull/576) ### Added - Prolog predicates needed in new Flux mention notification trigger: @@ -52,8 +53,9 @@ This project _loosely_ adheres to [Semantic Versioning](https://semver.org/spec/ - Models can also be added straight from a local file [PR#565](https://github.com/coasys/ad4m/pull/565) - Enable fine-tuning of voice detection parameters for transcription streams [PR#566](https://github.com/coasys/ad4m/pull/566) - Neighbourhood p2p signal: optional loopback sending signals to all connected apps/UIs of the sending agent [PR#568](https://github.com/coasys/ad4m/pull/568) -- DB and Perspective to JSON exports and imports [PR#569](https://github.com/coasys/ad4m/pull/569) - +- DB and Perspective to JSON exports and imports [PR#569](https://github.com/coasys/ad4m/pull/569) +- Enhanced SubjectEntity query mechanism with where-queries, that compiles complex queries down to Prolog which can be subscribed to [PR#575](https://github.com/coasys/ad4m/pull/575) + ### Changed - Partially migrated the Runtime service to Rust. (DM language installation for agents is pending.) [PR#466](https://github.com/coasys/ad4m/pull/466) - Improved performance of SDNA / SubjectClass functions by moving code from client into executor and saving a lot of client <-> executor roundtrips [PR#480](https://github.com/coasys/ad4m/pull/480) diff --git a/core/src/Literal.ts b/core/src/Literal.ts index 113316c6e..c597a22b4 100644 --- a/core/src/Literal.ts +++ b/core/src/Literal.ts @@ -27,7 +27,7 @@ export class Literal { toUrl(): string { if(this.#url && !this.#literal) return this.#url - if(!this.#url && !this.#literal) + if(!this.#url && (this.#literal === undefined || this.#literal === "" || this.#literal === null)) throw new Error("Can't turn empty Literal into URL") let encoded @@ -38,6 +38,9 @@ export class Literal { case 'number': encoded = `number:${encodeRFC3986URIComponent(this.#literal)}` break; + case 'boolean': + encoded = `boolean:${encodeRFC3986URIComponent(this.#literal)}` + break; case 'object': encoded = `json:${encodeRFC3986URIComponent(JSON.stringify(this.#literal))}` break; diff --git a/core/src/perspectives/PerspectiveClient.ts b/core/src/perspectives/PerspectiveClient.ts index 423d9247d..50f3ec65e 100644 --- a/core/src/perspectives/PerspectiveClient.ts +++ b/core/src/perspectives/PerspectiveClient.ts @@ -10,6 +10,7 @@ import { Perspective } from "./Perspective"; import { PerspectiveHandle, PerspectiveState } from "./PerspectiveHandle"; import { LinkStatus, PerspectiveProxy } from './PerspectiveProxy'; import { AIClient } from "../ai/AIClient"; +import { AllInstancesResult } from "../subject/SubjectEntity"; const LINK_EXPRESSION_FIELDS = ` author @@ -154,7 +155,7 @@ export class PerspectiveClient { return JSON.parse(perspectiveQueryProlog) } - async subscribeQuery(uuid: string, query: string): Promise<{ subscriptionId: string, result: string }> { + async subscribeQuery(uuid: string, query: string): Promise<{ subscriptionId: string, result: AllInstancesResult }> { const { perspectiveSubscribeQuery } = unwrapApolloResult(await this.#apolloClient.mutate({ mutation: gql`mutation perspectiveSubscribeQuery($uuid: String!, $query: String!) { perspectiveSubscribeQuery(uuid: $uuid, query: $query) { @@ -164,11 +165,17 @@ export class PerspectiveClient { }`, variables: { uuid, query } })) - - return perspectiveSubscribeQuery + const { subscriptionId, result } = perspectiveSubscribeQuery + let finalResult = result; + try { + finalResult = JSON.parse(result) + } catch (e) { + console.error('Error parsing perspectiveSubscribeQuery result:', e) + } + return { subscriptionId, result: finalResult } } - subscribeToQueryUpdates(subscriptionId: string, onData: (result: string) => void): () => void { + subscribeToQueryUpdates(subscriptionId: string, onData: (result: AllInstancesResult) => void): () => void { const subscription = this.#apolloClient.subscribe({ query: gql` subscription perspectiveQuerySubscription($subscriptionId: String!) { @@ -181,7 +188,13 @@ export class PerspectiveClient { }).subscribe({ next: (result) => { if (result.data && result.data.perspectiveQuerySubscription) { - onData(result.data.perspectiveQuerySubscription); + let finalResult = result.data.perspectiveQuerySubscription; + try { + finalResult = JSON.parse(result.data.perspectiveQuerySubscription) + } catch (e) { + console.error('Error parsing perspectiveQuerySubscription:', e) + } + onData(finalResult); } }, error: (e) => console.error('Error in query subscription:', e) diff --git a/core/src/perspectives/PerspectiveProxy.ts b/core/src/perspectives/PerspectiveProxy.ts index 67be58016..3a6cf3cef 100644 --- a/core/src/perspectives/PerspectiveProxy.ts +++ b/core/src/perspectives/PerspectiveProxy.ts @@ -12,8 +12,9 @@ import { NeighbourhoodExpression } from "../neighbourhood/Neighbourhood"; import { AIClient } from "../ai/AIClient"; import { PERSPECTIVE_QUERY_SUBSCRIPTION } from "./PerspectiveResolver"; import { gql } from "@apollo/client/core"; +import { AllInstancesResult } from "../subject/SubjectEntity"; -type QueryCallback = (result: string) => void; +type QueryCallback = (result: AllInstancesResult) => void; // Generic subscription interface that matches Apollo's Subscription interface Unsubscribable { @@ -53,7 +54,7 @@ export class QuerySubscriptionProxy { #callbacks: Set; #keepaliveTimer: number; #unsubscribe?: () => void; - #latestResult: string; + #latestResult: AllInstancesResult; #disposed: boolean = false; /** Creates a new query subscription @@ -62,7 +63,7 @@ export class QuerySubscriptionProxy { * @param initialResult - The initial query result * @param client - The PerspectiveClient instance to use for communication */ - constructor(uuid: string, subscriptionId: string, initialResult: string, client: PerspectiveClient) { + constructor(uuid: string, subscriptionId: string, initialResult: AllInstancesResult, client: PerspectiveClient) { this.#uuid = uuid; this.#subscriptionId = subscriptionId; this.#client = client; @@ -109,7 +110,7 @@ export class QuerySubscriptionProxy { * * @returns The latest query result as a string (usually a JSON array of bindings) */ - get result(): string { + get result(): AllInstancesResult { return this.#latestResult; } @@ -138,7 +139,7 @@ export class QuerySubscriptionProxy { } /** Internal method to notify all callbacks of a new result */ - #notifyCallbacks(result: string) { + #notifyCallbacks(result: AllInstancesResult) { for (const callback of this.#callbacks) { try { callback(result); @@ -450,7 +451,7 @@ export class PerspectiveProxy { } } - async stringOrTemplateObjectToSubjectClass(subjectClass: T): Promise { + async stringOrTemplateObjectToSubjectClassName(subjectClass: T): Promise { if(typeof subjectClass === "string") return subjectClass else { @@ -505,7 +506,7 @@ export class PerspectiveProxy { * @param exprAddr The address of the expression to be turned into a subject instance */ async removeSubject(subjectClass: T, exprAddr: string) { - let className = await this.stringOrTemplateObjectToSubjectClass(subjectClass) + let className = await this.stringOrTemplateObjectToSubjectClassName(subjectClass) let result = await this.infer(`subject_class("${className}", C), destructor(C, Actions)`) if(!result.length) { throw "No constructor found for given subject class: " + className @@ -522,7 +523,7 @@ export class PerspectiveProxy { * that matches the given properties will be used. */ async isSubjectInstance(expression: string, subjectClass: T): Promise { - let className = await this.stringOrTemplateObjectToSubjectClass(subjectClass) + let className = await this.stringOrTemplateObjectToSubjectClassName(subjectClass) let isInstance = false; const maxAttempts = 5; let attempts = 0; @@ -549,7 +550,7 @@ export class PerspectiveProxy { if(!await this.isSubjectInstance(base, subjectClass)) { throw `Expression ${base} is not a subject instance of given class: ${JSON.stringify(subjectClass)}` } - let className = await this.stringOrTemplateObjectToSubjectClass(subjectClass) + let className = await this.stringOrTemplateObjectToSubjectClassName(subjectClass) let subject = new Subject(this, base, className) await subject.init() return subject as unknown as T diff --git a/core/src/subject/Subject.ts b/core/src/subject/Subject.ts index abeed8ed0..3c4ffe7d6 100644 --- a/core/src/subject/Subject.ts +++ b/core/src/subject/Subject.ts @@ -7,18 +7,18 @@ import { collectionSetterToName, collectionToAdderName, collectionToRemoverName, */ export class Subject { #baseExpression: string; - #subjectClass: string; + #subjectClassName: string; #perspective: PerspectiveProxy /** * Constructs a new subject. * @param perspective - The perspective that the subject belongs to. * @param baseExpression - The base expression of the subject. - * @param subjectClass - The class of the subject. + * @param subjectClassName - The class name of the subject. */ - constructor(perspective: PerspectiveProxy, baseExpression: string, subjectClass: string) { + constructor(perspective: PerspectiveProxy, baseExpression: string, subjectClassName: string) { this.#baseExpression = baseExpression - this.#subjectClass = subjectClass + this.#subjectClassName = subjectClassName this.#perspective = perspective } @@ -36,21 +36,21 @@ export class Subject { */ async init() { // Check if the subject is a valid instance of the subject class - let isInstance = await this.#perspective.isSubjectInstance(this.#baseExpression, this.#subjectClass) + let isInstance = await this.#perspective.isSubjectInstance(this.#baseExpression, this.#subjectClassName) if(!isInstance) { - throw `Not a valid subject instance of ${this.#subjectClass} for ${this.#baseExpression}` + throw `Not a valid subject instance of ${this.#subjectClassName} for ${this.#baseExpression}` } // Define properties and collections dynamically - let results = await this.#perspective.infer(`subject_class("${this.#subjectClass}", C), property(C, Property)`) + let results = await this.#perspective.infer(`subject_class("${this.#subjectClassName}", C), property(C, Property)`) let properties = results.map(result => result.Property) for(let p of properties) { - const resolveExpressionURI = await this.#perspective.infer(`subject_class("${this.#subjectClass}", C), property_resolve(C, "${p}")`) + const resolveExpressionURI = await this.#perspective.infer(`subject_class("${this.#subjectClassName}", C), property_resolve(C, "${p}")`) Object.defineProperty(this, p, { configurable: true, get: async () => { - let results = await this.#perspective.infer(`subject_class("${this.#subjectClass}", C), property_getter(C, "${this.#baseExpression}", "${p}", Value)`) + let results = await this.#perspective.infer(`subject_class("${this.#subjectClassName}", C), property_getter(C, "${this.#baseExpression}", "${p}", Value)`) if(results && results.length > 0) { let expressionURI = results[0].Value if(resolveExpressionURI) { @@ -81,13 +81,13 @@ export class Subject { } // Define setters - const setters = await this.#perspective.infer(`subject_class("${this.#subjectClass}", C), property_setter(C, Property, Setter)`) + const setters = await this.#perspective.infer(`subject_class("${this.#subjectClassName}", C), property_setter(C, Property, Setter)`) for(let setter of (setters ? setters : [])) { if(setter) { const property = setter.Property const actions = eval(setter.Setter) - const resolveLanguageResults = await this.#perspective.infer(`subject_class("${this.#subjectClass}", C), property_resolve_language(C, "${property}", Language)`) + const resolveLanguageResults = await this.#perspective.infer(`subject_class("${this.#subjectClassName}", C), property_resolve_language(C, "${property}", Language)`) let resolveLanguage if(resolveLanguageResults && resolveLanguageResults.length > 0) { resolveLanguage = resolveLanguageResults[0].Language @@ -102,7 +102,7 @@ export class Subject { } // Define collections - let results2 = await this.#perspective.infer(`subject_class("${this.#subjectClass}", C), collection(C, Collection)`) + let results2 = await this.#perspective.infer(`subject_class("${this.#subjectClassName}", C), collection(C, Collection)`) if(!results2) results2 = [] let collections = results2.map(result => result.Collection) @@ -110,7 +110,7 @@ export class Subject { Object.defineProperty(this, c, { configurable: true, get: async () => { - let results = await this.#perspective.infer(`subject_class("${this.#subjectClass}", C), collection_getter(C, "${this.#baseExpression}", "${c}", Value)`) + let results = await this.#perspective.infer(`subject_class("${this.#subjectClassName}", C), collection_getter(C, "${this.#baseExpression}", "${c}", Value)`) if(results && results.length > 0 && results[0].Value) { let collectionContent = results[0].Value.filter((v: any) => v !== "" && v !== '') return collectionContent @@ -122,7 +122,7 @@ export class Subject { } // Define collection adders - let adders = await this.#perspective.infer(`subject_class("${this.#subjectClass}", C), collection_adder(C, Collection, Adder)`) + let adders = await this.#perspective.infer(`subject_class("${this.#subjectClassName}", C), collection_adder(C, Collection, Adder)`) if(!adders) adders = [] for(let adder of adders) { @@ -140,7 +140,7 @@ export class Subject { } // Define collection removers - let removers = await this.#perspective.infer(`subject_class("${this.#subjectClass}", C), collection_remover(C, Collection, Remover)`) + let removers = await this.#perspective.infer(`subject_class("${this.#subjectClassName}", C), collection_remover(C, Collection, Remover)`) if(!removers) removers = [] for(let remover of removers) { @@ -158,7 +158,7 @@ export class Subject { } // Define collection setters - let collectionSetters = await this.#perspective.infer(`subject_class("${this.#subjectClass}", C), collection_setter(C, Collection, Setter)`) + let collectionSetters = await this.#perspective.infer(`subject_class("${this.#subjectClassName}", C), collection_setter(C, Collection, Setter)`) if(!collectionSetters) collectionSetters = [] for(let collectionSetter of collectionSetters) { diff --git a/core/src/subject/SubjectEntity.ts b/core/src/subject/SubjectEntity.ts index 55942c2fe..bca73783a 100644 --- a/core/src/subject/SubjectEntity.ts +++ b/core/src/subject/SubjectEntity.ts @@ -4,10 +4,165 @@ import { PerspectiveProxy } from "../perspectives/PerspectiveProxy"; import { makeRandomPrologAtom } from "./SDNADecorators"; import { singularToPlural } from "./util"; -export type QueryPartialEntity = { - [P in keyof T]?: T[P] | (() => string); +type ValueTuple = [name: string, value: any, resolve?: boolean]; +type WhereOps = { + not: string | number | boolean | string[] | number[]; + between: [number, number]; + lt: number; // less than + lte: number; // less than or equal to + gt: number; // greater than + gte: number; // greater than or equal to }; +type WhereCondition = string | number | boolean | string[] | number[] | { [K in keyof WhereOps]?: WhereOps[K] }; +type Where = { [propertyName: string]: WhereCondition }; +type Order = { [propertyName: string]: "ASC" | "DESC" }; +export type Query = { + source?: string; + properties?: string[]; + collections?: string[]; + where?: Where; + order?: Order; + offset?: number; + limit?: number; +}; + +export type AllInstancesResult = { AllInstances: SubjectEntity[] }; + +function capitalize(word: string): string { + return word.charAt(0).toUpperCase() + word.slice(1); +} + +function buildSourceQuery(source?: string): string { + // Constrains the query to instances that have the provided source + if (!source) return ""; + return `triple("${source}", "ad4m://has_child", Base)`; +} + +// todo: only return Timestamp & Author from query (Base, AllLinks, and SortLinks not required) +function buildAuthorAndTimestampQuery(): string { + // Gets the author and timestamp of a SubjectEntity instance (based on the first link mentioning the base) + return ` + findall( + [T, A], + link(Base, _, _, T, A), + AllLinks + ), + sort(AllLinks, SortedLinks), + SortedLinks = [[Timestamp, Author]|_] + `; +} + +function buildPropertiesQuery(properties?: string[]): string { + // Gets the name, value, and resolve boolean for all (or some) properties on a SubjectEntity instance + // Resolves literals (if property_resolve/2 is true) to their value - either the data field if it is + // an Expression in JSON literal, or the direct literal value if it is a simple literal + // If no properties are provided, all are included + return ` + findall([PropertyName, PropertyValue, Resolve], ( + % Constrain to specified properties if provided + ${properties ? `member(PropertyName, [${properties.map((name) => `"${name}"`).join(", ")}]),` : ""} + resolve_property(SubjectClass, Base, PropertyName, PropertyValue, Resolve) + ), Properties) + `; +} + +function buildCollectionsQuery(collections?: string[]): string { + // Gets the name and array of values for all (or some) collections on a SubjectEntity instance + // If no collections are provided, all are included + return ` + findall([CollectionName, CollectionValues], ( + % Constrain to specified collections if provided + ${collections ? `member(CollectionName, [${collections.map((name) => `"${name}"`).join(", ")}]),` : ""} + + collection(SubjectClass, CollectionName), + collection_getter(SubjectClass, Base, CollectionName, CollectionValues) + ), Collections) + `; +} + +function buildWhereQuery(where: Where = {}): string { + // Constrains the query to instances that match the provided where conditions + + function formatValue(value) { + // Wrap strings in quotes + return typeof value === "string" ? `"${value}"` : value; + } + + return (Object.entries(where) as [string, WhereCondition][]) + .map(([key, value]) => { + const isSpecial = ["author", "timestamp"].includes(key); + const getter = `resolve_property(SubjectClass, Base, "${key}", Value${key}, _)`; + // const getter = `property_getter(SubjectClass, Base, "${key}", URI), literal_from_url(URI, V, _)`; + const field = capitalize(key); + + // Handle direct array values (for IN conditions) + if (Array.isArray(value)) { + const formattedValues = value.map((v) => formatValue(v)).join(", "); + if (isSpecial) return `member(${field}, [${formattedValues}])`; + else return `${getter}, member(Value${key}, [${formattedValues}])`; + } + + // Handle operation object + if (typeof value === "object" && value !== null) { + const { not, between, lt, lte, gt, gte } = value; + + // Handle NOT operation + if (not !== undefined) { + if (Array.isArray(not)) { + // NOT IN array + const formattedValues = not.map((v) => formatValue(v)).join(", "); + if (isSpecial) return `\\+ member(${field}, [${formattedValues}])`; + else return `${getter}, \\+ member(Value${key}, [${formattedValues}])`; + } else { + // NOT EQUAL + if (isSpecial) return `${field} \\= ${formatValue(not)}`; + else return `\\+ (${getter}, Value${key} = ${formatValue(not)})`; + } + } + + // Handle BETWEEN + if (between !== undefined && Array.isArray(between) && between.length === 2) { + if (isSpecial) return `${field} >= ${between[0]}, ${field} =< ${between[1]}`; + else return `${getter}, Value${key} >= ${between[0]}, Value${key} =< ${between[1]}`; + } + + // Handle lt, lte, gt, & gte operations + const operators = [ + { value: lt, symbol: "<" }, // LESS THAN + { value: lte, symbol: "=<" }, // LESS THAN OR EQUAL TO + { value: gt, symbol: ">" }, // GREATER THAN + { value: gte, symbol: ">=" }, // GREATER THAN OR EQUAL TO + ]; + + for (const { value, symbol } of operators) { + if (value !== undefined) + return isSpecial ? `${field} ${symbol} ${value}` : `${getter}, Value${key} ${symbol} ${value}`; + } + } + + // Default to direct equality + if (isSpecial) return `${field} = ${formatValue(value)}`; + else return `${getter}, Value${key} = ${formatValue(value)}`; + }) + .join(", "); +} + +function buildOrderQuery(order?: Order): string { + if (!order) return "SortedInstances = UnsortedInstances"; + const [propertyName, direction] = Object.entries(order)[0]; + return `sort_instances(UnsortedInstances, "${propertyName}", "${direction}", SortedInstances)`; +} + +function buildOffsetQuery(offset?: number): string { + if (!offset || offset < 0) return "InstancesWithOffset = SortedInstances"; + return `skipN(SortedInstances, ${offset}, InstancesWithOffset)`; +} + +function buildLimitQuery(limit?: number): string { + if (!limit || limit < 0) return "AllInstances = InstancesWithOffset"; + return `takeN(InstancesWithOffset, ${limit}, AllInstances)`; +} /** * Class representing a subject entity. @@ -15,19 +170,37 @@ export type QueryPartialEntity = { */ export class SubjectEntity { #baseExpression: string; - #subjectClass: string; + #subjectClassName: string; #source: string; - #perspective: PerspectiveProxy + #perspective: PerspectiveProxy; author: string; timestamp: string; + private static classNamesByClass = new WeakMap(); - /** - * Constructs a new subject. - * @param perspective - The perspective that the subject belongs to. - * @param baseExpression - The base expression of the subject. - * @param soruce - The source of the subject, the expression this instance is linked too. - */ + static async getClassName(perspective: PerspectiveProxy) { + // Get or create the cache for this class + let classCache = this.classNamesByClass.get(this); + if (!classCache) { + classCache = {}; + this.classNamesByClass.set(this, classCache); + } + + // Get or create the cached name for this perspective + const perspectiveID = perspective.uuid; + if (!classCache[perspectiveID]) { + classCache[perspectiveID] = await perspective.stringOrTemplateObjectToSubjectClassName(this); + } + + return classCache[perspectiveID]; + } + + /** + * Constructs a new subject. + * @param perspective - The perspective that the subject belongs to. + * @param baseExpression - The base expression of the subject. + * @param source - The source of the subject, the expression this instance is linked too. + */ constructor(perspective: PerspectiveProxy, baseExpression?: string, source?: string) { this.#baseExpression = baseExpression ? baseExpression : Literal.from(makeRandomPrologAtom(24)).toUrl(); this.#perspective = perspective; @@ -38,7 +211,7 @@ export class SubjectEntity { * Gets the base expression of the subject. */ get baseExpression() { - return this.#baseExpression + return this.#baseExpression; } /** @@ -49,75 +222,208 @@ export class SubjectEntity { return this.#perspective; } - private async getData(id?: string) { - const tempId = id ?? this.#baseExpression; - let data = await this.#perspective.getSubjectData(this.#subjectClass, tempId) - Object.assign(this, data); - this.#baseExpression = tempId; - return this + public static async assignValuesToInstance( + perspective: PerspectiveProxy, + instance: SubjectEntity, + values: ValueTuple[] + ) { + // Map properties to object + const propsObject = Object.fromEntries( + await Promise.all( + values.map(async ([name, value, resolve]) => { + let finalValue = value; + // Resolve the value if necessary + if (resolve) { + let resolvedExpression = await perspective.getExpression(value); + if (resolvedExpression) { + finalValue = resolvedExpression.data; + } + } + return [name, finalValue]; + }) + ) + ); + // Assign properties to instance + Object.assign(instance, propsObject); + } + + private async getData() { + // Builds an object with the author, timestamp, all properties, & all collections on the SubjectEntity and saves it to the instance + const subQueries = [buildAuthorAndTimestampQuery(), buildPropertiesQuery(), buildCollectionsQuery()]; + const fullQuery = ` + Base = "${this.#baseExpression}", + subject_class("${this.#subjectClassName}", SubjectClass), + ${subQueries.join(", ")} + `; + + const result = await this.#perspective.infer(fullQuery); + if (result?.[0]) { + const { Properties, Collections, Timestamp, Author } = result?.[0]; + const values = [...Properties, ...Collections, ["timestamp", Timestamp], ["author", Author]]; + await SubjectEntity.assignValuesToInstance(this.#perspective, this, values); + } + + return this; + } + + // Todo: Only return AllInstances (InstancesWithOffset, SortedInstances, & UnsortedInstances not required) + public static async queryToProlog(perspective: PerspectiveProxy, query: Query) { + const { source, properties, collections, where, order, offset, limit } = query; + + const instanceQueries = [ + buildAuthorAndTimestampQuery(), + buildSourceQuery(source), + buildPropertiesQuery(properties), + buildCollectionsQuery(collections), + buildWhereQuery(where), + ]; + + const resultSetQueries = [buildOrderQuery(order), buildOffsetQuery(offset), buildLimitQuery(limit)]; + + const fullQuery = ` + findall([Base, Properties, Collections, Timestamp, Author], ( + subject_class("${await this.getClassName(perspective)}", SubjectClass), + instance(SubjectClass, Base), + ${instanceQueries.filter((q) => q).join(", ")} + ), UnsortedInstances), + ${resultSetQueries.filter((q) => q).join(", ")} + `; + + return fullQuery; + } + + public static async instancesFromPrologResult( + perspective: PerspectiveProxy, + query: Query, + result: AllInstancesResult + ) { + if (!result?.[0]?.AllInstances) return []; + // Map results to instances + const requestedAttribtes = [...(query?.properties || []), ...(query?.collections || [])]; + const allInstances = await Promise.all( + result[0].AllInstances.map(async ([Base, Properties, Collections, Timestamp, Author]) => { + try { + const instance = new this(perspective, Base) as any; + // Remove unrequested attributes from instance + if (requestedAttribtes.length) { + Object.keys(instance).forEach((key) => { + if (!requestedAttribtes.includes(key)) delete instance[key]; + }); + } + // Collect values to assign to instance + const values = [...Properties, ...Collections, ["timestamp", Timestamp], ["author", Author]]; + await SubjectEntity.assignValuesToInstance(perspective, instance, values); + + return instance; + } catch (error) { + console.error(`Failed to process instance ${Base}:`, error); + // Return null for failed instances - we'll filter these out below + return null; + } + }) + ); + return allInstances.filter((instance) => instance !== null); + } + + /** + * Gets all instances of the subject entity in the perspective that match the query params . + * @param perspective - The perspective that the subject entities belongs to. + * @param query - The query object used to define search contraints. + * + * @returns An array of all subject entity matches. + * + */ + static async findAll(perspective: PerspectiveProxy, query: Query = {}) { + // todo: set up includes + const prologQuery = await this.queryToProlog(perspective, query); + const result = await perspective.infer(prologQuery); + const allInstances = await this.instancesFromPrologResult(perspective, query, result); + return allInstances; } private async setProperty(key: string, value: any) { - const setters = await this.#perspective.infer(`subject_class("${this.#subjectClass}", C), property_setter(C, "${key}", Setter)`) + const setters = await this.#perspective.infer( + `subject_class("${this.#subjectClassName}", C), property_setter(C, "${key}", Setter)` + ); if (setters && setters.length > 0) { - const actions = eval(setters[0].Setter) - const resolveLanguageResults = await this.#perspective.infer(`subject_class("${this.#subjectClass}", C), property_resolve_language(C, "${key}", Language)`) - let resolveLanguage + const actions = eval(setters[0].Setter); + const resolveLanguageResults = await this.#perspective.infer( + `subject_class("${this.#subjectClassName}", C), property_resolve_language(C, "${key}", Language)` + ); + let resolveLanguage; if (resolveLanguageResults && resolveLanguageResults.length > 0) { - resolveLanguage = resolveLanguageResults[0].Language + resolveLanguage = resolveLanguageResults[0].Language; } if (resolveLanguage) { - value = await this.#perspective.createExpression(value, resolveLanguage) + value = await this.#perspective.createExpression(value, resolveLanguage); } - await this.#perspective.executeAction(actions, this.#baseExpression, [{ name: "value", value }]) + await this.#perspective.executeAction(actions, this.#baseExpression, [{ name: "value", value }]); } } private async setCollectionSetter(key: string, value: any) { - let collectionSetters = await this.#perspective.infer(`subject_class("${this.#subjectClass}", C), collection_setter(C, "${singularToPlural(key)}", Setter)`) - if (!collectionSetters) collectionSetters = [] + let collectionSetters = await this.#perspective.infer( + `subject_class("${this.#subjectClassName}", C), collection_setter(C, "${singularToPlural(key)}", Setter)` + ); + if (!collectionSetters) collectionSetters = []; if (collectionSetters.length > 0) { - const actions = eval(collectionSetters[0].Setter) + const actions = eval(collectionSetters[0].Setter); if (value) { if (Array.isArray(value)) { - await this.#perspective.executeAction(actions, this.#baseExpression, value.map(v => ({ name: "value", value: v }))) + await this.#perspective.executeAction( + actions, + this.#baseExpression, + value.map((v) => ({ name: "value", value: v })) + ); } else { - await this.#perspective.executeAction(actions, this.#baseExpression, [{ name: "value", value }]) + await this.#perspective.executeAction(actions, this.#baseExpression, [{ name: "value", value }]); } } } } private async setCollectionAdder(key: string, value: any) { - let adders = await this.#perspective.infer(`subject_class("${this.#subjectClass}", C), collection_adder(C, "${singularToPlural(key)}", Adder)`) - if (!adders) adders = [] + let adders = await this.#perspective.infer( + `subject_class("${this.#subjectClassName}", C), collection_adder(C, "${singularToPlural(key)}", Adder)` + ); + if (!adders) adders = []; if (adders.length > 0) { - const actions = eval(adders[0].Adder) + const actions = eval(adders[0].Adder); if (value) { if (Array.isArray(value)) { - await Promise.all(value.map(v => this.#perspective.executeAction(actions, this.#baseExpression, [{ name: "value", value: v }]))) + await Promise.all( + value.map((v) => + this.#perspective.executeAction(actions, this.#baseExpression, [{ name: "value", value: v }]) + ) + ); } else { - await this.#perspective.executeAction(actions, this.#baseExpression, [{ name: "value", value }]) + await this.#perspective.executeAction(actions, this.#baseExpression, [{ name: "value", value }]); } } } } private async setCollectionRemover(key: string, value: any) { - let removers = await this.#perspective.infer(`subject_class("${this.#subjectClass}", C), collection_remover(C, "${singularToPlural(key)}", Remover)`) - if (!removers) removers = [] + let removers = await this.#perspective.infer( + `subject_class("${this.#subjectClassName}", C), collection_remover(C, "${singularToPlural(key)}", Remover)` + ); + if (!removers) removers = []; if (removers.length > 0) { - const actions = eval(removers[0].Remover) + const actions = eval(removers[0].Remover); if (value) { if (Array.isArray(value)) { - await Promise.all(value.map(v => this.#perspective.executeAction(actions, this.#baseExpression, [{ name: "value", value: v }]))) + await Promise.all( + value.map((v) => + this.#perspective.executeAction(actions, this.#baseExpression, [{ name: "value", value: v }]) + ) + ); } else { - await this.#perspective.executeAction(actions, this.#baseExpression, [{ name: "value", value }]) + await this.#perspective.executeAction(actions, this.#baseExpression, [{ name: "value", value }]); } } } @@ -126,64 +432,57 @@ export class SubjectEntity { /** * Save the subject entity. * This method will create a new subject with the base expression and add a new link from the source to the base expression with the predicate "ad4m://has_child". - * + * * If a property has an action, it will perform the action (Only for collections). * If a property is an array and is not empty, it will set the collection. * If a property is not undefined, not null, and not an empty string, it will set the property. - * - * + * + * * @throws Will throw an error if the subject entity cannot be converted to a subject class, or if the subject cannot be created, or if the link cannot be added, or if the subject entity cannot be updated. */ async save() { - this.#subjectClass = await this.#perspective.stringOrTemplateObjectToSubjectClass(this) - await this.#perspective.createSubject(this, this.#baseExpression); await this.#perspective.add( - new Link({ - source: this.#source, - predicate: "ad4m://has_child", - target: this.baseExpression, - }) + new Link({ source: this.#source, predicate: "ad4m://has_child", target: this.baseExpression }) ); - await this.update() + await this.update(); } /** * Update the subject entity. - * + * * It will iterate over the properties of the subject entity. - * + * * If a property has an action, it will perform the action (Only for collections). * If a property is an array and is not empty, it will set the collection. * If a property is not undefined, not null, and not an empty string, it will set the property. - * + * * @throws Will throw an error if the subject entity cannot be converted to a subject class, or if a property cannot be set, or if a collection cannot be set, or if the data of the subject entity cannot be gotten. */ async update() { - this.#subjectClass = await this.#perspective.stringOrTemplateObjectToSubjectClass(this) + this.#subjectClassName = await this.#perspective.stringOrTemplateObjectToSubjectClassName(this); const entries = Object.entries(this); - for (const [key, value] of entries) { if (value !== undefined && value !== null) { if (value?.action) { switch (value.action) { - case 'setter': - await this.setCollectionSetter(key, value.value) + case "setter": + await this.setCollectionSetter(key, value.value); break; case "adder": - await this.setCollectionAdder(key, value.value) + await this.setCollectionAdder(key, value.value); break; - case 'remover': - await this.setCollectionRemover(key, value.value) + case "remover": + await this.setCollectionRemover(key, value.value); default: - await this.setCollectionSetter(key, value.value) + await this.setCollectionSetter(key, value.value); break; } } else if (Array.isArray(value) && value.length > 0) { - await this.setCollectionSetter(key, value) + await this.setCollectionSetter(key, value); } else if (value !== undefined && value !== null && value !== "") { await this.setProperty(key, value); } @@ -195,142 +494,106 @@ export class SubjectEntity { /** * Get the subject entity with all the properties & collection populated. - * + * * @returns The subject entity. - * + * * @throws Will throw an error if the subject entity cannot be converted to a subject class, or if the data of the subject entity cannot be gotten. */ async get() { - this.#subjectClass = await this.#perspective.stringOrTemplateObjectToSubjectClass(this) + this.#subjectClassName = await this.#perspective.stringOrTemplateObjectToSubjectClassName(this); - return await this.getData() + return await this.getData(); } - /** * Delete the subject entity. * This method will remove the subject from the perspective. - * + * * @throws Will throw an error if the subject entity cannot be removed. */ async delete() { await this.#perspective.removeSubject(this, this.#baseExpression); } - /** - * Get all the subject entities of the subject class. - * - * NOTE: this is a static method and should be called on the class itself. - * - * @param perspective - The perspective that the subject belongs to. - * - * @returns The subject entities. - * - * @throws Will throw an error if the subject entity cannot be converted to a subject class, or if the subject proxies cannot be gotten. + /** Query builder for SubjectEntity queries. + * Allows building queries with a fluent interface and either running them once + * or subscribing to updates. */ - static async all(perspective: PerspectiveProxy) { - let subjectClass = await perspective.stringOrTemplateObjectToSubjectClass(this) - const proxies = await perspective.getAllSubjectProxies(subjectClass) - - const instances = [] - - if (proxies) { - for (const proxy of proxies) { - // @ts-ignore - const instance = new this(perspective, proxy.X) - instances.push(await instance.get()) - } - - return instances; - } + static query(perspective: PerspectiveProxy, query?: Query): SubjectQueryBuilder { + return new SubjectQueryBuilder(perspective, this as any, query); + } +} - return [] +/** Query builder for SubjectEntity queries. + * Allows building queries with a fluent interface and either running them once + * or subscribing to updates. + */ +export class SubjectQueryBuilder { + private perspective: PerspectiveProxy; + private queryParams: Query = {}; + private ctor: typeof SubjectEntity; + + constructor(perspective: PerspectiveProxy, ctor: typeof SubjectEntity, query?: Query) { + this.perspective = perspective; + this.ctor = ctor; + if (query) this.queryParams = query; } - /** - * Query the subject entities of the subject class. - * - * NOTE: this is a static method and should be called on the class itself. - * - * @param perspective - The perspective that the subject belongs to. - * @param query - The query of the subject entities. - * - * @returns The subject entities. - * - * @throws Will throw an error if the subject entity cannot be converted to a subject class, or if the query cannot be inferred, or if the data of the subject entities cannot be gotten. - */ - static async query(perspective: PerspectiveProxy, query?: SubjectEntityQueryParam) { - const source = query?.source || "ad4m://self"; - let subjectClass = await perspective.stringOrTemplateObjectToSubjectClass(this) + where(conditions: Where): SubjectQueryBuilder { + this.queryParams.where = conditions; + return this; + } - let res = []; - let instanceConditions = `subject_class("${subjectClass}", C), instance(C, Base), link("${source}", Predicate, Base, Timestamp, Author)` + order(orderBy: Order): SubjectQueryBuilder { + this.queryParams.order = orderBy; + return this; + } - if (query) { - if(query.where) { - if(query.where["condition"]) { - instanceConditions += ", " + query.where["condition"] - } else { - const pairs = Object.entries(query.where); - for(let p of pairs) { - const propertyName = p[0]; - const propertyValue = p[1]; - instanceConditions += `, property_getter(C, Base, "${propertyName}", "${propertyValue}")` - } - } - } + limit(limit: number): SubjectQueryBuilder { + this.queryParams.limit = limit; + return this; + } - try { - const queryResponse = (await perspective.infer(`findall([Timestamp, Base], (${instanceConditions}), AllData), sort(AllData, SortedData), length(SortedData, DataLength).`))[0] + offset(offset: number): SubjectQueryBuilder { + this.queryParams.offset = offset; + return this; + } - if (queryResponse.DataLength >= query.size) { - const mainQuery = `findall([Timestamp, Base], (${instanceConditions}), AllData), sort(AllData, SortedData), reverse(SortedData, ReverseSortedData), paginate(ReverseSortedData, ${query.page}, ${query.size}, PageData).` + source(source: string): SubjectQueryBuilder { + this.queryParams.source = source; + return this; + } - res = await perspective.infer(mainQuery); + properties(properties: string[]): SubjectQueryBuilder { + this.queryParams.properties = properties; + return this; + } - res = res[0].PageData.map(r => ({ - Base: r[1], - Timestamp: r[0] - })) - } else { - res = await perspective.infer(instanceConditions); - } - } catch (e) { - console.log("Query failed", e); - } - } else { - res = await perspective.infer(instanceConditions); - } + collections(collections: string[]): SubjectQueryBuilder { + this.queryParams.collections = collections; + return this; + } - if (!res) return []; + /** Execute the query once and return the results */ + async run(): Promise { + const query = await this.ctor.queryToProlog(this.perspective, this.queryParams); + const result = await this.perspective.infer(query); + const allInstances = await this.ctor.instancesFromPrologResult(this.perspective, this.queryParams, result); + return allInstances as T[]; + } - const data = await Promise.all( - res.map(async (result) => { - const instance = new this(perspective, result.Base) + /** Subscribe to the query and receive updates when results change */ + async subscribeAndRun(callback: (results: T[]) => void): Promise { + const query = await this.ctor.queryToProlog(this.perspective, this.queryParams); + const subscription = await this.perspective.subscribeInfer(query); - return await instance.get(); - }) - ); + const processResults = async (result: AllInstancesResult) => { + let newInstances = await this.ctor.instancesFromPrologResult(this.perspective, this.queryParams, result); + callback(newInstances as T[]); + }; - return data; + subscription.onResult(processResults); + let instances = await this.ctor.instancesFromPrologResult(this.perspective, this.queryParams, subscription.result); + return instances as T[]; } } - -export type SubjectArray = T[] | { - action: 'setter' | 'adder' | 'remover', - value: T[] -} - -export type SubjectEntityQueryParam = { - // The source of the query. - source?: string; - - // The size of the query. - size?: number; - - // The page of the query. - page?: number; - - // conditions on properties - where?: { condition?: string } | object; -} \ No newline at end of file diff --git a/rust-executor/src/perspectives/sdna.rs b/rust-executor/src/perspectives/sdna.rs index 81c5dfba0..c188f782e 100644 --- a/rust-executor/src/perspectives/sdna.rs +++ b/rust-executor/src/perspectives/sdna.rs @@ -210,6 +210,71 @@ takeN(Rest, NextN, PageRest). lines.extend(lib.split('\n').map(|s| s.to_string())); + let sorting_predicates = r#" +:- discontiguous(decorate/4). +decorate(List, SortKey, Direction, Decorated) :- + maplist(decorate_item(SortKey), List, Decorated), + (Direction == "ASC" ; Direction == "DESC"). + +:- discontiguous(decorate_item/2). +decorate_item("timestamp", [Base, Properties, Collections, Timestamp, Author], [Timestamp, Base, Properties, Collections, Timestamp, Author]) :- !. +decorate_item(PropertyKey, [Base, Properties, Collections, Timestamp, Author], [KeyValue, Base, Properties, Collections, Timestamp, Author]) :- + member([PropertyKey, KeyValue, _], Properties), + !. +decorate_item(_, Item, [0|Item]). % Default to 0 if key not found + +:- discontiguous(undecorate/2). +undecorate(Decorated, Undecorated) :- + maplist(arg(2), Decorated, Undecorated). + +:- discontiguous(merge_sort/3). +merge_sort([], [], _). +merge_sort([X], [X], _). +merge_sort(List, Sorted, Direction) :- + length(List, Len), + Len > 1, + split(List, Left, Right), + merge_sort(Left, SortedLeft, Direction), + merge_sort(Right, SortedRight, Direction), + merge(SortedLeft, SortedRight, Sorted, Direction). + +:- discontiguous(split/3). +split(List, Left, Right) :- + length(List, Len), + Half is Len // 2, + length(Left, Half), + append(Left, Right, List). + +:- discontiguous(merge/4). +merge([], Right, Right, _). +merge(Left, [], Left, _). +merge([X|Xs], [Y|Ys], [X|Merged], "ASC") :- + X = [KeyX|_], Y = [KeyY|_], + KeyX =< KeyY, + merge(Xs, [Y|Ys], Merged, "ASC"). +merge([X|Xs], [Y|Ys], [Y|Merged], "ASC") :- + X = [KeyX|_], Y = [KeyY|_], + KeyX > KeyY, + merge([X|Xs], Ys, Merged, "ASC"). +merge([X|Xs], [Y|Ys], [X|Merged], "DESC") :- + X = [KeyX|_], Y = [KeyY|_], + KeyX >= KeyY, + merge(Xs, [Y|Ys], Merged, "DESC"). +merge([X|Xs], [Y|Ys], [Y|Merged], "DESC") :- + X = [KeyX|_], Y = [KeyY|_], + KeyX < KeyY, + merge([X|Xs], Ys, Merged, "DESC"). + +% New sort_instances predicate +:- discontiguous(sort_instances/4). +sort_instances(UnsortedInstances, SortKey, Direction, SortedInstances) :- + decorate(UnsortedInstances, SortKey, Direction, Decorated), + merge_sort(Decorated, SortedDecorated, Direction), + undecorate(SortedDecorated, SortedInstances). +"#; + + lines.extend(sorting_predicates.split('\n').map(|s| s.to_string())); + let literal_html_string_predicates = r#" % Main predicate to remove HTML tags remove_html_tags(Input, Output) :- @@ -242,7 +307,19 @@ string([C|Cs]) --> [C], string(Cs). literal_from_url(Url, Decoded, Scheme) :- phrase(parse_url(Scheme, Encoded), Url), - phrase(url_decode(Decoded), Encoded). + phrase(url_decode(StringValue), Encoded), + ( Scheme = number + -> number_chars(Decoded, StringValue) + ; ( Scheme = boolean + -> ( StringValue = "true" + -> Decoded = true + ; StringValue = "false" + -> Decoded = false + ; Decoded = false % Default for invalid boolean + ) + ; Decoded = StringValue % For all other schemes + ) + ). % DCG rule to parse the URL parse_url(Scheme, Encoded) --> @@ -250,6 +327,7 @@ parse_url(Scheme, Encoded) --> scheme(string) --> "string". scheme(number) --> "number". +scheme(boolean) --> "boolean". scheme(json) --> "json". url_decode([]) --> []. @@ -316,6 +394,9 @@ url_decode_char(Char) --> [Char], { \+ member(Char, "%") }. json_value(Value) --> json_array(Value). json_value(Value) --> json_string(Value). json_value(Value) --> json_number(Value). + json_value(true) --> "true". + json_value(false) --> "false". + json_value(null) --> "null". json_array([Value|Values]) --> "[", ws, json_value(Value), ws, ("," -> json_value_list(Values) ; {Values=[]}), ws, "]". @@ -339,8 +420,7 @@ url_decode_char(Char) --> [Char], { \+ member(Char, "%") }. json_number(Number) --> number_sequence(Chars), - { atom_chars(Atom, Chars), - atom_number(Atom, Number) }. + { number_chars(Number, Chars) }. string_chars([]) --> []. string_chars([C|Cs]) --> [C], { dif(C, '"') }, string_chars(Cs). @@ -360,6 +440,47 @@ url_decode_char(Char) --> [Char], { \+ member(Char, "%") }. lines.extend(json_parser.split('\n').map(|s| s.to_string())); + let resolve_property = r#" + % Retrieve a property from a subject class + % If the property is resolvable, this will try to do the resolution here + % which works if it is a literal, otherwise it will pass through the URI to JS + % to be resolved later + % Resolve is a boolean that tells JS whether to resolve the property or not + % If it is false, then the property value is a literal and we did resolve it here + % If it is true, then the property value is a URI and it still needs to be resolved + resolve_property(SubjectClass, Base, PropertyName, PropertyValue, Resolve) :- + % Get the property name and resolve boolean + property(SubjectClass, PropertyName), + property_getter(SubjectClass, Base, PropertyName, PropertyUri), + ( property_resolve(SubjectClass, PropertyName) + % If the property is resolvable, try to resolve it + -> ( + append("literal://", _, PropertyUri) + % If the property is a literal, we can resolve it here + -> ( + % so tell JS to not resolve it + Resolve = false, + literal_from_url(PropertyUri, LiteralValue, Scheme), + ( + json_property(LiteralValue, "data", Data) + % If it is a JSON literal, and it has a 'data' field, use that + -> PropertyValue = Data + % Otherwise, just use the literal value + ; PropertyValue = LiteralValue + ) + ) + ; + % else (it should be resolved but is not a literal), + % pass through URI to JS and tell JS to resolve it + (Resolve = true, PropertyValue = PropertyUri) + ) + ; + % else (no property resolve), just return the URI as the value + (Resolve = false, PropertyValue = PropertyUri) + )."#; + + lines.extend(resolve_property.split('\n').map(|s| s.to_string())); + let assert_link = r#" assert_link(Source, Predicate, Target, Timestamp, Author) :- \+ link(Source, Predicate, Target, Timestamp, Author), diff --git a/tests/js/tests/perspective.ts b/tests/js/tests/perspective.ts index bc21213c8..31c55f513 100644 --- a/tests/js/tests/perspective.ts +++ b/tests/js/tests/perspective.ts @@ -426,13 +426,13 @@ export default function perspectiveTests(testContext: TestContext) { const subscription = await (proxy as any).subscribeInfer('triple(X, _, "todo-ontology://is-todo").') // Check initial result - const initialResult = JSON.parse(subscription.result) + const initialResult = subscription.result expect(initialResult).to.be.an('array') expect(initialResult.length).to.equal(1) expect(initialResult[0].X).to.equal('note-ipfs://Qm123') // Set up callback for updates - const updates: string[] = [] + const updates: any[] = [] const unsubscribe = subscription.onResult((result: any) => { updates.push(result) }) @@ -448,7 +448,7 @@ export default function perspectiveTests(testContext: TestContext) { // Verify we got an update expect(updates.length).to.be.greaterThan(0) - const latestResult = JSON.parse(updates[updates.length - 1]) + const latestResult = updates[updates.length - 1] expect(latestResult).to.be.an('array') expect(latestResult.length).to.equal(2) expect(latestResult.map((r: any) => r.X)).to.include('note-ipfs://Qm123') diff --git a/tests/js/tests/prolog-and-literals.test.ts b/tests/js/tests/prolog-and-literals.test.ts index 86091a915..7e48912aa 100644 --- a/tests/js/tests/prolog-and-literals.test.ts +++ b/tests/js/tests/prolog-and-literals.test.ts @@ -556,10 +556,18 @@ describe("Prolog + Literals", () => { }) type: string = "" + //@ts-ignore + @SubjectProperty({ + through: "recipe://plain", + writable: true, + }) + plain: string = "" + //@ts-ignore @SubjectProperty({ through: "recipe://name", writable: true, + resolveLanguage: "literal" }) name: string = "" @@ -567,9 +575,17 @@ describe("Prolog + Literals", () => { @SubjectProperty({ through: "recipe://boolean", writable: true, + resolveLanguage: "literal" }) booleanTest: boolean = false + @SubjectProperty({ + through: "recipe://number", + writable: true, + resolveLanguage: "literal" + }) + number: number = 0 + //@ts-ignore @SubjectCollection({ through: "recipe://entries" }) entries: string[] = [] @@ -602,6 +618,9 @@ describe("Prolog + Literals", () => { }) resolve: string = "" + // static query(perspective: PerspectiveProxy) { + // return SubjectEntity.query(perspective); + // } } before(async () => { @@ -612,9 +631,10 @@ describe("Prolog + Literals", () => { it("save() & get()", async () => { let root = Literal.from("Active record implementation test").toUrl() - const recipe = new Recipe(perspective!, root) - recipe.name = "recipe://test"; + const recipe = new Recipe(perspective!, root) + recipe.name = "Save and get test"; + recipe.plain = "recipe://test"; recipe.booleanTest = false; await recipe.save(); @@ -623,15 +643,17 @@ describe("Prolog + Literals", () => { await recipe2.get(); - expect(recipe2.name).to.equal("recipe://test") + expect(recipe2.name).to.equal("Save and get test") + expect(recipe2.plain).to.equal("recipe://test") expect(recipe2.booleanTest).to.equal(false) }) it("update()", async () => { let root = Literal.from("Active record implementation test").toUrl() - const recipe = new Recipe(perspective!, root) - recipe.name = "recipe://test1"; + const recipe = new Recipe(perspective!, root) + recipe.name = "Update test"; + recipe.plain = "recipe://update_test"; await recipe.update(); @@ -639,11 +661,12 @@ describe("Prolog + Literals", () => { await recipe2.get(); - expect(recipe2.name).to.equal("recipe://test1") + expect(recipe2.name).to.equal("Update test") + expect(recipe2.plain).to.equal("recipe://update_test") }) it("find()", async () => { - const recipes = await Recipe.all(perspective!); + const recipes = await Recipe.findAll(perspective!); expect(recipes.length).to.equal(1) }) @@ -652,7 +675,7 @@ describe("Prolog + Literals", () => { let root = Literal.from("Active record implementation collection test").toUrl() const recipe = new Recipe(perspective!, root) - recipe.name = "recipe://collection_test"; + recipe.name = "Collection test"; recipe.comments = ['recipe://test', 'recipe://test1'] @@ -661,7 +684,6 @@ describe("Prolog + Literals", () => { const recipe2 = new Recipe(perspective!, root); await recipe2.get(); - console.log("comments:", recipe2.comments) expect(recipe2.comments.length).to.equal(2) }) @@ -670,7 +692,7 @@ describe("Prolog + Literals", () => { let root = Literal.from("Active record implementation test local link").toUrl() const recipe = new Recipe(perspective!, root) - recipe.name = "recipe://locallink"; + recipe.name = "Local test"; recipe.local = 'recipe://test' await recipe.save(); @@ -679,7 +701,7 @@ describe("Prolog + Literals", () => { await recipe2.get(); - expect(recipe2.name).to.equal("recipe://locallink") + expect(recipe2.name).to.equal("Local test") expect(recipe2.local).to.equal("recipe://test") // @ts-ignore @@ -692,69 +714,40 @@ describe("Prolog + Literals", () => { expect(links![0].status).to.equal('LOCAL') }) - it("query()", async () => { - let recipes = await Recipe.query(perspective!, { page: 1, size: 2 }); - expect(recipes.length).to.equal(2) - - recipes = await Recipe.query(perspective!, { page: 2, size: 1 }); - expect(recipes.length).to.equal(1) - - const testName = "recipe://where_test" - - recipes = await Recipe.query(perspective!, { where: { name: testName }}) - expect(recipes.length).to.equal(0) - - let root = Literal.from("Where test").toUrl() - const whereTestRecipe = new Recipe(perspective!, root) - whereTestRecipe.name = testName - await whereTestRecipe.save() - - recipes = await Recipe.query(perspective!, { where: { name: testName }}) - expect(recipes.length).to.equal(1) - - recipes = await Recipe.query(perspective!, { where: { condition: `triple(Base, _, "ad4m://test_self")` }}) - expect(recipes.length).to.equal(0) - - await perspective?.add({source: root, target: "ad4m://test_self"}) - recipes = await Recipe.query(perspective!, { where: { condition: `triple(Base, _, "ad4m://test_self")` }}) - expect(recipes.length).to.equal(1) - - }) - it("delete()", async () => { - const recipe2 = await Recipe.all(perspective!); + const recipes = await Recipe.findAll(perspective!); - expect(recipe2.length).to.equal(4) + expect(recipes.length).to.equal(3) - await recipe2[0].delete(); + await recipes[0].delete(); - const recipe3 = await Recipe.all(perspective!); + const updatedRecipies = await Recipe.findAll(perspective!); - expect(recipe3.length).to.equal(3) + expect(updatedRecipies.length).to.equal(2) }) it("can constrain collection entries through 'where' clause with prolog condition", async () => { - let root = Literal.from("Active record implementation collection test with where").toUrl() - const recipe = new Recipe(perspective!, root) + let root = Literal.from("Active record implementation collection test with where").toUrl(); + const recipe = new Recipe(perspective!, root); - let recipeEntries = Literal.from("test recipes").toUrl() + let recipeEntries = Literal.from("test recipes").toUrl(); - recipe.entries = [recipeEntries] + recipe.entries = [recipeEntries]; // @ts-ignore - recipe.comments = ['recipe://test', 'recipe://test1'] - recipe.name = "recipe://collection_test"; + recipe.comments = ['recipe://test', 'recipe://test1']; + recipe.name = "Collection test"; - await recipe.save() + await recipe.save(); - await perspective?.add(new Link({source: recipeEntries, predicate: "recipe://has_ingredient", target: "recipe://test"})) + await perspective?.add(new Link({source: recipeEntries, predicate: "recipe://has_ingredient", target: "recipe://test"})); - await recipe.get() + await recipe.get(); const recipe2 = new Recipe(perspective!, root); await recipe2.get(); - expect(recipe2.ingredients.length).to.equal(1) + expect(recipe2.ingredients.length).to.equal(1); }) it("can implement the resolveLanguage property type", async () => { @@ -764,13 +757,16 @@ describe("Prolog + Literals", () => { recipe.resolve = "Test name literal"; await recipe.save(); - await recipe.get(); //@ts-ignore let links = await perspective!.get(new LinkQuery({source: root, predicate: "recipe://resolve"})) expect(links.length).to.equal(1) let literal = Literal.fromUrl(links[0].data.target).get() expect(literal.data).to.equal(recipe.resolve) + + const recipe3 = new Recipe(perspective!, root); + await recipe3.get(); + expect(recipe3.resolve).to.equal("Test name literal"); }) it("works with very long property values", async() => { @@ -778,12 +774,12 @@ describe("Prolog + Literals", () => { const recipe = new Recipe(perspective!, root) const longName = "This is a very long recipe name that goes on and on with many many characters to test that we can handle long property values without any issues whatsoever and keep going even longer to make absolutely sure we hit at least 300 characters in this test string that just keeps getting longer and longer until we are completely satisfied that it works properly with such lengthy content. But wait, there's more! We need to make this string even longer to properly test the system's ability to handle extremely long property values. Let's add some more meaningful content about recipes - ingredients like flour, sugar, eggs, milk, butter, vanilla extract, baking powder, salt, and detailed instructions for mixing them together in just the right way to create the perfect baked goods. We could go on about preheating the oven to the right temperature, greasing the pans properly, checking for doneness with a toothpick, and letting things cool completely before frosting. The possibilities are endless when it comes to recipe details and instructions that could make this string longer and longer. We want to be absolutely certain that our system can handle property values of any reasonable length without truncating or corrupting the data in any way. This is especially important for recipes where precise instructions and ingredient amounts can make the difference between success and failure in the kitchen. Testing with realistically long content helps ensure our system works reliably in real-world usage scenarios where users might enter detailed information that extends well beyond a few simple sentences." - recipe.name = longName + recipe.plain = longName recipe.resolve = longName await recipe.save() - let linksName = await perspective!.get(new LinkQuery({source: root, predicate: "recipe://name"})) + let linksName = await perspective!.get(new LinkQuery({source: root, predicate: "recipe://plain"})) expect(linksName.length).to.equal(1) expect(linksName[0].data.target).to.equal(longName) @@ -795,8 +791,8 @@ describe("Prolog + Literals", () => { const recipe2 = new Recipe(perspective!, root) await recipe2.get() - expect(recipe2.name.length).to.equal(longName.length) - expect(recipe2.name).to.equal(longName) + expect(recipe2.plain.length).to.equal(longName.length) + expect(recipe2.plain).to.equal(longName) expect(recipe2.resolve.length).to.equal(longName.length) expect(recipe2.resolve).to.equal(longName) @@ -806,7 +802,7 @@ describe("Prolog + Literals", () => { let root = Literal.from("Author and timestamp test").toUrl() const recipe = new Recipe(perspective!, root) - recipe.name = "recipe://test"; + recipe.name = "author and timestamp test"; await recipe.save(); const recipe2 = new Recipe(perspective!, root); @@ -818,6 +814,1055 @@ describe("Prolog + Literals", () => { // @ts-ignore expect(recipe2.timestamp).to.not.be.undefined; }) + + it("get() returns all subject entity properties (via getData())", async () => { + let root = Literal.from("getData test").toUrl() + const recipe = new Recipe(perspective!, root) + + recipe.name = "getData all test"; + recipe.booleanTest = true; + recipe.comments = ['recipe://comment1', 'recipe://comment2']; + recipe.local = "recipe://local_test"; + recipe.resolve = "Resolved literal value"; + + await recipe.save(); + + const data = await recipe.get(); + + expect(data.name).to.equal("getData all test"); + expect(data.booleanTest).to.equal(true); + expect(data.comments).to.deep.equal(['recipe://comment1', 'recipe://comment2']); + expect(data.local).to.equal("recipe://local_test"); + expect(data.resolve).to.equal("Resolved literal value"); + + await recipe.delete(); + }) + + it("findAll() returns properties on instances", async () => { + // Clear all previous recipes + const allRecipes = await Recipe.findAll(perspective!); + for (const recipe of allRecipes) await recipe.delete(); + + let root1 = Literal.from("findAll test 1").toUrl() + let root2 = Literal.from("findAll test 2").toUrl() + + const recipe1 = new Recipe(perspective!, root1) + recipe1.name = "findAll test 1"; + recipe1.resolve = "Resolved literal value 1"; + recipe1.plain = "recipe://findAll_test1"; + await recipe1.save(); + + const recipe2 = new Recipe(perspective!, root2) + recipe2.name = "findAll test 2"; + recipe2.resolve = "Resolved literal value 2"; + recipe2.plain = "recipe://findAll_test2"; + await recipe2.save(); + + // Test findAll + const recipes = await Recipe.findAll(perspective!); + + expect(recipes.length).to.equal(2); + expect(recipes[0].name).to.equal("findAll test 1"); + expect(recipes[0].resolve).to.equal("Resolved literal value 1"); + expect(recipes[1].name).to.equal("findAll test 2"); + expect(recipes[1].resolve).to.equal("Resolved literal value 2"); + expect(recipes[0].plain).to.equal("recipe://findAll_test1"); + expect(recipes[1].plain).to.equal("recipe://findAll_test2"); + + await recipe1.delete(); + await recipe2.delete(); + }) + + it("findAll() returns collections on instances", async () => { + // Clear all previous recipes + const allRecipes = await Recipe.findAll(perspective!); + for (const recipe of allRecipes) await recipe.delete(); + + let root1 = Literal.from("findAll test 1").toUrl() + let root2 = Literal.from("findAll test 2").toUrl() + + const recipe1 = new Recipe(perspective!, root1) + recipe1.comments = ["Recipe 1: Comment 1", "Recipe 1: Comment 2"]; + await recipe1.save(); + + const recipe2 = new Recipe(perspective!, root2) + recipe2.comments = ["Recipe 2: Comment 1", "Recipe 2: Comment 2"]; + await recipe2.save(); + + // Test findAll + const recipes = await Recipe.findAll(perspective!); + + expect(recipes.length).to.equal(2); + expect(recipes[0].comments.length).to.equal(2); + expect(recipes[0].comments[0]).to.equal("Recipe 1: Comment 1"); + expect(recipes[0].comments[1]).to.equal("Recipe 1: Comment 2"); + + expect(recipes[1].comments.length).to.equal(2); + expect(recipes[1].comments[0]).to.equal("Recipe 2: Comment 1"); + expect(recipes[1].comments[1]).to.equal("Recipe 2: Comment 2"); + + await recipe1.delete(); + await recipe2.delete(); + }) + + it("findAll() returns author & timestamp on instances", async () => { + // Clear all previous recipes + const allRecipes = await Recipe.findAll(perspective!); + for (const recipe of allRecipes) await recipe.delete(); + + let root1 = Literal.from("findAll test 1").toUrl() + let root2 = Literal.from("findAll test 2").toUrl() + + const recipe1 = new Recipe(perspective!, root1); + recipe1.name = "findAll test 1"; + await recipe1.save(); + + const recipe2 = new Recipe(perspective!, root2); + recipe2.name = "findAll test 2"; + await recipe2.save(); + + const recipes = await Recipe.findAll(perspective!); + + const me = await ad4m!.agent.me(); + expect(recipes[0].author).to.equal(me!.did) + expect(recipes[0].timestamp).to.not.be.undefined; + expect(recipes[1].author).to.equal(me!.did) + expect(recipes[1].timestamp).to.not.be.undefined; + + await recipe1.delete(); + await recipe2.delete(); + }) + + it("findAll() works with source prop", async () => { + // Clear all previous recipes + const oldRecipes = await Recipe.findAll(perspective!); + for (const recipe of oldRecipes) await recipe.delete(); + + const source1 = Literal.from("Source 1").toUrl() + const source2 = Literal.from("Source 2").toUrl() + + const recipe1 = new Recipe(perspective!, undefined, source1) + recipe1.name = "Recipe 1: Name"; + await recipe1.save(); + + const recipe2 = new Recipe(perspective!, undefined, source2) + recipe2.name = "Recipe 2: Name"; + await recipe2.save(); + + const recipe3 = new Recipe(perspective!, undefined, source2) + recipe3.name = "Recipe 3: Name"; + await recipe3.save(); + + const allRecipes = await Recipe.findAll(perspective!); + expect(allRecipes.length).to.equal(3); + + const source1Recipes = await Recipe.findAll(perspective!, { source: source1 }); + expect(source1Recipes.length).to.equal(1); + expect(source1Recipes[0].name).to.equal("Recipe 1: Name"); + + const source2Recipes = await Recipe.findAll(perspective!, { source: source2 }); + expect(source2Recipes.length).to.equal(2); + + await recipe1.delete(); + await recipe2.delete(); + await recipe3.delete(); + }) + + it("findAll() works with properties query", async () => { + // Clear all previous recipes + const allRecipes = await Recipe.findAll(perspective!); + for (const recipe of allRecipes) await recipe.delete(); + + let root = Literal.from("findAll test 1").toUrl() + const recipe = new Recipe(perspective!, root); + recipe.name = "recipe://test_name"; + recipe.booleanTest = true; + await recipe.save(); + + const me = await ad4m!.agent.me(); + + // Test recipes with all properties + const recipesWithAllAttributes = await Recipe.findAll(perspective!); + expect(recipesWithAllAttributes[0].name).to.equal("recipe://test_name") + expect(recipesWithAllAttributes[0].booleanTest).to.equal(true) + expect(recipesWithAllAttributes[0].author).to.equal(me!.did) + + // Test recipes with name only + const recipesWithNameOnly = await Recipe.findAll(perspective!, { properties: ["name"] }); + expect(recipesWithNameOnly[0].name).to.equal("recipe://test_name") + expect(recipesWithNameOnly[0].booleanTest).to.be.undefined + + // Test recipes with name and booleanTest only + const recipesWithTypeAndBooleanTestOnly = await Recipe.findAll(perspective!, { properties: ["name", "booleanTest"] }); + expect(recipesWithTypeAndBooleanTestOnly[0].name).to.equal("recipe://test_name") + expect(recipesWithTypeAndBooleanTestOnly[0].booleanTest).to.equal(true) + + // Test recipes with author only + const recipesWithAuthorOnly = await Recipe.findAll(perspective!, { properties: ["author"] }); + expect(recipesWithAuthorOnly[0].name).to.be.undefined + expect(recipesWithAuthorOnly[0].booleanTest).to.be.undefined + expect(recipesWithAuthorOnly[0].author).to.equal(me!.did) + + await recipe.delete(); + }) + + it("findAll() works with collections query", async () => { + // Clear all previous recipes + const allRecipes = await Recipe.findAll(perspective!); + for (const recipe of allRecipes) await recipe.delete(); + + let root = Literal.from("findAll test 1").toUrl() + const recipe = new Recipe(perspective!, root); + recipe.comments = ["Recipe 1: Comment 1", "Recipe 1: Comment 2"]; + recipe.entries = ["Recipe 1: Entry 1", "Recipe 1: Entry 2"]; + await recipe.save(); + + // Test recipes with all collections + const recipesWithAllCollections = await Recipe.findAll(perspective!); + expect(recipesWithAllCollections[0].comments.length).to.equal(2) + expect(recipesWithAllCollections[0].entries.length).to.equal(2) + + // Test recipes with comments only + const recipesWithCommentsOnly = await Recipe.findAll(perspective!, { collections: ["comments"] }); + expect(recipesWithCommentsOnly[0].comments.length).to.equal(2) + expect(recipesWithCommentsOnly[0].entries).to.be.undefined + + // Test recipes with entries only + const recipesWithEntriesOnly = await Recipe.findAll(perspective!, { collections: ["entries"] }); + expect(recipesWithEntriesOnly[0].comments).to.be.undefined + expect(recipesWithEntriesOnly[0].entries.length).to.equal(2) + + await recipe.delete(); + }) + + it("findAll() works with basic where queries", async () => { + // Clear previous recipes + const oldRecipes = await Recipe.findAll(perspective!); + for (const recipe of oldRecipes) await recipe.delete(); + + // Create recipies + const recipe1 = new Recipe(perspective!); + recipe1.name = "Recipe 1"; + recipe1.number = 5; + recipe1.booleanTest = true; + await recipe1.save(); + + const recipe2 = new Recipe(perspective!); + recipe2.name = "Recipe 2"; + recipe2.number = 10; + recipe2.booleanTest = true; + await recipe2.save(); + + const recipe3 = new Recipe(perspective!); + recipe3.name = "Recipe 3"; + recipe3.number = 15; + recipe3.booleanTest = false; + await recipe3.save(); + + // Check all recipes are there + const allRecipes = await Recipe.findAll(perspective!); + expect(allRecipes.length).to.equal(3) + + // Test where with valid name + const recipes1 = await Recipe.findAll(perspective!, { where: { name: "Recipe 1" } }); + expect(recipes1.length).to.equal(1); + + // Test where with invalid name + const recipes2 = await Recipe.findAll(perspective!, { where: { name: "This name doesn't exist" } }); + expect(recipes2.length).to.equal(0); + + // Test where with boolean + const recipes3 = await Recipe.findAll(perspective!, { where: { booleanTest: true } }); + expect(recipes3.length).to.equal(2); + + // Test where with number + const recipes4 = await Recipe.findAll(perspective!, { where: { number: 5 } }); + expect(recipes4.length).to.equal(1); + + // Test where with an array of possible matches + const recipes5 = await Recipe.findAll(perspective!, { where: { name: ["Recipe 1", "Recipe 2"] } }); + expect(recipes5.length).to.equal(2); + + // Test where with author + const me = await ad4m!.agent.me(); + // Test where with valid author + const recipes6 = await Recipe.findAll(perspective!, { where: { author: me.did } }); + expect(recipes6.length).to.equal(3); + // Test where with invalid author + const recipes7 = await Recipe.findAll(perspective!, { where: { author: "This author doesn't exist" } }); + expect(recipes7.length).to.equal(0); + + // Test where with timestamp + const validTimestamp1 = allRecipes[0].timestamp; + const validTimestamp2 = allRecipes[1].timestamp; + const invalidTimestamp = new Date().getTime(); + // Test where with valid timestamp + const recipes8 = await Recipe.findAll(perspective!, { where: { timestamp: validTimestamp1 } }); + expect(recipes8.length).to.equal(1); + // Test where with invalid timestamp + const recipes9 = await Recipe.findAll(perspective!, { where: { timestamp: invalidTimestamp } }); + expect(recipes9.length).to.equal(0); + // Test where with an array of possible timestamp matches + const recipes10 = await Recipe.findAll(perspective!, { where: { timestamp: [validTimestamp1, validTimestamp2] } }); + expect(recipes10.length).to.equal(2); + + await recipe1.delete(); + await recipe2.delete(); + await recipe3.delete(); + }) + + it("findAll() works with where query not operations", async () => { + // Clear previous recipes + const oldRecipes = await Recipe.findAll(perspective!); + for (const recipe of oldRecipes) await recipe.delete(); + + // Create recipies + const recipe1 = new Recipe(perspective!); + recipe1.name = "Recipe 1"; + recipe1.number = 5; + await recipe1.save(); + + const recipe2 = new Recipe(perspective!); + recipe2.name = "Recipe 2"; + recipe2.number = 10; + await recipe2.save(); + + const recipe3 = new Recipe(perspective!); + recipe3.name = "Recipe 3"; + recipe3.number = 15; + await recipe3.save(); + + // Check all recipes are there + const allRecipes = await Recipe.findAll(perspective!); + expect(allRecipes.length).to.equal(3); + + // Store valid timestamps + const validTimestamp1 = allRecipes[0].timestamp; + const validTimestamp2 = allRecipes[1].timestamp; + const validTimestamp3 = allRecipes[2].timestamp; + + // Test not operation on standard property + const recipes1 = await Recipe.findAll(perspective!, { where: { name: { not: "Recipe 1" } } }); + expect(recipes1.length).to.equal(2); + + // Test not operation on author + const me = await ad4m!.agent.me(); + const recipes2 = await Recipe.findAll(perspective!, { where: { author: { not: me.did } } }); + expect(recipes2.length).to.equal(0); + + // Test not operation on timestamp + const recipes3 = await Recipe.findAll(perspective!, { where: { timestamp: { not: validTimestamp1 } } }); + expect(recipes3.length).to.equal(2); + + // Test not operation with an array of possible string matches + const recipes4 = await Recipe.findAll(perspective!, { where: { name: { not: ["Recipe 1", "Recipe 2"] } } }); + expect(recipes4.length).to.equal(1); + expect(recipes4[0].name).to.equal("Recipe 3"); + + // Test not operation with an array of possible timestamp matches + const recipes5 = await Recipe.findAll(perspective!, { where: { timestamp: { not: [validTimestamp1, validTimestamp2] } } }); + expect(recipes5.length).to.equal(1); + expect(recipes5[0].timestamp).to.equal(validTimestamp3); + + await recipe1.delete(); + await recipe2.delete(); + await recipe3.delete(); + }) + + it("findAll() works with where query lt, lte, gt, & gte operations", async () => { + // Clear previous recipes + const oldRecipes = await Recipe.findAll(perspective!); + for (const recipe of oldRecipes) await recipe.delete(); + + // Create recipes + const recipe1 = new Recipe(perspective!); + recipe1.name = "Recipe 1"; + recipe1.number = 5; + await recipe1.save(); + + const recipe2 = new Recipe(perspective!); + recipe2.name = "Recipe 2"; + recipe2.number = 10; + await recipe2.save(); + + const recipe3 = new Recipe(perspective!); + recipe3.name = "Recipe 3"; + recipe3.number = 15; + await recipe3.save(); + + const recipe4 = new Recipe(perspective!); + recipe4.name = "Recipe 4"; + recipe4.number = 20; + await recipe4.save(); + + // Check all recipes are there + const allRecipes = await Recipe.findAll(perspective!); + expect(allRecipes.length).to.equal(4); + + // 1. Number properties + // Test less than (lt) operation on number property + const recipes1 = await Recipe.findAll(perspective!, { where: { number: { lt: 10 } } }); + expect(recipes1.length).to.equal(1); + + // Test less than or equal to (lte) operation on number property + const recipes2 = await Recipe.findAll(perspective!, { where: { number: { lte: 10 } } }); + expect(recipes2.length).to.equal(2); + + // Test greater than (gt) operation on number property + const recipes3 = await Recipe.findAll(perspective!, { where: { number: { gt: 10 } } }); + expect(recipes3.length).to.equal(2); + + // Test greater than or equal to (gte) operation on number property + const recipes4 = await Recipe.findAll(perspective!, { where: { number: { gte: 10 } } }); + expect(recipes4.length).to.equal(3); + + // 2. Timestamps + const recipe2timestamp = allRecipes[1].timestamp; + + // Test less than (lt) operation on timestamp + const recipes5 = await Recipe.findAll(perspective!, { where: { timestamp: { lt: recipe2timestamp } } }); + expect(recipes5.length).to.equal(1); + + // Test less than or equal to (lte) operation on timestamp + const recipes6 = await Recipe.findAll(perspective!, { where: { timestamp: { lte: recipe2timestamp } } }); + expect(recipes6.length).to.equal(2); + + // Test greater than (gt) operation on timestamp + const recipes7 = await Recipe.findAll(perspective!, { where: { timestamp: { gt: recipe2timestamp } } }); + expect(recipes7.length).to.equal(2); + + // Test greater than (gt) operation on timestamp + const recipes8 = await Recipe.findAll(perspective!, { where: { timestamp: { gte: recipe2timestamp } } }); + expect(recipes8.length).to.equal(3); + + await recipe1.delete(); + await recipe2.delete(); + await recipe3.delete(); + await recipe4.delete(); + }) + + it("findAll() works with where query between operations", async () => { + @SDNAClass({ + name: "Task_due" + }) + class TaskDue extends SubjectEntity { + @SubjectProperty({ + through: "task://title", + writable: true, + required: true, + initial: "task://notitle", + resolveLanguage: "literal" + }) + title: string = ""; + + @SubjectProperty({ + through: "task://priority", + writable: true, + resolveLanguage: "literal" + }) + priority: number = 0; + + @SubjectProperty({ + through: "task://dueDate", + writable: true, + resolveLanguage: "literal" + }) + dueDate: number = 0; + } + + // Register the Task class + await perspective!.ensureSDNASubjectClass(TaskDue); + + // Clear any previous tasks + let tasks = await TaskDue.findAll(perspective!); + for (const task of tasks) await task.delete(); + + // Create timestamps & tasks + const start = new Date().getTime(); + + const task1 = new TaskDue(perspective!); + task1.title = "Low priority task"; + task1.priority = 2; + task1.dueDate = start; + await task1.save(); + + await sleep(2000); + + const mid = new Date().getTime(); + + const task2 = new TaskDue(perspective!); + task2.title = "Medium priority task"; + task2.priority = 5; + task2.dueDate = mid + 1; + await task2.save(); + + const task3 = new TaskDue(perspective!); + task3.title = "High priority task"; + task3.priority = 8; + task3.dueDate = mid + 2; + await task3.save(); + + await sleep(2000); + + const end = new Date().getTime(); + + // Check all tasks are there + const allTasks = await TaskDue.findAll(perspective!); + expect(allTasks.length).to.equal(3); + + // Test between operation on priority + const lowToMediumTasks = await TaskDue.findAll(perspective!, { where: { priority: { between: [1, 5] } } }); + expect(lowToMediumTasks.length).to.equal(2); + + // Test between operation on priority with different values + const mediumToHighTasks = await TaskDue.findAll(perspective!, { where: { priority: { between: [5, 10] } } }); + expect(mediumToHighTasks.length).to.equal(2); + + // Test between operation on dueDate + const earlyTasks = await TaskDue.findAll(perspective!, { where: { dueDate: { between: [start, mid] } } }); + expect(earlyTasks.length).to.equal(1); + + // Test between operation on dueDate with different values + const laterTasks = await TaskDue.findAll(perspective!, { where: { dueDate: { between: [mid, end] } } }); + expect(laterTasks.length).to.equal(2); + + // Clean up + await task1.delete(); + await task2.delete(); + await task3.delete(); + }) + + it("findAll() works with ordering", async () => { + // Clear previous recipes + const oldRecipes = await Recipe.findAll(perspective!); + for (const recipe of oldRecipes) await recipe.delete(); + + // Create recipes + const recipe1 = new Recipe(perspective!); + recipe1.name = "Recipe 1"; + recipe1.number = 10; + await recipe1.save(); + + const recipe2 = new Recipe(perspective!); + recipe2.name = "Recipe 2"; + recipe2.number = 5; + await recipe2.save(); + + const recipe3 = new Recipe(perspective!); + recipe3.name = "Recipe 3"; + recipe3.number = 15; + await recipe3.save(); + + // Check all recipes are there + const allRecipes = await Recipe.findAll(perspective!); + expect(allRecipes.length).to.equal(3); + + // Test ordering by number properties + const recipes1 = await Recipe.findAll(perspective!, { order: { number: "ASC" } }); + expect(recipes1[0].number).to.equal(5); + expect(recipes1[1].number).to.equal(10); + expect(recipes1[2].number).to.equal(15); + + const recipes2 = await Recipe.findAll(perspective!, { order: { number: "DESC" } }); + expect(recipes2[0].number).to.equal(15); + expect(recipes2[1].number).to.equal(10); + expect(recipes2[2].number).to.equal(5); + + // Test ordering by timestamp + const recipes3 = await Recipe.findAll(perspective!, { order: { timestamp: "ASC" } }); + expect(recipes3[0].name).to.equal("Recipe 1"); + expect(recipes3[1].name).to.equal("Recipe 2"); + expect(recipes3[2].name).to.equal("Recipe 3"); + + const recipes4 = await Recipe.findAll(perspective!, { order: { timestamp: "DESC" } }); + expect(recipes4[0].name).to.equal("Recipe 3"); + expect(recipes4[1].name).to.equal("Recipe 2"); + expect(recipes4[2].name).to.equal("Recipe 1"); + + await recipe1.delete(); + await recipe2.delete(); + await recipe3.delete(); + }) + + it("findAll() works with limit and offset", async () => { + // Clear previous recipes + const oldRecipes = await Recipe.findAll(perspective!); + for (const recipe of oldRecipes) await recipe.delete(); + + // Create recipes + const recipe1 = new Recipe(perspective!); + recipe1.name = "Recipe 1"; + await recipe1.save(); + + const recipe2 = new Recipe(perspective!); + recipe2.name = "Recipe 2"; + await recipe2.save(); + + const recipe3 = new Recipe(perspective!); + recipe3.name = "Recipe 3"; + await recipe3.save(); + + const recipe4 = new Recipe(perspective!); + recipe4.name = "Recipe 4"; + await recipe4.save(); + + const recipe5 = new Recipe(perspective!); + recipe5.name = "Recipe 5"; + await recipe5.save(); + + const recipe6 = new Recipe(perspective!); + recipe6.name = "Recipe 6"; + await recipe6.save(); + + // Check all recipes are there + const allRecipes = await Recipe.findAll(perspective!); + expect(allRecipes.length).to.equal(6); + + // Test limit + const recipes1 = await Recipe.findAll(perspective!, { limit: 2 }); + expect(recipes1.length).to.equal(2); + + const recipes2 = await Recipe.findAll(perspective!, { limit: 4 }); + expect(recipes2.length).to.equal(4); + + // Test offset + const recipes3 = await Recipe.findAll(perspective!, { offset: 2 }); + expect(recipes3[0].name).to.equal("Recipe 3"); + + const recipes4 = await Recipe.findAll(perspective!, { offset: 4 }); + expect(recipes4[0].name).to.equal("Recipe 5"); + + // Test limit and offset + const recipes5 = await Recipe.findAll(perspective!, { limit: 2, offset: 1 }); + expect(recipes5.length).to.equal(2); + expect(recipes5[0].name).to.equal("Recipe 2"); + + const recipes6 = await Recipe.findAll(perspective!, { limit: 3, offset: 2 }); + expect(recipes6.length).to.equal(3); + expect(recipes6[0].name).to.equal("Recipe 3"); + + + await recipe1.delete(); + await recipe2.delete(); + await recipe3.delete(); + }) + + it("findAll() works with a mix of query constraints", async () => { + // Clear previous recipes + const oldRecipes = await Recipe.findAll(perspective!); + for (const recipe of oldRecipes) await recipe.delete(); + + // Create recipies + const recipe1 = new Recipe(perspective!); + recipe1.name = "Recipe 1"; + recipe1.booleanTest = true; + recipe1.comments = ["Recipe 1: Comment 1", "Recipe 1: Comment 2"]; + recipe1.entries = ["Recipe 1: Entry 1", "Recipe 1: Entry 2"]; + await recipe1.save(); + + const recipe2 = new Recipe(perspective!); + recipe2.name = "Recipe 2"; + recipe2.booleanTest = false; + recipe2.comments = ["Recipe 2: Comment 1", "Recipe 2: Comment 2"]; + recipe2.entries = ["Recipe 2: Entry 1", "Recipe 2: Entry 2"]; + await recipe2.save(); + + // Check all recipes are there + const allRecipes = await Recipe.findAll(perspective!); + expect(allRecipes.length).to.equal(2); + + // Test with where, properties, and collections + const recipes1 = await Recipe.findAll(perspective!, { where: { name: "Recipe 1" }, properties: ["name"], collections: ["comments"] }); + expect(recipes1.length).to.equal(1); + expect(recipes1[0].name).to.equal("Recipe 1"); + expect(recipes1[0].booleanTest).to.be.undefined; + expect(recipes1[0].comments.length).to.equal(2); + expect(recipes1[0].entries).to.be.undefined; + + // Test with different where, properties, and collections + const recipes2 = await Recipe.findAll(perspective!, { where: { name: "Recipe 2" }, properties: ["booleanTest"], collections: ["entries"] }); + expect(recipes2.length).to.equal(1); + expect(recipes2[0].name).to.be.undefined; + expect(recipes2[0].booleanTest).to.equal(false); + expect(recipes2[0].comments).to.be.undefined; + expect(recipes2[0].entries.length).to.equal(2); + + await recipe1.delete(); + await recipe2.delete(); + }) + + it("findAll() works with constraining resolved literal properties", async () => { + // Clear previous recipes + const oldRecipes = await Recipe.findAll(perspective!); + for (const recipe of oldRecipes) await recipe.delete(); + + // Create a recipe with a resolved literal property + const recipe = new Recipe(perspective!); + recipe.resolve = "Hello World" + await recipe.save(); + + // Test with resolved literal property + const recipes1 = await Recipe.findAll(perspective!, { where: { resolve: "Hello World" } }); + expect(recipes1.length).to.equal(1); + expect(recipes1[0].resolve).to.equal("Hello World"); + + await recipe.delete(); + }) + + it("findAll() works with multiple property constraints in one where clause", async () => { + // Clear previous recipes + const oldRecipes = await Recipe.findAll(perspective!); + for (const recipe of oldRecipes) await recipe.delete(); + + // Create recipes with different combinations of properties + const recipe1 = new Recipe(perspective!); + recipe1.name = "Recipe 1"; + recipe1.number = 5; + recipe1.booleanTest = true; + await recipe1.save(); + + const recipe2 = new Recipe(perspective!); + recipe2.name = "Recipe 2"; + recipe2.number = 10; + recipe2.booleanTest = true; + await recipe2.save(); + + const recipe3 = new Recipe(perspective!); + recipe3.name = "Recipe 3"; + recipe3.number = 15; + recipe3.booleanTest = false; + await recipe3.save(); + + // Check all recipes are there + const allRecipes = await Recipe.findAll(perspective!); + expect(allRecipes.length).to.equal(3); + + // Test where with multiple property constraints + const recipes1 = await Recipe.findAll(perspective!, { + where: { + name: "Recipe 1", + number: 5, + booleanTest: true + } + }); + expect(recipes1.length).to.equal(1); + + // Test where with multiple property constraints that match multiple recipes + const recipes2 = await Recipe.findAll(perspective!, { + where: { + number: { gt: 5 }, + booleanTest: true + } + }); + expect(recipes2.length).to.equal(1); + expect(recipes2[0].name).to.equal("Recipe 2"); + + // Test where with multiple property constraints that match no recipes + const recipes3 = await Recipe.findAll(perspective!, { + where: { + name: "Recipe 1", + booleanTest: false + } + }); + expect(recipes3.length).to.equal(0); + + await recipe1.delete(); + await recipe2.delete(); + await recipe3.delete(); + }) + + it("query builder works with subscriptions", async () => { + @SDNAClass({ + name: "Notification" + }) + class Notification extends SubjectEntity { + @SubjectProperty({ + through: "notification://title", + writable: true, + required: true, + initial: "literal://string:notitle", + resolveLanguage: "literal" + }) + title: string = ""; + + @SubjectProperty({ + through: "notification://priority", + writable: true, + resolveLanguage: "literal" + }) + priority: number = 0; + + @SubjectProperty({ + through: "notification://read", + writable: true, + resolveLanguage: "literal" + }) + read: boolean = false; + } + + // Register the Notification class + await perspective!.ensureSDNASubjectClass(Notification); + + // Clear any previous notifications + let notifications = await Notification.findAll(perspective!); + for (const notification of notifications) await notification.delete(); + + // Set up subscription for high-priority unread notifications + let updateCount = 0; + const query = Notification.query(perspective!).where({ + priority: { gt: 5 }, + read: false + }); + const initialResults = await query.subscribeAndRun((newNotifications: SubjectEntity[]) => { + notifications = newNotifications; + updateCount++; + }); + + // Initially no results + expect(initialResults.length).to.equal(0); + expect(updateCount).to.equal(0); + + // Add matching notification - should trigger subscription + const notification1 = new Notification(perspective!); + notification1.title = "High priority notification"; + notification1.priority = 8; + notification1.read = false; + await notification1.save(); + + // Wait for subscription to fire + await sleep(1000); + expect(updateCount).to.equal(1); + expect(notifications.length).to.equal(1); + + // Add another matching notification - should trigger subscription again + const notification2 = new Notification(perspective!); + notification2.title = "Another high priority"; + notification2.priority = 7; + notification2.read = false; + await notification2.save(); + + await sleep(1000); + expect(updateCount).to.equal(2); + expect(notifications.length).to.equal(2); + + // Add non-matching notification (low priority) - should not trigger subscription + const notification3 = new Notification(perspective!); + notification3.title = "Low priority notification"; + notification3.priority = 3; + notification3.read = false; + await notification3.save(); + + await sleep(1000); + expect(updateCount).to.equal(2); + expect(notifications.length).to.equal(2); + + // Mark notification1 as read - should trigger subscription to remove it + notification1.read = true; + await notification1.update(); + await sleep(1000); + expect(notifications.length).to.equal(1); + + // Clean up + await notification1.delete(); + await notification2.delete(); + await notification3.delete(); + }); + + it("query builder should filter by subject class", async () => { + // Define a second subject class + @SDNAClass({ + name: "Note1" + }) + class Note1 extends SubjectEntity { + @SubjectProperty({ + through: "note://name", + writable: true, + required: true, + initial: "note://noname", + resolveLanguage: "literal" + }) + name: string = ""; + + @SubjectProperty({ + through: "note1://content", + writable: true, + required: true, + initial: "note1://nocontent", + resolveLanguage: "literal" + }) + content1: string = ""; + } + + @SDNAClass({ + name: "Note2" + }) + class Note2 extends SubjectEntity { + @SubjectProperty({ + through: "note://name", + writable: true, + required: true, + initial: "note://noname", + resolveLanguage: "literal" + }) + name: string = ""; + + @SubjectProperty({ + through: "note2://content", + writable: true, + required: true, + initial: "note2://nocontent", + resolveLanguage: "literal" + }) + content2: string = ""; + } + + // Register the Note class + await perspective!.ensureSDNASubjectClass(Note1); + await perspective!.ensureSDNASubjectClass(Note2); + + // Create instances of both classes with the same name + const note1 = new Note1(perspective!); + note1.name = "Test Item"; + await note1.save(); + + const note2 = new Note2(perspective!); + note2.name = "Test Item"; + await note2.save(); + + // Query for recipes - this should only return the recipe instance + const note1Query = Note1.query(perspective!).where({ name: "Test Item" }); + const note1Results = await note1Query.run(); + + console.log("note1Results: ", note1Results) + // This assertion will fail because the query builder doesn't filter by class + expect(note1Results.length).to.equal(1); + expect(note1Results[0]).to.be.instanceOf(Note1); + + // Clean up + await note1.delete(); + await note2.delete(); + }); + + it("query builder works with single query object, complex query and subscriptions", async () => { + @SDNAClass({ + name: "Task" + }) + class Task extends SubjectEntity { + @SubjectProperty({ + through: "task://description", + writable: true, + required: true, + initial: "task://nodescription", + resolveLanguage: "literal" + }) + description: string = ""; + + @SubjectProperty({ + through: "task://dueDate", + writable: true, + resolveLanguage: "literal" + }) + dueDate: number = 0; + + @SubjectProperty({ + through: "task://completed", + writable: true, + resolveLanguage: "literal" + }) + completed: boolean = false; + + @SubjectProperty({ + through: "task://assignee", + writable: true, + resolveLanguage: "literal" + }) + assignee: string = ""; + } + + // Register the Task class + await perspective!.ensureSDNASubjectClass(Task); + + // Clear any previous tasks + let tasks = await Task.findAll(perspective!); + for (const task of tasks) await task.delete(); + + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + const tomorrowTimestamp = tomorrow.getTime(); + + const nextWeek = new Date(); + nextWeek.setDate(nextWeek.getDate() + 7); + const nextWeekTimestamp = nextWeek.getTime(); + + // Set up subscription for upcoming incomplete tasks assigned to "alice" + let updateCount = 0; + const initialResults = await (Task.query(perspective!, { + where: { + dueDate: { lte: nextWeekTimestamp }, + completed: false, + assignee: "alice" + } + })).subscribeAndRun((newTasks: SubjectEntity[]) => { + tasks = newTasks; + updateCount++; + }); + + // Initially no results + expect(initialResults.length).to.equal(0); + expect(updateCount).to.equal(0); + + // Add matching task - should trigger subscription + const task1 = new Task(perspective!); + task1.description = "Urgent task for tomorrow"; + task1.dueDate = tomorrowTimestamp; + task1.completed = false; + task1.assignee = "alice"; + await task1.save(); + + await task1.get(); + + // Wait for subscription to fire + await sleep(1000); + + expect(updateCount).to.equal(1); + expect(tasks.length).to.equal(1); + + // Add another matching task - should trigger subscription again + const task2 = new Task(perspective!); + task2.description = "Another task for next week"; + task2.dueDate = nextWeekTimestamp; + task2.completed = false; + task2.assignee = "alice"; + await task2.save(); + + await sleep(1000); + expect(updateCount).to.equal(2); + expect(tasks.length).to.equal(2); + + // Add non-matching task (wrong assignee) - should not trigger subscription + const task3 = new Task(perspective!); + task3.description = "Task assigned to bob"; + task3.dueDate = tomorrowTimestamp; + task3.completed = false; + task3.assignee = "bob"; + await task3.save(); + + await sleep(1000); + expect(updateCount).to.equal(2); + expect(tasks.length).to.equal(2); + + // Mark task1 as completed - should trigger subscription to remove it + task1.completed = true; + await task1.update(); + await sleep(1000); + + expect(tasks.length).to.equal(1); + + // Clean up + await task1.delete(); + await task2.delete(); + await task3.delete(); + }); }) }) })