+
- ${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
-
-
+ ` : ''}
${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);
+ });
+ })
})
})