diff --git a/connect/package.json b/connect/package.json index 82c0dba5a..d5855e8d7 100644 --- a/connect/package.json +++ b/connect/package.json @@ -72,5 +72,5 @@ "@coasys/ad4m": "*" } }, - "version": "0.11.2-dev.4" + "version": "0.11.2-dev.5" } diff --git a/connect/src/components/views/ConnectionOptions.ts b/connect/src/components/views/ConnectionOptions.ts index aeb741d9c..0aaf505c8 100644 --- a/connect/src/components/views/ConnectionOptions.ts +++ b/connect/src/components/views/ConnectionOptions.ts @@ -17,6 +17,7 @@ export class ConnectionOptions extends LitElement { @state() private localNodeDetected = false; @state() private newPort = 0; @state() private newRemoteUrl = ""; + @state() private isMobile = false; static styles = [ sharedStyles, @@ -83,14 +84,25 @@ export class ConnectionOptions extends LitElement { this.detectLocalNode(); } + private checkMobile = () => { + this.isMobile = window.innerWidth < 800; + }; + async connectedCallback() { super.connectedCallback(); this.newPort = this.port; this.newRemoteUrl = this.remoteUrl || ""; + this.checkMobile(); + window.addEventListener('resize', this.checkMobile); await this.detectLocalNode(); this.loading = false; } + disconnectedCallback() { + super.disconnectedCallback(); + window.removeEventListener('resize', this.checkMobile); + } + willUpdate(changedProps: PropertyValues) { if (changedProps.has('port')) this.newPort = this.port; if (changedProps.has('remoteUrl')) this.newRemoteUrl = this.remoteUrl || ""; @@ -107,56 +119,58 @@ export class ConnectionOptions extends LitElement {
-
-
- ${LocalIcon()} -

Local Node

-
+ ${!this.isMobile ? html` +
+
+ ${LocalIcon()} +

Local Node

+
- ${this.localNodeDetected - ? html` -
- ${CheckIcon()} -

Local node detected on port ${this.port}

-
- - - ` - : html` -
- ${CrossIcon()} -

No local node detected on port ${this.port}

-
- -

Download and install AD4M

- - ` - } - -

Or try another port

-
- { - const input = e.target as HTMLInputElement; - const next = Number.parseInt(input.value, 10); - if (Number.isFinite(next)) this.newPort = next; - }} - @keydown=${(e: KeyboardEvent) => { - if (e.key === 'Enter') this.refreshPort(); - }} - /> - + ${this.localNodeDetected + ? html` +
+ ${CheckIcon()} +

Local node detected on port ${this.port}

+
+ + + ` + : html` +
+ ${CrossIcon()} +

No local node detected on port ${this.port}

+
+ +

Download and install AD4M

+ + ` + } + +

Or try another port

+
+ { + const input = e.target as HTMLInputElement; + const next = Number.parseInt(input.value, 10); + if (Number.isFinite(next)) this.newPort = next; + }} + @keydown=${(e: KeyboardEvent) => { + if (e.key === 'Enter') this.refreshPort(); + }} + /> + +
-
+ ` : ''} ${this.showMultiUserOption ? html`
diff --git a/connect/src/core.ts b/connect/src/core.ts index 7b4dc5afa..516a702e4 100644 --- a/connect/src/core.ts +++ b/connect/src/core.ts @@ -47,10 +47,10 @@ export default class Ad4mConnect extends EventTarget { console.log('[Ad4m Connect] Embedded mode - waiting for AD4M config via postMessage'); return new Promise((resolve, reject) => { - // Set up timeout + // Set up 30 second timeout const timeout = setTimeout(() => { reject(new Error('Timeout waiting for AD4M config from parent window')); - }, 30000); // 30 second timeout + }, 30000); // Store resolvers to call when AD4M_CONFIG arrives this.embeddedResolve = (client: Ad4mClient) => { @@ -65,14 +65,16 @@ export default class Ad4mConnect extends EventTarget { }; // If we already have a client (message arrived before connect() was called) - if (this.ad4mClient && this.authState === 'authenticated') { - clearTimeout(timeout); - console.log('[Ad4m Connect] Client already initialized in embedded mode'); - resolve(this.ad4mClient); - } else if (this.ad4mClient && this.authState !== 'authenticated') { - // Auth already failed before connect() was called - clearTimeout(timeout); - reject(new Error(`Embedded auth state: ${this.authState}`)); + if (this.ad4mClient) { + if (this.authState === 'authenticated') { + clearTimeout(timeout); + console.log('[Ad4m Connect] Client already initialized in embedded mode'); + resolve(this.ad4mClient); + } else { + // Auth already failed before connect() was called + clearTimeout(timeout); + reject(new Error(`Embedded auth state: ${this.authState}`)); + } } }); } @@ -186,14 +188,22 @@ export default class Ad4mConnect extends EventTarget { } catch (error) { console.error('[Ad4m Connect] Authentication check failed:', error); const lockedMessage = "Cannot extractByTags from a ciphered wallet. You must unlock first."; + if (error.message === lockedMessage) { // TODO: isLocked throws an error, should just return a boolean. Temp fix this.notifyAuthChange("locked"); return true; - } else { - this.notifyAuthChange("unauthenticated"); - return false; } + + // Clear token if it's invalid (signed by different agent) + if (error.message === "InvalidSignature") { + console.log('[Ad4m Connect] Clearing invalid token due to InvalidSignature'); + this.token = ''; + removeLocal('ad4m-token'); + } + + this.notifyAuthChange("unauthenticated"); + return false; } } @@ -455,7 +465,6 @@ export default class Ad4mConnect extends EventTarget { } private notifyAuthChange(value: AuthStates) { - if (this.authState === value) return; this.authState = value; this.dispatchEvent(new CustomEvent("authstatechange", { detail: value })); diff --git a/connect/src/web.ts b/connect/src/web.ts index 2fc56eb02..e5e9297c5 100644 --- a/connect/src/web.ts +++ b/connect/src/web.ts @@ -39,7 +39,7 @@ const styles = css` left: 0; height: 100vh; width: 100vw; - z-index: 100; + z-index: 99999; } .backdrop { @@ -90,12 +90,13 @@ const styles = css` background: transparent; padding: 0; cursor: pointer; - position: absolute; - bottom: 20px; - right: 20px; + position: fixed; + bottom: 10px; + right: 10px; color: var(--ac-primary-color); - width: 40px; - height: 40px; + width: 34px; + height: 34px; + z-index: 99999; } `; diff --git a/core/package.json b/core/package.json index 51757aa50..8f526bbb2 100644 --- a/core/package.json +++ b/core/package.json @@ -69,5 +69,5 @@ "graphql@15.7.2": "patches/graphql@15.7.2.patch" } }, - "version": "0.11.2-dev.2" + "version": "0.11.2-dev.5" } diff --git a/core/src/model/Ad4mModel.test.ts b/core/src/model/Ad4mModel.test.ts index c23c20e53..b98cd76ff 100644 --- a/core/src/model/Ad4mModel.test.ts +++ b/core/src/model/Ad4mModel.test.ts @@ -22,7 +22,7 @@ describe("Ad4mModel.getModelMetadata()", () => { @Optional({ through: "test://optional", writable: true }) optional: string = ""; - @ReadOnly({ through: "test://readonly", getter: "custom_getter" }) + @ReadOnly({ through: "test://readonly", prologGetter: "custom_getter" }) readonly: string = ""; @Flag({ through: "test://type", value: "test://flag" }) @@ -47,7 +47,7 @@ describe("Ad4mModel.getModelMetadata()", () => { // Verify "readonly" property expect(metadata.properties.readonly.predicate).toBe("test://readonly"); expect(metadata.properties.readonly.writable).toBe(false); - expect(metadata.properties.readonly.getter).toBe("custom_getter"); + expect(metadata.properties.readonly.prologGetter).toBe("custom_getter"); // Verify "type" property (flag) expect(metadata.properties.type.predicate).toBe("test://type"); @@ -114,18 +114,18 @@ describe("Ad4mModel.getModelMetadata()", () => { class CustomModel extends Ad4mModel { @Optional({ through: "test://computed", - getter: "triple(Base, 'test://value', V), Value is V * 2", - setter: "Value is V / 2, Actions = [{action: 'setSingleTarget', source: 'this', predicate: 'test://value', target: Value}]" + prologGetter: "triple(Base, 'test://value', V), Value is V * 2", + prologSetter: "Value is V / 2, Actions = [{action: 'setSingleTarget', source: 'this', predicate: 'test://value', target: Value}]" }) computed: number = 0; } const metadata = CustomModel.getModelMetadata(); - // Assert getter and setter contain the custom code - expect(metadata.properties.computed.getter).toContain("triple(Base, 'test://value', V), Value is V * 2"); - expect(metadata.properties.computed.setter).toContain("Value is V / 2"); - expect(metadata.properties.computed.setter).toContain("setSingleTarget"); + // Assert prologGetter and prologSetter contain the custom code + expect(metadata.properties.computed.prologGetter).toContain("triple(Base, 'test://value', V), Value is V * 2"); + expect(metadata.properties.computed.prologSetter).toContain("Value is V / 2"); + expect(metadata.properties.computed.prologSetter).toContain("setSingleTarget"); }); it("should handle collection with isInstance where clause", () => { @@ -163,7 +163,7 @@ describe("Ad4mModel.getModelMetadata()", () => { @Optional({ through: "recipe://description" }) description: string = ""; - @ReadOnly({ through: "recipe://rating", getter: "avg_rating(Base, Value)" }) + @ReadOnly({ through: "recipe://rating", prologGetter: "avg_rating(Base, Value)" }) rating: number = 0; @Collection({ through: "recipe://ingredient" }) @@ -194,7 +194,7 @@ describe("Ad4mModel.getModelMetadata()", () => { expect(metadata.properties.name.resolveLanguage).toBe("literal"); expect(metadata.properties.description.predicate).toBe("recipe://description"); expect(metadata.properties.rating.predicate).toBe("recipe://rating"); - expect(metadata.properties.rating.getter).toBe("avg_rating(Base, Value)"); + expect(metadata.properties.rating.prologGetter).toBe("avg_rating(Base, Value)"); expect(metadata.collections.ingredients.predicate).toBe("recipe://ingredient"); expect(metadata.collections.steps.predicate).toBe("recipe://step"); expect(metadata.collections.steps.local).toBe(true); diff --git a/core/src/model/Ad4mModel.ts b/core/src/model/Ad4mModel.ts index 3a8a5ed66..241e191b7 100644 --- a/core/src/model/Ad4mModel.ts +++ b/core/src/model/Ad4mModel.ts @@ -90,9 +90,11 @@ export interface PropertyMetadata { /** Language for resolution (e.g., "literal") */ resolveLanguage?: string; /** Custom Prolog getter code */ - getter?: string; + prologGetter?: string; /** Custom Prolog setter code */ - setter?: string; + prologSetter?: string; + /** Custom SurrealQL getter code */ + getter?: string; /** Whether stored locally only */ local?: boolean; /** Transform function */ @@ -110,7 +112,9 @@ export interface CollectionMetadata { /** The predicate URI (through value) */ predicate: string; /** Filter conditions */ - where?: { isInstance?: any; condition?: string }; + where?: { isInstance?: any; prologCondition?: string; condition?: string }; + /** Custom SurrealQL getter code */ + getter?: string; /** Whether stored locally only */ local?: boolean; } @@ -409,7 +413,8 @@ export class Ad4mModel { #source: string; #perspective: PerspectiveProxy; author: string; - timestamp: string; + createdAt: any; + updatedAt: any; private static classNamesByClass = new WeakMap(); @@ -438,6 +443,14 @@ export class Ad4mModel { return classCache[perspectiveID]; } + /** + * Backwards compatibility alias for createdAt. + * @deprecated Use createdAt instead. This will be removed in a future version. + */ + get timestamp(): any { + return (this as any).createdAt; + } + /** * Extracts metadata from decorators for query building. * @@ -502,8 +515,9 @@ export class Ad4mModel { writable: options.writable || false, ...(options.initial !== undefined && { initial: options.initial }), ...(options.resolveLanguage !== undefined && { resolveLanguage: options.resolveLanguage }), + ...(options.prologGetter !== undefined && { prologGetter: options.prologGetter }), ...(options.getter !== undefined && { getter: options.getter }), - ...(options.setter !== undefined && { setter: options.setter }), + ...(options.prologSetter !== undefined && { prologSetter: options.prologSetter }), ...(options.local !== undefined && { local: options.local }), ...(options.transform !== undefined && { transform: options.transform }), ...(options.flag !== undefined && { flag: options.flag }) @@ -520,7 +534,8 @@ export class Ad4mModel { name: collectionName, predicate: options.through || "", ...(options.where !== undefined && { where: options.where }), - ...(options.local !== undefined && { local: options.local }) + ...(options.local !== undefined && { local: options.local }), + ...(options.getter !== undefined && { getter: options.getter }) }; } @@ -644,10 +659,10 @@ export class Ad4mModel { throw new Error(`Property "${key}" is read-only and cannot be written`); } - if (metadata.setter) { - // Custom setter - throw error for now (Phase 2) + if (metadata.prologSetter) { + // Custom Prolog setter - throw error for now (Phase 2) throw new Error( - `Custom setter for property "${key}" not yet supported without Prolog. ` + + `Custom Prolog setter for property "${key}" not yet supported without Prolog. ` + `Use standard @Property decorator or enable Prolog for custom setters.` ); } @@ -743,8 +758,27 @@ export class Ad4mModel { }) ) ); + // Filter out properties that are read-only (getters without setters) + const writableProps = Object.fromEntries( + Object.entries(propsObject).filter(([key]) => { + const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(instance), key); + if (!descriptor) { + // No descriptor means it's a regular property on the instance, allow it + return true; + } + // Check if it's an accessor descriptor (has get/set) vs data descriptor (has value/writable) + const isAccessor = descriptor.get !== undefined || descriptor.set !== undefined; + if (isAccessor) { + // Accessor descriptor: only allow if it has a setter + return descriptor.set !== undefined; + } else { + // Data descriptor: only allow if writable is not explicitly false + return descriptor.writable !== false; + } + }) + ); // Assign properties to instance - Object.assign(instance, propsObject); + Object.assign(instance, writableProps); } private async getData() { @@ -769,11 +803,14 @@ export class Ad4mModel { const links = await this.#perspective.querySurrealDB(linksQuery); if (links && links.length > 0) { + let minTimestamp = null; let maxTimestamp = null; let latestAuthor = null; + let originalAuthor = null; - // Process properties + // Process properties (skip those with custom getter) for (const [propName, propMeta] of Object.entries(metadata.properties)) { + if (propMeta.getter) continue; // Handle via custom getter evaluation const matching = links.filter((l: any) => l.predicate === propMeta.predicate); if (matching.length > 0) { // "Latest wins" semantics: select the last element since links are ordered ASC. @@ -781,10 +818,16 @@ export class Ad4mModel { const link = matching[matching.length - 1]; let value = link.target; - // Track timestamp/author - if (link.timestamp && (!maxTimestamp || link.timestamp > maxTimestamp)) { - maxTimestamp = link.timestamp; - latestAuthor = link.author; + // Track timestamps/authors for createdAt and updatedAt + if (link.timestamp) { + if (!minTimestamp || link.timestamp < minTimestamp) { + minTimestamp = link.timestamp; + originalAuthor = link.author; + } + if (!maxTimestamp || link.timestamp > maxTimestamp) { + maxTimestamp = link.timestamp; + latestAuthor = link.author; + } } // Handle resolveLanguage @@ -820,20 +863,100 @@ export class Ad4mModel { } } - // Process collections + // Process collections (skip those with custom getter) for (const [collName, collMeta] of Object.entries(metadata.collections)) { + if (collMeta.getter) continue; // Handle via custom getter evaluation const matching = links.filter((l: any) => l.predicate === collMeta.predicate); // Collections preserve chronological order: links are sorted ASC by timestamp, // so the collection reflects the order in which items were added (oldest to newest). - (this as any)[collName] = matching.map((l: any) => l.target); + let values = matching.map((l: any) => l.target); + + // Apply where.condition filtering if present + if (collMeta.where?.condition && values.length > 0) { + try { + // Filter values by evaluating condition for each value + const filteredValues: string[] = []; + + for (const value of values) { + let condition = collMeta.where.condition + .replace(/\$perspective/g, `'${this.#perspective.uuid}'`) + .replace(/\$base/g, `'${this.#baseExpression}'`) + .replace(/Target/g, `'${value.replace(/'/g, "\\'")}'`); + + // If condition starts with WHERE, wrap it in array length check pattern + // Using array::len() to properly count matching links + if (condition.trim().startsWith('WHERE')) { + condition = `array::len(SELECT * FROM link ${condition}) > 0`; + } + + const filterQuery = `RETURN ${condition}`; + const result = await this.#perspective.querySurrealDB(filterQuery); + + // RETURN can return the value directly or in an array + const isTrue = result === true || (Array.isArray(result) && result.length > 0 && result[0] === true); + if (isTrue) { + filteredValues.push(value); + } + } + + values = filteredValues; + } catch (error) { + console.warn(`Failed to apply condition filter for ${collName}:`, error); + // Keep unfiltered values on error + } + } + + // Apply where.isInstance filtering if present + if (collMeta.where?.isInstance && values.length > 0) { + try { + const className = typeof collMeta.where.isInstance === 'string' + ? collMeta.where.isInstance + : collMeta.where.isInstance.name; + + const filterMetadata = await this.#perspective.getSubjectClassMetadataFromSDNA(className); + if (filterMetadata) { + values = await this.#perspective.batchCheckSubjectInstances(values, filterMetadata); + } + } catch (error) { + // Keep unfiltered values on error + } + } + + (this as any)[collName] = values; } - // Set author and timestamp - if (latestAuthor) { - (this as any).author = latestAuthor; + // Set author and timestamps + if (originalAuthor) { + (this as any).author = originalAuthor; + } + if (minTimestamp) { + (this as any).createdAt = minTimestamp; } if (maxTimestamp) { - (this as any).timestamp = maxTimestamp; + (this as any).updatedAt = maxTimestamp; + } + } + + // Evaluate SurrealQL getters + await ctor.evaluateCustomGettersForInstance(this, this.#perspective, metadata); + + // Apply where.isInstance filtering to getter collections + // (non-getter collections were already filtered above) + for (const [collName, collMeta] of Object.entries(metadata.collections)) { + if (collMeta.getter && collMeta.where?.isInstance && (this as any)[collName]?.length > 0) { + try { + const className = typeof collMeta.where.isInstance === 'string' + ? collMeta.where.isInstance + : collMeta.where.isInstance.name; + + const filterMetadata = await this.#perspective.getSubjectClassMetadataFromSDNA(className); + if (filterMetadata) { + const filtered = await this.#perspective.batchCheckSubjectInstances((this as any)[collName], filterMetadata); + (this as any)[collName] = filtered; + } + } catch (error) { + // Keep unfiltered values on error + } } } } catch (e) { @@ -870,6 +993,60 @@ export class Ad4mModel { return fullQuery; } + /** + * Evaluates custom SurrealQL getters for properties and collections on a specific instance. + * @private + */ + private static async evaluateCustomGettersForInstance( + instance: any, + perspective: PerspectiveProxy, + metadata: any + ) { + const safeBaseExpression = this.formatSurrealValue(instance.baseExpression); + + // Evaluate property getters + for (const [propName, propMeta] of Object.entries(metadata.properties)) { + if ((propMeta as any).getter) { + try { + // Replace 'Base' placeholder with actual base expression + const query = (propMeta as any).getter.replace(/Base/g, safeBaseExpression); + // Query from node table to have graph traversal context + const result = await perspective.querySurrealDB( + `SELECT (${query}) AS value FROM node WHERE uri = ${safeBaseExpression}` + ); + if (result && result.length > 0 && result[0].value !== undefined && result[0].value !== null && result[0].value !== 'None' && result[0].value !== '') { + instance[propName] = result[0].value; + } + } catch (error) { + console.warn(`Failed to evaluate getter for ${propName}:`, error); + } + } + } + + // Evaluate collection getters + for (const [collName, collMeta] of Object.entries(metadata.collections)) { + if ((collMeta as any).getter) { + try { + // Replace 'Base' placeholder with actual base expression + const query = (collMeta as any).getter.replace(/Base/g, safeBaseExpression); + // Query from node table to have graph traversal context + const result = await perspective.querySurrealDB( + `SELECT (${query}) AS value FROM node WHERE uri = ${safeBaseExpression}` + ); + if (result && result.length > 0 && result[0].value !== undefined && result[0].value !== null) { + // Filter out 'None' from collection results + const value = result[0].value; + instance[collName] = Array.isArray(value) + ? value.filter((v: any) => v !== undefined && v !== null && v !== '' && v !== 'None') + : value; + } + } catch (error) { + console.warn(`Failed to evaluate getter for ${collName}:`, error); + } + } + } + } + /** * Generates a SurrealQL query from a Query object. * @@ -1295,8 +1472,9 @@ WHERE ${whereConditions.join(' AND ')} } // Always add author and timestamp fields - fields.push(`(SELECT VALUE author FROM link WHERE source = source LIMIT 1) AS author`); - fields.push(`(SELECT VALUE timestamp FROM link WHERE source = source LIMIT 1) AS timestamp`); + fields.push(`(SELECT VALUE author FROM link WHERE source = source ORDER BY timestamp ASC LIMIT 1) AS author`); + fields.push(`(SELECT VALUE timestamp FROM link WHERE source = source ORDER BY timestamp ASC LIMIT 1) AS createdAt`); + fields.push(`(SELECT VALUE timestamp FROM link WHERE source = source ORDER BY timestamp DESC LIMIT 1) AS updatedAt`); return fields.join(',\n '); } @@ -1332,9 +1510,10 @@ WHERE ${whereConditions.join(' AND ')} fields.push(`target[WHERE predicate = '${escapedPredicate}'] AS ${collName}`); } - // Always add author and timestamp fields using array::first + // Always add author and timestamp fields fields.push(`array::first(author) AS author`); - fields.push(`array::first(timestamp) AS timestamp`); + fields.push(`array::first(timestamp) AS createdAt`); + fields.push(`array::last(timestamp) AS updatedAt`); return fields.join(',\n '); } @@ -1394,7 +1573,7 @@ WHERE ${whereConditions.join(' AND ')} }); } // Collect values to assign to instance - const values = [...Properties, ...Collections, ["timestamp", Timestamp], ["author", Author]]; + const values = [...Properties, ...Collections, ["createdAt", Timestamp], ["author", Author]]; await Ad4mModel.assignValuesToInstance(perspective, instance, values); return instance; @@ -1451,8 +1630,10 @@ WHERE ${whereConditions.join(' AND ')} const instance = new this(perspective, base) as any; - // Track the most recent timestamp and corresponding author + // Track both earliest (createdAt) and most recent (updatedAt) timestamps + let minTimestamp = null; let maxTimestamp = null; + let originalAuthor = null; let latestAuthor = null; // Process each link (track index for collection ordering) @@ -1464,15 +1645,22 @@ WHERE ${whereConditions.join(' AND ')} // Skip 'None' values if (target === 'None') continue; - // Track the most recent timestamp and its author - if (link.timestamp && (!maxTimestamp || link.timestamp > maxTimestamp)) { - maxTimestamp = link.timestamp; - latestAuthor = link.author; + // Track both earliest (createdAt) and latest (updatedAt) timestamps with their authors + if (link.timestamp) { + if (!minTimestamp || link.timestamp < minTimestamp) { + minTimestamp = link.timestamp; + originalAuthor = link.author; + } + if (!maxTimestamp || link.timestamp > maxTimestamp) { + maxTimestamp = link.timestamp; + latestAuthor = link.author; + } } - // Find matching property + // Find matching property (skip those with getter) let foundProperty = false; for (const [propName, propMeta] of Object.entries(metadata.properties)) { + if (propMeta.getter) continue; // Handle via getter evaluation if (propMeta.predicate === predicate) { // For properties, take the first value (or we could use timestamp to get latest) // Note: Empty objects {} are truthy, so we need to check for them explicitly @@ -1539,9 +1727,10 @@ WHERE ${whereConditions.join(' AND ')} } } - // If not a property, check if it's a collection + // If not a property, check if it's a collection (skip those with getter) if (!foundProperty) { for (const [collName, collMeta] of Object.entries(metadata.collections)) { + if (collMeta.getter) continue; // Handle via getter evaluation if (collMeta.predicate === predicate) { // For collections, accumulate all values with their timestamps and indices for sorting if (!instance[collName]) { @@ -1568,18 +1757,32 @@ WHERE ${whereConditions.join(' AND ')} } } - // Set author and timestamp from the most recent link - if (latestAuthor && maxTimestamp) { - instance.author = latestAuthor; - // Convert timestamp to number (milliseconds) if it's an ISO string + // Set author and timestamps + if (originalAuthor) { + instance.author = originalAuthor; + } + + // Set createdAt from earliest timestamp + if (minTimestamp) { + if (typeof minTimestamp === 'string' && minTimestamp.includes('T')) { + instance.createdAt = new Date(minTimestamp).getTime(); + } else if (typeof minTimestamp === 'string') { + const parsed = parseInt(minTimestamp, 10); + instance.createdAt = isNaN(parsed) ? minTimestamp : parsed; + } else { + instance.createdAt = minTimestamp; + } + } + + // Set updatedAt from most recent timestamp + if (maxTimestamp) { if (typeof maxTimestamp === 'string' && maxTimestamp.includes('T')) { - instance.timestamp = new Date(maxTimestamp).getTime(); + instance.updatedAt = new Date(maxTimestamp).getTime(); } else if (typeof maxTimestamp === 'string') { - // Try to parse as number string const parsed = parseInt(maxTimestamp, 10); - instance.timestamp = isNaN(parsed) ? maxTimestamp : parsed; + instance.updatedAt = isNaN(parsed) ? maxTimestamp : parsed; } else { - instance.timestamp = maxTimestamp; + instance.updatedAt = maxTimestamp; } } @@ -1603,8 +1806,10 @@ WHERE ${whereConditions.join(' AND ')} // Use original index as tiebreaker for stable sorting return a.originalIndex - b.originalIndex; }); - // Replace collection with sorted values - instance[collName] = pairs.map(p => p.value); + // Replace collection with sorted values, filtering out empty strings and None + instance[collName] = pairs + .map(p => p.value) + .filter((v: any) => v !== undefined && v !== null && v !== '' && v !== 'None'); // Clean up temporary arrays delete instance[timestampsKey]; delete instance[indicesKey]; @@ -1615,8 +1820,9 @@ WHERE ${whereConditions.join(' AND ')} if (requestedProperties.length > 0 || requestedCollections.length > 0) { const requestedAttributes = [...requestedProperties, ...requestedCollections]; Object.keys(instance).forEach((key) => { - // Keep only requested attributes, plus always keep timestamp and author - if (!requestedAttributes.includes(key) && key !== 'timestamp' && key !== 'author' && key !== 'baseExpression') { + // Keep only requested attributes, plus always keep createdAt, updatedAt, author, and baseExpression + // Note: timestamp is a getter alias for createdAt, so we preserve createdAt instead + if (!requestedAttributes.includes(key) && key !== 'createdAt' && key !== 'updatedAt' && key !== 'author' && key !== 'baseExpression') { delete instance[key]; } }); @@ -1628,6 +1834,43 @@ WHERE ${whereConditions.join(' AND ')} } } + // Evaluate custom getters for all instances (single pass) + // This populates collection values needed for where.isInstance filtering + for (const instance of instances) { + await this.evaluateCustomGettersForInstance(instance, perspective, metadata); + } + + // Filter collections by where.isInstance if specified + // Do this after initial evaluation so collection values exist for filtering + for (const instance of instances) { + for (const [collName, collMeta] of Object.entries(metadata.collections)) { + if (collMeta.where?.isInstance && instance[collName]?.length > 0) { + try { + const targetClass = collMeta.where.isInstance; + const subjects = instance[collName]; + + // Get the class metadata from SDNA to pass to batchCheckSubjectInstances + const targetClassName = typeof targetClass === 'string' + ? targetClass + : (targetClass as any).prototype?.className || targetClass.name; + const classMetadata = await perspective.getSubjectClassMetadataFromSDNA(targetClassName); + + if (!classMetadata) { + continue; + } + + // Check which subjects are instances of the target class + const validSubjects = await perspective.batchCheckSubjectInstances(subjects, classMetadata); + + // Update the collection with filtered instances + instance[collName] = validSubjects; + } catch (error) { + // On error, leave the collection unfiltered rather than breaking everything + } + } + } + } + // Filter by where conditions that couldn't be filtered in SQL // This includes: // - author/timestamp (computed from grouped links) diff --git a/core/src/model/decorators.ts b/core/src/model/decorators.ts index af215b425..44a915c42 100644 --- a/core/src/model/decorators.ts +++ b/core/src/model/decorators.ts @@ -29,9 +29,9 @@ export interface InstanceQueryParams { where?: object; /** - * A string representing the condition clause of the query. + * A string representing the Prolog condition clause of the query. */ -condition?: string; +prologCondition?: string; } /** @@ -65,14 +65,14 @@ condition?: string; * static async findByName(perspective: PerspectiveProxy): Promise { return [] } * * // Get highly rated recipes using a custom condition - * @InstanceQuery({ condition: "triple(Instance, 'recipe://rating', Rating), Rating > 4" }) + * @InstanceQuery({ prologCondition: "triple(Instance, 'recipe://rating', Rating), Rating > 4" }) * static async topRated(perspective: PerspectiveProxy): Promise { return [] } * } * ``` * * @param {Object} [options] - Query options * @param {object} [options.where] - Object with property-value pairs to match - * @param {string} [options.condition] - Custom Prolog condition for more complex queries + * @param {string} [options.prologCondition] - Custom Prolog condition for more complex queries */ export function InstanceQuery(options?: InstanceQueryParams) { return function (target: T, key: keyof T, descriptor: PropertyDescriptor) { @@ -93,8 +93,8 @@ export function InstanceQuery(options?: InstanceQueryParams) { } } - if(options && options.condition) { - query += ', ' + options.condition + if(options && options.prologCondition) { + query += ', ' + options.prologCondition } // Try Prolog first @@ -171,14 +171,21 @@ export interface PropertyOptions { resolveLanguage?: string; /** - * Custom getter to get the value of the property in the prolog engine. If not provided, the default getter will be used. + * Custom Prolog getter to get the value of the property. If not provided, the default getter will be used. */ - getter?: string; + prologGetter?: string; + + /** + * Custom Prolog setter to set the value of the property. Only available if the property is writable. + */ + prologSetter?: string; /** - * Custom setter to set the value of the property in the prolog engine. Only available if the property is writable. + * Custom SurrealQL getter to resolve the property value. Use this for custom graph traversals. + * The expression can reference 'Base' which will be replaced with the instance's base expression. + * Example: "(<-link[WHERE predicate = 'flux://has_reply'].in.uri)[0]" */ - setter?: string; + getter?: string; /** * Indicates whether the property is stored locally in the perspective and not in the network. Useful for properties that are not meant to be shared with the network. @@ -269,8 +276,8 @@ export interface PropertyOptions { * @param {boolean} [opts.required] - Whether the property must have a value * @param {boolean} [opts.writable=true] - Whether the property can be modified * @param {string} [opts.resolveLanguage] - Language to use for value resolution (e.g. "literal") - * @param {string} [opts.getter] - Custom Prolog code for getting the property value - * @param {string} [opts.setter] - Custom Prolog code for setting the property value + * @param {string} [opts.prologGetter] - Custom Prolog code for getting the property value + * @param {string} [opts.prologSetter] - Custom Prolog code for setting the property value * @param {boolean} [opts.local] - Whether the property should only be stored locally */ export function Optional(opts: PropertyOptions) { @@ -283,8 +290,8 @@ export function Optional(opts: PropertyOptions) { throw new Error("SubjectProperty requires an 'initial' option if 'required' is true"); } - if (!opts.through && !opts.getter) { - throw new Error("SubjectProperty requires either 'through' or 'getter' option") + if (!opts.through && !opts.prologGetter) { + throw new Error("SubjectProperty requires either 'through' or 'prologGetter' option") } target["__properties"] = target["__properties"] || {}; @@ -398,6 +405,7 @@ export function Flag(opts: FlagOptions) { interface WhereOptions { isInstance?: any + prologCondition?: string condition?: string } @@ -412,6 +420,13 @@ export interface CollectionOptions { */ where?: WhereOptions; + /** + * Custom SurrealQL getter to resolve the collection values. Use this for custom graph traversals. + * The expression can reference 'Base' which will be replaced with the instance's base expression. + * Example: "(<-link[WHERE predicate = 'flux://has_reply'].in.uri)" + */ + getter?: string; + /** * Indicates whether the property is stored locally in the perspective and not in the network. Useful for properties that are not meant to be shared with the network. */ @@ -454,13 +469,20 @@ export interface CollectionOptions { * }) * comments: string[] = []; * - * // Collection with custom filter condition + * // Collection with custom Prolog filter condition * @Collection({ * through: "recipe://step", - * where: { condition: `triple(Target, "step://order", Order), Order < 3` } + * where: { prologCondition: `triple(Target, "step://order", Order), Order < 3` } * }) * firstSteps: string[] = []; * + * // Collection with custom SurrealDB filter condition + * @Collection({ + * through: "recipe://entries", + * where: { condition: `WHERE in.uri = Target AND predicate = 'recipe://has_ingredient' AND out.uri = 'recipe://test')` + * }) + * ingredients: string[] = []; + * * // Local-only collection not shared with network * @Collection({ * through: "recipe://note", @@ -480,7 +502,7 @@ export interface CollectionOptions { * @param {string} opts.through - The predicate URI for collection links * @param {WhereOptions} [opts.where] - Filter conditions for collection values * @param {any} [opts.where.isInstance] - Model class to filter instances by - * @param {string} [opts.where.condition] - Custom Prolog condition for filtering + * @param {string} [opts.where.prologCondition] - Custom Prolog condition for filtering * @param {boolean} [opts.local] - Whether collection links are stored locally only */ export function Collection(opts: CollectionOptions) { @@ -600,15 +622,15 @@ export function ModelOptions(opts: ModelOptionsOptions) { for(let property in properties) { let propertyCode = `property(${uuid}, "${property}").\n` - let { through, initial, required, resolveLanguage, writable, flag, getter, setter, local } = properties[property] + let { through, initial, required, resolveLanguage, writable, flag, prologGetter, prologSetter, local } = properties[property] if(resolveLanguage) { propertyCode += `property_resolve(${uuid}, "${property}").\n` propertyCode += `property_resolve_language(${uuid}, "${property}", "${resolveLanguage}").\n` } - if(getter) { - propertyCode += `property_getter(${uuid}, Base, "${property}", Value) :- ${getter}.\n` + if(prologGetter) { + propertyCode += `property_getter(${uuid}, Base, "${property}", Value) :- ${prologGetter}.\n` } else if(through) { propertyCode += `property_getter(${uuid}, Base, "${property}", Value) :- triple(Base, "${through}", Value).\n` @@ -621,8 +643,8 @@ export function ModelOptions(opts: ModelOptionsOptions) { } } - if(setter) { - propertyCode += `property_setter(${uuid}, "${property}", Actions) :- ${setter}.\n` + if(prologSetter) { + propertyCode += `property_setter(${uuid}, "${property}", Actions) :- ${prologSetter}.\n` } else if (writable && through) { let setter = obj[propertyNameToSetterName(property)] if(typeof setter === "function") { @@ -665,8 +687,8 @@ export function ModelOptions(opts: ModelOptionsOptions) { if(through) { if(where) { - if(!where.isInstance && !where.condition) { - throw "'where' needs one of 'isInstance' or 'condition'" + if(!where.isInstance && !where.prologCondition && !where.condition) { + throw "'where' needs one of 'isInstance', 'prologCondition', or 'condition'" } let conditions = [] @@ -681,13 +703,19 @@ export function ModelOptions(opts: ModelOptionsOptions) { conditions.push(`instance(OtherClass, Target), subject_class("${otherClass}", OtherClass)`) } - if(where.condition) { - conditions.push(where.condition) + if(where.prologCondition) { + conditions.push(where.prologCondition) } - const conditionString = conditions.join(", ") - - collectionCode += `collection_getter(${uuid}, Base, "${collection}", List) :- setof(Target, (triple(Base, "${through}", Target), ${conditionString}), List).\n` + // If there are Prolog conditions (isInstance or prologCondition), use setof with conditions + // If only condition is present, use simple findall (SurrealDB will filter later) + if(conditions.length > 0) { + const conditionString = conditions.join(", ") + collectionCode += `collection_getter(${uuid}, Base, "${collection}", List) :- setof(Target, (triple(Base, "${through}", Target), ${conditionString}), List).\n` + } else { + // Only SurrealDB condition present (no Prolog filtering) + collectionCode += `collection_getter(${uuid}, Base, "${collection}", List) :- findall(C, triple(Base, "${through}", C), List).\n` + } } else { collectionCode += `collection_getter(${uuid}, Base, "${collection}", List) :- findall(C, triple(Base, "${through}", C), List).\n` } @@ -796,8 +824,8 @@ export function ModelOptions(opts: ModelOptionsOptions) { * @param {string} opts.through - The predicate URI for the property * @param {string} [opts.initial] - Initial value (defaults to "literal://string:uninitialized") * @param {string} [opts.resolveLanguage] - Language to use for value resolution (e.g. "literal") - * @param {string} [opts.getter] - Custom Prolog code for getting the property value - * @param {string} [opts.setter] - Custom Prolog code for setting the property value + * @param {string} [opts.prologGetter] - Custom Prolog code for getting the property value + * @param {string} [opts.prologSetter] - Custom Prolog code for setting the property value * @param {boolean} [opts.local] - Whether the property should only be stored locally */ export function Property(opts: PropertyOptions) { @@ -861,7 +889,7 @@ export function Property(opts: PropertyOptions) { * @param {string} opts.through - The predicate URI for the property * @param {string} [opts.initial] - Initial value (if property should have one) * @param {string} [opts.resolveLanguage] - Language to use for value resolution (e.g. "literal") - * @param {string} [opts.getter] - Custom Prolog code for getting the property value + * @param {string} [opts.prologGetter] - Custom Prolog code for getting the property value * @param {boolean} [opts.local] - Whether the property should only be stored locally */ export function ReadOnly(opts: PropertyOptions) { diff --git a/core/src/perspectives/PerspectiveProxy.ts b/core/src/perspectives/PerspectiveProxy.ts index e111f39c6..03999b860 100644 --- a/core/src/perspectives/PerspectiveProxy.ts +++ b/core/src/perspectives/PerspectiveProxy.ts @@ -1178,11 +1178,11 @@ export class PerspectiveProxy { * Returns required predicates that define what makes something an instance, * plus a map of property/collection names to their predicates. */ - private async getSubjectClassMetadataFromSDNA(className: string): Promise<{ + async getSubjectClassMetadataFromSDNA(className: string): Promise<{ requiredPredicates: string[], requiredTriples: Array<{predicate: string, target?: string}>, properties: Map, - collections: Map + collections: Map } | null> { try { // Get SDNA code from perspective - it's stored as a link @@ -1286,7 +1286,7 @@ export class PerspectiveProxy { //console.log("properties", properties); // Extract collection metadata - const collections = new Map(); + const collections = new Map(); const collectionResults = await this.infer(`subject_class("${className}", C), collection(C, Coll)`); //console.log("collectionResults", collectionResults); if (collectionResults) { @@ -1294,6 +1294,7 @@ export class PerspectiveProxy { const collName = result.Coll; let predicate: string | null = null; let instanceFilter: string | undefined = undefined; + let condition: string | undefined = undefined; // Try to extract predicate from collection_adder first const adderResults = await this.infer(`subject_class("${className}", C), collection_adder(C, "${collName}", Adder)`); @@ -1304,6 +1305,9 @@ export class PerspectiveProxy { predicate = predicateMatch[1] || predicateMatch[2]; } } + + // Note: condition is not stored in SDNA, it's read from class metadata + // It will be populated by augmentMetadataWithCollectionOptions() when needed // Parse collection_getter from SDNA to extract predicate and instanceFilter // Format 1 (findall): collection_getter(c, Base, "comments", List) :- findall(C, triple(Base, "todo://comment", C), List). @@ -1345,7 +1349,7 @@ export class PerspectiveProxy { } if (predicate) { - collections.set(collName, { predicate, instanceFilter }); + collections.set(collName, { predicate, instanceFilter, condition }); } } } @@ -1364,7 +1368,7 @@ export class PerspectiveProxy { requiredPredicates: string[], requiredTriples: Array<{predicate: string, target?: string}>, properties: Map, - collections: Map + collections: Map }): string { if (metadata.requiredTriples.length === 0) { // No required triples - any node with links is an instance @@ -1433,6 +1437,7 @@ export class PerspectiveProxy { /** * Gets collection values using SurrealDB when Prolog fails. * This is used as a fallback in SdnaOnly mode where link data isn't in Prolog. + * Note: This is used by Subject.ts (legacy pattern). Ad4mModel.ts uses getModelMetadata() instead. */ async getCollectionValuesViaSurreal(baseExpression: string, className: string, collectionName: string): Promise { const metadata = await this.getSubjectClassMetadataFromSDNA(className); @@ -1455,6 +1460,35 @@ export class PerspectiveProxy { } let values = result.map(r => r.value).filter(v => v !== "" && v !== ''); + + // Apply condition filtering if present + if (collMeta.condition && values.length > 0) { + try { + const filteredValues: string[] = []; + + for (const value of values) { + let condition = collMeta.condition + .replace(/\$perspective/g, `'${this.uuid}'`) + .replace(/\$base/g, `'${baseExpression}'`) + .replace(/Target/g, `'${value.replace(/'/g, "\\'")}'`); + + // If condition starts with WHERE, wrap in array length check + if (condition.trim().startsWith('WHERE')) { + condition = `array::len(SELECT * FROM link ${condition}) > 0`; + } + + const filterResult = await this.querySurrealDB(`RETURN ${condition}`); + const isTrue = filterResult === true || (Array.isArray(filterResult) && filterResult.length > 0 && filterResult[0] === true); + if (isTrue) { + filteredValues.push(value); + } + } + + values = filteredValues; + } catch (error) { + console.warn(`Failed to apply condition filter for ${collectionName}:`, error); + } + } // Apply instance filter if present - batch-check all values at once if (collMeta.instanceFilter) { @@ -1479,13 +1513,13 @@ export class PerspectiveProxy { * Batch-checks multiple expressions against subject class metadata using a single or limited SurrealDB queries. * This avoids N+1 query problems by checking all values at once. */ - private async batchCheckSubjectInstances( + async batchCheckSubjectInstances( expressions: string[], metadata: { requiredPredicates: string[], requiredTriples: Array<{predicate: string, target?: string}>, properties: Map, - collections: Map + collections: Map } ): Promise { if (expressions.length === 0) { @@ -1511,10 +1545,12 @@ export class PerspectiveProxy { if (triple.target) { // Flag: must match both predicate AND exact target value const escapedTarget = escapeSurrealString(triple.target); - checkQuery = `SELECT in.uri AS uri FROM link WHERE in.uri IN [${escapedExpressions}] AND predicate = '${escapedPredicate}' AND out.uri = '${escapedTarget}' GROUP BY in.uri`; + // Note: Removed GROUP BY because it was causing SurrealDB to only return one result + checkQuery = `SELECT in.uri AS uri FROM link WHERE in.uri IN [${escapedExpressions}] AND predicate = '${escapedPredicate}' AND out.uri = '${escapedTarget}'`; } else { // Property: just check predicate exists - checkQuery = `SELECT in.uri AS uri FROM link WHERE in.uri IN [${escapedExpressions}] AND predicate = '${escapedPredicate}' GROUP BY in.uri`; + // Note: Removed GROUP BY because it was causing SurrealDB to only return one result + checkQuery = `SELECT in.uri AS uri FROM link WHERE in.uri IN [${escapedExpressions}] AND predicate = '${escapedPredicate}'`; } const result = await this.querySurrealDB(checkQuery); diff --git a/tests/js/tests/prolog-and-literals.test.ts b/tests/js/tests/prolog-and-literals.test.ts index 5ce887bec..0b202b3db 100644 --- a/tests/js/tests/prolog-and-literals.test.ts +++ b/tests/js/tests/prolog-and-literals.test.ts @@ -327,7 +327,7 @@ describe("Prolog + Literals", () => { @InstanceQuery({where: { state: "todo://done" }}) static async allDone(perspective: PerspectiveProxy): Promise { return [] } - @InstanceQuery({condition: 'triple("ad4m://self", _, Instance)'}) + @InstanceQuery({ prologCondition: 'triple("ad4m://self", _, Instance)'}) static async allSelf(perspective: PerspectiveProxy): Promise { return [] } //@ts-ignore @@ -345,7 +345,7 @@ describe("Prolog + Literals", () => { title: string = "" @ReadOnly({ - getter: `triple(Base, "flux://has_reaction", "flux://thumbsup"), Value = true` + prologGetter: `triple(Base, "flux://has_reaction", "flux://thumbsup"), Value = true` }) isLiked: boolean = false @@ -363,7 +363,7 @@ describe("Prolog + Literals", () => { @Collection({ through: "flux://entry_type", - where: { condition: `triple(Target, "flux://has_reaction", "flux://thumbsup")` } + where: { prologCondition: `triple(Target, "flux://has_reaction", "flux://thumbsup")` } }) likedMessages: string[] = [] } @@ -492,27 +492,6 @@ describe("Prolog + Literals", () => { //console.log((await perspective!.getSdna())[1]) }) - it.skip("can constrain collection entries through 'where' clause with prolog condition", async () => { - let root = Literal.from("Collection where test with prolog condition").toUrl() - let todo = await perspective!.createSubject(new Todo(), root) - - let messageEntry = Literal.from("test message").toUrl() - - // @ts-ignore - await todo.addEntries(messageEntry) - - let entries = await todo.entries - expect(entries.length).to.equal(1) - - let messageEntries = await todo.likedMessages - expect(messageEntries.length).to.equal(0) - - await perspective?.add(new Link({source: messageEntry, predicate: "flux://has_reaction", target: "flux://thumbsup"})) - - messageEntries = await todo.likedMessages - expect(messageEntries.length).to.equal(1) - }) - it.skip("can use properties with custom getter prolog code", async () => { let root = Literal.from("Custom getter test").toUrl() let todo = await perspective!.createSubject(new Todo(), root) @@ -625,7 +604,7 @@ describe("Prolog + Literals", () => { @Collection({ through: "recipe://entries", - where: { condition: `triple(Target, "recipe://has_ingredient", "recipe://test")` } + where: { prologCondition: `triple(Target, "recipe://has_ingredient", "recipe://test")` } }) ingredients: string[] = [] @@ -806,6 +785,63 @@ describe("Prolog + Literals", () => { expect(recipe2.ingredients.length).to.equal(1); }) + it("can constrain collection entries through 'where' clause with condition", async () => { + // Define a Recipe model with condition filtering + @ModelOptions({ name: "RecipeWithSurrealFilter" }) + class RecipeWithSurrealFilter extends Ad4mModel { + @Optional({ + through: "recipe://name", + resolveLanguage: "literal" + }) + name: string = ""; + + @Collection({ through: "recipe://entries" }) + entries: string[] = []; + + @Collection({ + through: "recipe://entries", + where: { + condition: `WHERE in.uri = Target AND predicate = 'recipe://has_ingredient' AND out.uri = 'recipe://test'` + } + }) + ingredients: string[] = []; + } + + // Register the class + await perspective!.ensureSDNASubjectClass(RecipeWithSurrealFilter); + + let root = Literal.from("Active record surreal condition test").toUrl(); + const recipe = new RecipeWithSurrealFilter(perspective!, root); + + let entry1 = Literal.from("entry with ingredient").toUrl(); + let entry2 = Literal.from("entry without ingredient").toUrl(); + + recipe.entries = [entry1, entry2]; + recipe.name = "Condition test"; + + await recipe.save(); + + // Add the ingredient link to entry1 only + await perspective?.add(new Link({ + source: entry1, + predicate: "recipe://has_ingredient", + target: "recipe://test" + })); + + // Small delay for SurrealDB indexing + await sleep(500); + + const recipe2 = new RecipeWithSurrealFilter(perspective!, root); + await recipe2.get(); + + // Should have 2 entries total + expect(recipe2.entries.length).to.equal(2); + + // But only 1 ingredient (entry1 which has the ingredient link) + expect(recipe2.ingredients.length).to.equal(1); + expect(recipe2.ingredients[0]).to.equal(entry1); + }) + it("can implement the resolveLanguage property type", async () => { let root = Literal.from("Active record implementation test resolveLanguage").toUrl() const recipe = new Recipe(perspective!, root) @@ -2849,6 +2885,323 @@ describe("Prolog + Literals", () => { }); }); }) + + describe("getter feature tests", () => { + @ModelOptions({ name: "BlogPost" }) + class BlogPost extends Ad4mModel { + @Property({ + through: "blog://title", + resolveLanguage: "literal" + }) + title: string = ""; + + @Optional({ + through: "blog://parent", + getter: "(->link[WHERE perspective = $perspective AND predicate = 'blog://reply_to'].out.uri)[0]" + }) + parentPost: string | undefined; + + @Collection({ + through: "blog://tags", + getter: "(->link[WHERE perspective = $perspective AND predicate = 'blog://tagged_with'].out.uri)" + }) + tags: string[] = []; + } + + beforeEach(async () => { + if(perspective) { + await ad4m!.perspective.remove(perspective.uuid) + } + perspective = await ad4m!.perspective.add("getter-test") + const { name, sdna } = (BlogPost as any).generateSDNA(); + await perspective!.addSdna(name, sdna, 'subject_class') + }); + + it("should evaluate getter for property", async () => { + const postRoot = Literal.from("Blog post for getter property test").toUrl(); + const parentRoot = Literal.from("Parent blog post").toUrl(); + + const post = new BlogPost(perspective!, postRoot); + post.title = "Reply Post"; + await post.save(); + + const parent = new BlogPost(perspective!, parentRoot); + parent.title = "Original Post"; + await parent.save(); + + // Create the link that getter should find + await perspective!.add(new Link({ + source: postRoot, + predicate: "blog://reply_to", + target: parentRoot + })); + + // Get the post and check if getter resolved the parent + const retrievedPost = new BlogPost(perspective!, postRoot); + await retrievedPost.get(); + + expect(retrievedPost.parentPost).to.equal(parentRoot); + }); + + it("should evaluate getter for collection", async () => { + const postRoot = Literal.from("Blog post for getter collection test").toUrl(); + const tag1 = Literal.from("tag:javascript").toUrl(); + const tag2 = Literal.from("tag:typescript").toUrl(); + + const post = new BlogPost(perspective!, postRoot); + post.title = "Test Post"; + await post.save(); + + // Create links that getter should find + await perspective!.add(new Link({ + source: postRoot, + predicate: "blog://tagged_with", + target: tag1 + })); + await perspective!.add(new Link({ + source: postRoot, + predicate: "blog://tagged_with", + target: tag2 + })); + + // Get the post and check if getter resolved the tags + const retrievedPost = new BlogPost(perspective!, postRoot); + await retrievedPost.get(); + + expect(retrievedPost.tags).to.include(tag1); + expect(retrievedPost.tags).to.include(tag2); + expect(retrievedPost.tags.length).to.equal(2); + }); + + it("should filter out 'None' and empty values from getter results", async () => { + const postRoot = Literal.from("Blog post for None filtering test").toUrl(); + + const post = new BlogPost(perspective!, postRoot); + post.title = "Post without parent"; + await post.save(); + + // Don't create any reply_to link, so getter should return None/empty + + const retrievedPost = new BlogPost(perspective!, postRoot); + await retrievedPost.get(); + + // Property should be undefined, not 'None' or empty string + expect(retrievedPost.parentPost).to.be.undefined; + }); + }) + + describe("isInstance filtering tests", () => { + @ModelOptions({ name: "Comment" }) + class Comment extends Ad4mModel { + @Flag({ + through: "ad4m://type", + value: "ad4m://comment" + }) + type!: string; + + @Property({ + through: "comment://text", + resolveLanguage: "literal" + }) + text: string = ""; + } + + @ModelOptions({ name: "Article" }) + class Article extends Ad4mModel { + @Property({ + through: "article://title", + resolveLanguage: "literal" + }) + title: string = ""; + + @Collection({ + through: "article://has_comment", + where: { isInstance: Comment } + }) + comments: string[] = []; + } + + @ModelOptions({ name: "ArticleWithString" }) + class ArticleWithString extends Ad4mModel { + @Property({ + through: "article://title", + resolveLanguage: "literal" + }) + title: string = ""; + + @Collection({ + through: "article://has_comment", + where: { isInstance: "Comment" } + }) + comments: string[] = []; + } + + beforeEach(async () => { + if(perspective) { + await ad4m!.perspective.remove(perspective.uuid) + } + perspective = await ad4m!.perspective.add("isInstance-test") + + // Register both Comment and Article classes using ensureSDNASubjectClass + await perspective!.ensureSDNASubjectClass(Comment); + await perspective!.ensureSDNASubjectClass(Article); + await perspective!.ensureSDNASubjectClass(ArticleWithString); + + // Give perspective time to fully index the SDNA classes + await sleep(200); + }); + + it("should filter collection by isInstance with class reference", async () => { + const articleRoot = Literal.from("Article for isInstance test").toUrl(); + const validComment1 = Literal.from("Valid comment 1").toUrl(); + const validComment2 = Literal.from("Valid comment 2").toUrl(); + const invalidItem = Literal.from("Invalid item").toUrl(); + + const article = new Article(perspective!, articleRoot); + article.title = "Test Article"; + await article.save(); + + // Create valid comments + const comment1 = new Comment(perspective!, validComment1); + comment1.text = "This is a valid comment"; + await comment1.save(); + + const comment2 = new Comment(perspective!, validComment2); + comment2.text = "This is another valid comment"; + await comment2.save(); + + // Add delay to allow SurrealDB to finish indexing + await sleep(1500); + + // Add links to article + await perspective!.add(new Link({ + source: articleRoot, + predicate: "article://has_comment", + target: validComment1 + })); + await perspective!.add(new Link({ + source: articleRoot, + predicate: "article://has_comment", + target: invalidItem + })); + await perspective!.add(new Link({ + source: articleRoot, + predicate: "article://has_comment", + target: validComment2 + })); + + const retrievedArticle = new Article(perspective!, articleRoot); + await retrievedArticle.get(); + + // Should only contain valid Comments, not the invalid item + expect(retrievedArticle.comments).to.have.lengthOf(2); + expect(retrievedArticle.comments).to.include(validComment1); + expect(retrievedArticle.comments).to.include(validComment2); + expect(retrievedArticle.comments).to.not.include(invalidItem); + }); + + it("should filter collection by isInstance with string class name", async () => { + const articleRoot = Literal.from("Article for string isInstance test").toUrl(); + const validComment = Literal.from("Valid comment").toUrl(); + const invalidItem = Literal.from("Invalid item").toUrl(); + + const article = new ArticleWithString(perspective!, articleRoot); + article.title = "Test Article with String"; + await article.save(); + + // Create one valid comment + const comment = new Comment(perspective!, validComment); + comment.text = "Valid comment text"; + await comment.save(); + + // Add both to article + await perspective!.add(new Link({ + source: articleRoot, + predicate: "article://has_comment", + target: validComment + })); + await perspective!.add(new Link({ + source: articleRoot, + predicate: "article://has_comment", + target: invalidItem + })); + + const retrievedArticle = new ArticleWithString(perspective!, articleRoot); + await retrievedArticle.get(); + + expect(retrievedArticle.comments).to.have.lengthOf(1); + expect(retrievedArticle.comments[0]).to.equal(validComment); + }); + + it("should filter results in findAll() by isInstance", async () => { + // Create two articles + const article1Root = Literal.from("Article 1 for findAll isInstance").toUrl(); + const article2Root = Literal.from("Article 2 for findAll isInstance").toUrl(); + + const comment1 = Literal.from("Comment 1").toUrl(); + const invalid1 = Literal.from("Invalid 1").toUrl(); + const comment2 = Literal.from("Comment 2").toUrl(); + const invalid2 = Literal.from("Invalid 2").toUrl(); + + // Create articles + const article1 = new Article(perspective!, article1Root); + article1.title = "Article 1"; + await article1.save(); + + const article2 = new Article(perspective!, article2Root); + article2.title = "Article 2"; + await article2.save(); + + // Create valid comments + const c1 = new Comment(perspective!, comment1); + c1.text = "Comment 1 text"; + await c1.save(); + + const c2 = new Comment(perspective!, comment2); + c2.text = "Comment 2 text"; + await c2.save(); + + // Add comments to articles (mix of valid and invalid) + await perspective!.add(new Link({ + source: article1Root, + predicate: "article://has_comment", + target: comment1 + })); + await perspective!.add(new Link({ + source: article1Root, + predicate: "article://has_comment", + target: invalid1 + })); + await perspective!.add(new Link({ + source: article2Root, + predicate: "article://has_comment", + target: comment2 + })); + await perspective!.add(new Link({ + source: article2Root, + predicate: "article://has_comment", + target: invalid2 + })); + + // Use findAll and verify filtering + const articles = await Article.findAll(perspective!); + + expect(articles).to.have.lengthOf(2); + + const foundArticle1 = articles.find(a => a.title === "Article 1"); + const foundArticle2 = articles.find(a => a.title === "Article 2"); + + expect(foundArticle1).to.not.be.undefined; + expect(foundArticle2).to.not.be.undefined; + + // Each article should only have valid comments + expect(foundArticle1!.comments).to.have.lengthOf(1); + expect(foundArticle1!.comments[0]).to.equal(comment1); + + expect(foundArticle2!.comments).to.have.lengthOf(1); + expect(foundArticle2!.comments[0]).to.equal(comment2); + }); + }) }) })