Page MenuHomeSealhub

No OneTemporary

diff --git a/src/app/base-chips/field-types/cached-value.ts b/src/app/base-chips/field-types/cached-value.ts
index 9b50ab2c..cbf01415 100644
--- a/src/app/base-chips/field-types/cached-value.ts
+++ b/src/app/base-chips/field-types/cached-value.ts
@@ -1,282 +1,284 @@
import type {
App,
Field,
Context,
ValidationResult,
Collection,
CollectionItem,
ExtractFieldInput,
} from "../../../main.js";
import ItemList from "../../../chip-types/item-list.js";
import { BadContext } from "../../../response/errors.js";
import isEmpty from "../../../utils/is-empty.js";
import HybridField from "../../../chip-types/field-hybrid.js";
import {
CollectionRefreshCondition,
RefreshCondition,
} from "../../event-description.js";
import DerivedValue from "./derived-value.js";
type GetValue<DecodedValue> = (
context: Context,
item: CollectionItem
) => Promise<DecodedValue>;
type CachedValueSettings<InputType, DecodedType> = {
refresh_on: RefreshCondition[];
get_value: GetValue<DecodedType>;
initial_value: InputType | null;
derive_from?: string[];
};
export default class CachedValue<
DecodedType,
StorageType,
T extends Field<any, any, any>
> extends HybridField<
DecodedType,
ExtractFieldInput<T>,
{ timestamp: number; value: StorageType },
DecodedType,
ExtractFieldInput<T>,
StorageType,
T
> {
typeName = "cached-value";
app: App;
+ base_field: T;
refresh_on: RefreshCondition[];
get_value: GetValue<DecodedType>;
hasDefaultValue: () => true;
private initial_value: ExtractFieldInput<T> | null;
private virtual_derived: DerivedValue<
DecodedType,
ExtractFieldInput<T>,
T
> | null = null; // sometimes it's necessary to have a field react to both the changes in local fields, as well as changes in cron/another collection
constructor(
base_field: T,
public params: CachedValueSettings<ExtractFieldInput<T>, DecodedType>
) {
super(base_field);
super.setParams(params);
this.refresh_on = params.refresh_on;
- this.get_value = params.get_value;
- this.initial_value = params.initial_value;
- if (params.derive_from) {
- this.virtual_derived = new DerivedValue(base_field, {
- fields: params.derive_from,
- deriving_fn: params.get_value,
- });
- }
+ this.base_field = base_field;
}
async init(app: App, collection: Collection): Promise<void> {
+ this.get_value = this.params.get_value;
+ this.initial_value = this.params.initial_value;
+ if (this.params.derive_from) {
+ this.virtual_derived = new DerivedValue(this.base_field, {
+ fields: this.params.derive_from,
+ deriving_fn: this.params.get_value,
+ });
+ }
await super.init(app, collection);
await this.virtual_field.init(app, collection);
await this.virtual_derived?.init(app, collection);
this.checkForPossibleRecursiveEdits();
const create_action = this.refresh_on.find((condition) => {
return (
condition instanceof CollectionRefreshCondition &&
condition.event_names.some((name) => name.includes("create"))
);
});
if (
create_action &&
create_action instanceof CollectionRefreshCondition
) {
app.on("started", () =>
this.refresh_outdated_cache_values(app, create_action)
);
}
for (const condition of this.refresh_on) {
condition.attachTo(app, this.collection, async (arg) => {
const cache_resource_ids = await condition.resource_id_getter(
arg
);
if (!Array.isArray(cache_resource_ids)) {
throw new Error(
`resource_id_getter return value should be an array of strings, got: ${JSON.stringify(
cache_resource_ids
)}`
);
}
app.Logger.debug3("CACHED VALUE", "Inside hook", {
cache_resource_ids,
});
const promises = [];
const context = arg[0];
const { items } = await this.collection
.list(context)
.ids(cache_resource_ids)
.fetch();
for (const item of items) {
promises.push(
this.get_value(context, item).then(async (value) => {
const su_item = await context.app.collections[
this.collection.name
].suGetByID(item.id);
su_item.set(this.name, value);
await su_item.save(new app.SuperContext());
})
);
}
await Promise.all(promises);
});
}
}
checkForPossibleRecursiveEdits(): void {
const doesAnyMatches = this.refresh_on.some(
(condition) =>
condition instanceof CollectionRefreshCondition &&
condition.collection_name === this.collection.name
);
if (doesAnyMatches) {
throw new Error(
"In the " +
this.collection.name +
" collection definition you've tried to create the " +
this.name +
" cached-value field that refers to the collection itself. Consider using 'derived-value' field type to avoid problems with endless recurrence."
);
}
}
private async refresh_outdated_cache_values(
app: App,
condition: CollectionRefreshCondition
) {
const referenced_collection_name = condition.collection_name;
app.Logger.debug3(
"CACHED VALUE",
`Finding resources without cached value for field ${this.collection.name}.${this.name}. For this, we're looking for items from ${referenced_collection_name} and we'll be looking at them newest-to-oldest.`
);
const response = await new ItemList(
this.app.collections[referenced_collection_name],
new this.app.SuperContext()
)
.sort({ "_metadata.modified_at": "desc" })
.paginate({ items: 1 })
.fetch();
if (response.empty) {
return;
}
const last_modified_timestamp = response.items[0]._metadata.modified_at;
app.Logger.debug3(
"CACHED VALUE",
`Continuing searching for resources without cached value for field ${this.collection.name}.${this.name}. Now, we find resources that are potentially outdated.`
);
const outdated_resource_bodies = await this.app.Datastore.aggregate(
this.collection.name,
[
{
$match: {
$or: [
{
[`${this.name}.timestamp`]: {
$lt: last_modified_timestamp,
},
},
{ [this.name]: { $exists: false } },
],
},
},
]
);
this.app.Logger.debug3(
"CACHED",
"Outdated items",
outdated_resource_bodies
);
if (!outdated_resource_bodies) {
return;
}
const su_context = new this.app.SuperContext();
const { items } = await this.collection
.suList()
.ids(outdated_resource_bodies.map((b: { id: string }) => b.id))
.fetch();
for (const item of items) {
const value = await this.get_value(su_context, item);
const cache_value = await this.encode(su_context, value as any);
this.app.Logger.debug3(
"CACHED",
`New value for item ${item.id}.${this.name}`,
value
);
await this.app.Datastore.update(
this.collection.name,
{ id: item.id },
{ $set: { [this.name]: cache_value } }
);
}
}
async getDefaultValue(_: Context) {
return this.initial_value;
}
async encode(context: Context, new_value: ExtractFieldInput<T>) {
const encoded_value = await super.encode(context, new_value);
const ret = { timestamp: Date.now(), value: encoded_value };
context.app.Logger.debug3("CACHED VALUE", "Encode", { new_value, ret });
return ret as any;
}
async decode(
context: Context,
db_value: { timestamp: number; value: StorageType },
old_value: any,
format: any
) {
return super.decode(context, db_value.value as any, old_value, format);
}
async isProperValue(
context: Context,
new_value: Parameters<T["checkValue"]>[1],
old_value: Parameters<T["checkValue"]>[2],
new_value_blessing_token: symbol | null
): Promise<ValidationResult> {
if (this.virtual_derived) {
return this.virtual_derived.isProperValue(
context,
new_value,
old_value,
new_value_blessing_token
);
}
if (!isEmpty(new_value) && !context.is_super) {
throw new BadContext("This is a read-only field");
}
return this.virtual_field.checkValue(
context,
new_value,
old_value,
new_value_blessing_token
);
}
async getValuePath(): Promise<string> {
return `${this.name}.value`;
}
setName(name: string): void {
super.setName(name);
this.virtual_derived?.setName(name);
}
}
diff --git a/src/app/base-chips/field-types/deep-reverse-single-reference.remarkup b/src/app/base-chips/field-types/deep-reverse-single-reference.remarkup
index dd5390d4..04382197 100644
--- a/src/app/base-chips/field-types/deep-reverse-single-reference.remarkup
+++ b/src/app/base-chips/field-types/deep-reverse-single-reference.remarkup
@@ -1,72 +1,93 @@
# Deep Reverse Single Reference
This field will store a list of ids of a collection referenced in an
intermediary collection, often used for n-to-n relations.
Say you have collections like so:
```
articles: new (class extends Collection {
fields = {
title: new Text(),
};
})(),
categories: new (class extends Collection {
fields = {
name: new Text(),
};
})(),
article_category: new (class extends Collection {
fields = {
article: new SingleReference("articles"),
category: new SingleReference("categories"),
};
})(),
```
The `article_category` collection connects `articles` and `categories` in a way
that lets you have every connection assigned to any amount of articles, and any
article to any amount of categories (n-to-n).
Now, we can add a field to `articles` that will list the ids of categories
assigned to that article:
+```
+articles: new (class extends Collection {
+ fields = {
+ title: new Text(),
+ categories: new DeepReverseSingleReference("article_category"),
+ };
+})(),
+```
+
+In most cases its enough to provide just name of intermediary collection for
+sealious to figure out what values he needs but if its not a case you will get
+descriptive error. If thats a case you can use legacy API that will allow you
+to configure every value needed:
+
```
articles: new (class extends Collection {
fields = {
title: new Text(),
categories: new DeepReverseSingleReference({
intermediary_collection: "article_category",
intermediary_field_that_points_here:
"article",
intermediary_field_that_points_there:
"category",
target_collection: "categories",
}),
};
})(),
```
Now, when you add a category-article binding to the `article_category`
collection, the `categories` field in `articles` will automatically update and
contain a list of all categories that are assigned to the articles.
## Constructor params
Assuming you have A and B collections with n-to-n relation:
+```
+// .. in collection A
+
+new DeepReverseSingleReference("a_to_b"), // the collection that stores the links between colllection A and B
+```
+
+or
```
// .. in collection A
new DeepReverseSingleReference({
intermediary_collection: "a_to_b", // the collection that stores the links between colllection A and B
intermediary_field_that_points_here: "a", // which field in intermediary collection points to collection A
intermediary_field_that_points_there: "b", // which field in intermediary collection points to collection b
target_collection: "b", // the target collection
}),
```
diff --git a/src/app/base-chips/field-types/deep-reverse-single-reference.test.ts b/src/app/base-chips/field-types/deep-reverse-single-reference.test.ts
index 8857ede5..cf888717 100644
--- a/src/app/base-chips/field-types/deep-reverse-single-reference.test.ts
+++ b/src/app/base-chips/field-types/deep-reverse-single-reference.test.ts
@@ -1,141 +1,297 @@
import assert from "assert";
import { Collection, FieldTypes } from "../../../main.js";
import { TestApp } from "../../../test_utils/test-app.js";
import { withRunningApp } from "../../../test_utils/with-test-app.js";
import { DeepReverseSingleReference } from "./deep-reverse-single-reference.js";
import SingleReference from "./single-reference.js";
import Text from "./text.js";
import _locreq from "locreq";
import { module_dirname } from "../../../utils/module_filename.js";
const locreq = _locreq(module_dirname(import.meta.url));
describe("deep-reverse-single-reference", () => {
it("adds ids in an n-to-n collection scenario", () =>
withRunningApp(
(TestClass) =>
class extends TestClass {
collections = {
...TestApp.BaseCollections,
articles: new (class extends Collection {
fields = {
title: new Text(),
categories: new DeepReverseSingleReference({
intermediary_collection: "article_category",
intermediary_field_that_points_here:
"article",
intermediary_field_that_points_there:
"category",
target_collection: "categories",
}),
};
})(),
categories: new (class extends Collection {
fields = {
name: new Text(),
};
})(),
article_category: new (class extends Collection {
fields = {
article: new SingleReference("articles"),
category: new SingleReference("categories"),
};
})(),
};
},
async ({ app }) => {
const article = await app.collections.articles.suCreate({
title: "Hello, world",
});
const category = await app.collections.categories.suCreate({
name: "lifestyle",
});
const assignment =
await app.collections.article_category.suCreate({
article: article.id,
category: category.id,
});
const article_bis = await app.collections.articles.suGetByID(
article.id
);
assert.deepStrictEqual(article_bis.get("categories"), [
category.id,
]);
await app.collections.article_category.suRemoveByID(
assignment.id
);
const article_tris = await app.collections.articles.suGetByID(
article.id
);
assert.deepStrictEqual(article_tris.get("categories"), []);
}
));
it("handles formatting of the referenced collection", async () => {
return withRunningApp(
(t) =>
class extends t {
collections = {
...TestApp.BaseCollections,
dogs: new (class extends Collection {
fields = {
name: new FieldTypes.Text(),
photos: new FieldTypes.DeepReverseSingleReference(
{
intermediary_collection: "dog_to_photo",
intermediary_field_that_points_here:
"dog",
intermediary_field_that_points_there:
"photo",
target_collection: "photos",
}
),
};
})(),
dog_to_photo: new (class extends Collection {
fields = {
dog: new FieldTypes.SingleReference("dogs"),
photo: new FieldTypes.SingleReference("photos"),
};
})(),
photos: new (class extends Collection {
fields = {
photo: new FieldTypes.Image(),
};
})(),
};
},
async ({ app }) => {
let leon = await app.collections.dogs.suCreate({
name: "Leon",
});
let photo = await app.collections.photos.suCreate({
photo: app.FileManager.fromPath(
locreq.resolve(
"src/app/base-chips/field-types/default-image.jpg"
)
),
});
await app.collections.dog_to_photo.suCreate({
dog: leon.id,
photo: photo.id,
});
leon = (
await app.collections.dogs
.suList()
.ids([leon.id])
.format({ photos: { photo: "url" } })
.attach({ photos: true })
.fetch()
).items[0];
assert.strictEqual(
typeof leon.getAttachments("photos")[0].get("photo"),
"string"
);
}
);
});
+
+ it("adds ids in an n-to-n collection scenario with shortname", () =>
+ withRunningApp(
+ (TestClass) =>
+ class extends TestClass {
+ collections = {
+ ...TestApp.BaseCollections,
+ articles: new (class extends Collection {
+ fields = {
+ title: new Text(),
+ categories: new DeepReverseSingleReference(
+ "article_category"
+ ),
+ };
+ })(),
+ categories: new (class extends Collection {
+ fields = {
+ name: new Text(),
+ };
+ })(),
+ article_category: new (class extends Collection {
+ fields = {
+ article: new SingleReference("articles"),
+ category: new SingleReference("categories"),
+ };
+ })(),
+ };
+ },
+ async ({ app }) => {
+ const article = await app.collections.articles.suCreate({
+ title: "Hello, world",
+ });
+ const category = await app.collections.categories.suCreate({
+ name: "lifestyle",
+ });
+ const assignment =
+ await app.collections.article_category.suCreate({
+ article: article.id,
+ category: category.id,
+ });
+
+ const article_bis = await app.collections.articles.suGetByID(
+ article.id
+ );
+ assert.deepStrictEqual(article_bis.get("categories"), [
+ category.id,
+ ]);
+
+ await app.collections.article_category.suRemoveByID(
+ assignment.id
+ );
+
+ const article_tris = await app.collections.articles.suGetByID(
+ article.id
+ );
+ assert.deepStrictEqual(article_tris.get("categories"), []);
+ }
+ ));
+
+ it("properly auto maches missing configuration", () =>
+ withRunningApp(
+ (TestClass) =>
+ class extends TestClass {
+ collections = {
+ ...TestApp.BaseCollections,
+ articles: new (class extends Collection {
+ fields = {
+ title: new Text(),
+ categories: new DeepReverseSingleReference(
+ "article_category"
+ ),
+ };
+ })(),
+ categories: new (class extends Collection {
+ fields = {
+ name: new Text(),
+ };
+ })(),
+ article_category: new (class extends Collection {
+ fields = {
+ article: new SingleReference("articles"),
+ category: new SingleReference("categories"),
+ };
+ })(),
+ };
+ },
+ async ({ app }) => {
+ // {
+ // intermediary_collection: "article_category",
+ // intermediary_field_that_points_here: "article",
+ // intermediary_field_that_points_there: "category",
+ // target_collection: "categories",
+ // }
+ assert.deepStrictEqual(
+ app.collections.articles.fields.categories
+ .intermediary_collection,
+ "article_category"
+ );
+ assert.deepStrictEqual(
+ app.collections.articles.fields.categories
+ .intermediary_field_that_points_there,
+ "category"
+ );
+ assert.deepStrictEqual(
+ app.collections.articles.fields.categories
+ .target_collection,
+ "categories"
+ );
+ assert.deepStrictEqual(
+ app.collections.articles.fields.categories
+ .referencing_field,
+ "article"
+ );
+ }
+ ));
+
+ it("should throw an error if auto configuration is not possible", async () => {
+ try {
+ await withRunningApp(
+ (TestClass) =>
+ class extends TestClass {
+ collections = {
+ ...TestApp.BaseCollections,
+ articles: new (class extends Collection {
+ fields = {
+ title: new Text(),
+ categories: new DeepReverseSingleReference(
+ "article_category"
+ ),
+ };
+ })(),
+ categories: new (class extends Collection {
+ fields = {
+ name: new Text(),
+ };
+ })(),
+ article_category: new (class extends Collection {
+ fields = {
+ article: new SingleReference("articles"),
+ category: new SingleReference("categories"),
+ category2: new SingleReference(
+ "categories"
+ ),
+ };
+ })(),
+ };
+ },
+ () => Promise.resolve()
+ );
+ } catch (err) {
+ assert.deepEqual(
+ err.message,
+ "Couldn't match intermediary fields automatically. Please provide detailed configuration or clear intermediary collection."
+ );
+ }
+ });
});
diff --git a/src/app/base-chips/field-types/deep-reverse-single-reference.ts b/src/app/base-chips/field-types/deep-reverse-single-reference.ts
index fdda4475..c8006d91 100644
--- a/src/app/base-chips/field-types/deep-reverse-single-reference.ts
+++ b/src/app/base-chips/field-types/deep-reverse-single-reference.ts
@@ -1,128 +1,211 @@
import ItemList, { AttachmentOptions } from "../../../chip-types/item-list.js";
import type Context from "../../../context.js";
-import type { CollectionItem } from "../../../main.js";
+import type { App, Collection, CollectionItem } from "../../../main.js";
import ReverseSingleReference from "./reverse-single-reference.js";
+import SingleReference from "./single-reference.js";
+
+type Parameters =
+ | {
+ intermediary_collection: string;
+ intermediary_field_that_points_here: string;
+ intermediary_field_that_points_there: string;
+ target_collection: string;
+ }
+ | string;
export class DeepReverseSingleReference extends ReverseSingleReference {
typeName = "deep-reverse-single-reference";
intermediary_field_that_points_there: string;
target_collection: string;
+ intermediary_collection: string;
+
+ constructor(params: Parameters) {
+ let intermediary_field_that_points_here = "";
+ let intermediary_collection = "";
+ let intermediary_field_that_points_there = "";
+ let target_collection = "";
+ if (typeof params === "string") {
+ intermediary_collection = params;
+ } else {
+ intermediary_field_that_points_here =
+ params.intermediary_field_that_points_here;
+ intermediary_collection = params.intermediary_collection;
+ intermediary_field_that_points_there =
+ params.intermediary_field_that_points_there;
+ target_collection = params.target_collection;
+ }
- constructor({
- intermediary_collection,
- intermediary_field_that_points_here,
- intermediary_field_that_points_there,
- target_collection,
- }: {
- intermediary_collection: string;
- intermediary_field_that_points_here: string;
- intermediary_field_that_points_there: string;
- target_collection: string;
- }) {
super({
referencing_field: intermediary_field_that_points_here,
referencing_collection: intermediary_collection,
});
this.intermediary_field_that_points_there =
intermediary_field_that_points_there;
this.target_collection = target_collection;
+ this.intermediary_collection = intermediary_collection;
+ }
+
+ async init(app: App, collection: Collection): Promise<void> {
+ if (this.intermediary_collection && !this.target_collection) {
+ const orgin_collection = collection.name;
+
+ const intermediary_collection_instance =
+ app.collections[this.intermediary_collection];
+ const intermediary_fields = intermediary_collection_instance.fields;
+ const intermediary_collection_fields =
+ Object.values(intermediary_fields);
+
+ const fields_from_origin_collection =
+ intermediary_collection_fields.filter(
+ (fld: SingleReference) =>
+ fld instanceof SingleReference &&
+ fld.target_collection === orgin_collection
+ ) as SingleReference[];
+ const fields_from_target_collection =
+ intermediary_collection_fields.filter(
+ (fld: SingleReference) =>
+ fld instanceof SingleReference &&
+ fld.target_collection !== orgin_collection
+ ) as SingleReference[];
+
+ if (
+ fields_from_origin_collection.length !== 1 ||
+ fields_from_target_collection.length !== 1
+ ) {
+ throw new Error(
+ "Couldn't match intermediary fields automatically. Please provide detailed configuration or clear intermediary collection."
+ );
+ }
+
+ const intermediary_field_that_points_here = Object.keys(
+ intermediary_fields
+ ).find(
+ (key) =>
+ intermediary_fields[key] ===
+ fields_from_origin_collection[0]
+ );
+ const intermediary_field_that_points_there = Object.keys(
+ intermediary_fields
+ ).find(
+ (key) =>
+ intermediary_fields[key] ===
+ fields_from_target_collection[0]
+ );
+
+ if (
+ !intermediary_field_that_points_here ||
+ !intermediary_field_that_points_there
+ ) {
+ throw new Error(
+ "Runtime error. `intermediary_field_that_points_here` and `intermediary_field_that_points_there` could not be resolved."
+ );
+ }
+
+ this.target_collection =
+ fields_from_target_collection[0].target_collection;
+ this.intermediary_field_that_points_there =
+ intermediary_field_that_points_there;
+ this.referencing_field = intermediary_field_that_points_here;
+ }
+
+ super.init(app, collection);
}
getReferencingCollection() {
return this.app.collections[this.referencing_collection];
}
async getMatchQueryValue(context: Context, field_filter: any) {
if (typeof field_filter !== "object") {
return {
$eq: field_filter,
};
}
context.app.Logger.debug3(
"DEEP REVERSE SINGLE REFERENCE",
"Querying items matching query:",
field_filter
);
const { items } = await this.app.collections[this.target_collection]
.list(context)
.filter(field_filter)
.fetch();
return {
$in: items.map((resource) => resource.id),
};
}
async getMatchQuery(context: Context, filter: any) {
return {
[await this.getValuePath()]: await this.getMatchQueryValue(
context,
filter
),
};
}
getTargetCollection() {
return this.app.collections[this.target_collection];
}
async getAttachments(
context: Context,
target_id_lists: string[][],
attachment_options?: AttachmentOptions<any>,
format: any = {}
) {
context.app.Logger.debug2(
"DEEP REVERSE SINGLE REFERENCE",
"getAttachments",
target_id_lists
);
const merged_ids: string[] = target_id_lists.reduce(
(a, b) => a.concat(b),
[]
);
const ret = new ItemList<any>(this.getTargetCollection(), context).ids(
merged_ids
);
if (format) {
ret.format(format);
}
if (typeof attachment_options === "object") {
ret.attach(attachment_options);
}
return ret.fetch();
}
async getValueOnChange(context: Context, item: CollectionItem) {
context.app.Logger.debug2(
"DEEP REVERSE SINGLE REFERENCE",
"get_value",
{
affected_id: item.id,
}
);
const list = await new ItemList(
this.getReferencingCollection(),
context
)
.filter({ [this.referencing_field]: item.id })
.fetch();
const ret = list.items.map((item) =>
item.get(this.intermediary_field_that_points_there)
);
context.app.Logger.debug2(
"DEEP REVERSE SINGLE REFERENCE",
"get_value",
{
affected_id: item.id,
ret,
}
);
return ret;
}
getValueFromReferencingCollection(item: CollectionItem) {
// this returns what need to be stored as one of the values in the array
// that becomes this field's value
return item.get(this.intermediary_field_that_points_there) as string;
}
}
diff --git a/src/app/base-chips/field-types/reverse-single-reference.ts b/src/app/base-chips/field-types/reverse-single-reference.ts
index 44abe546..27d80841 100644
--- a/src/app/base-chips/field-types/reverse-single-reference.ts
+++ b/src/app/base-chips/field-types/reverse-single-reference.ts
@@ -1,213 +1,217 @@
-import { Field, Context, CollectionItem } from "../../../main.js";
+import {
+ Field,
+ Context,
+ CollectionItem,
+ App,
+ Collection,
+} from "../../../main.js";
import ItemList, { AttachmentOptions } from "../../../chip-types/item-list.js";
import { CachedValue } from "./field-types.js";
import { CollectionRefreshCondition } from "../../event-description.js";
export class ListOfIDs extends Field<[]> {
typeName = "list-of-ids";
async isProperValue(context: Context) {
return context.is_super
? Field.valid()
: Field.invalid(context.app.i18n("read_only_field"));
}
}
export default class ReverseSingleReference extends CachedValue<
string[],
string[],
ListOfIDs
> {
typeName = "reverse-single-reference";
referencing_field: string;
referencing_collection: string;
constructor(params: {
referencing_field: string;
referencing_collection: string;
}) {
super(new ListOfIDs(), {
- refresh_on: [
- new CollectionRefreshCondition(
- params.referencing_collection,
- "after:create",
- async ([, item]) => {
- const ret = [
- item.get(params.referencing_field) as string,
- ];
- this.app.Logger.debug3(
- "REVERSE SINGLE REFERENCE",
- "resource_getter for after create",
- { ret, item }
- );
- return ret;
- }
- ),
- new CollectionRefreshCondition(
- params.referencing_collection,
- "after:remove",
- async ([, item]) => {
- this.app.Logger.debug3(
- "REVERSE SINGLE REFERENCE",
- "handling the after:remove event"
- );
- const search_value =
- this.getValueFromReferencingCollection(item);
- const affected = await this.app.Datastore.find(
- this.collection.name,
- {
- [await this.getValuePath()]: search_value,
- }
- );
- const ret = affected.map(
- (document: { id: string }) => document.id
- );
- this.app.Logger.debug3(
- "REVERSE SINGLE REFERENCE",
- "resource_getter for after delete",
- { ret }
- );
- return ret;
- }
- ),
- new CollectionRefreshCondition(
- params.referencing_collection,
- "after:edit",
- async ([, item]) => {
- if (
- !item.body.changed_fields.has(
- this.referencing_field
- )
- ) {
- this.app.Logger.debug3(
- "REVERSE SINGLE REFERENCE",
- `Update does not concern the ${this.name} field, skipping hook...`
- );
- return [];
- }
- this.app.Logger.debug3(
- "REVERSE SINGLE REFERENCE",
- "started resource_getter for after edit"
- );
-
- const affected_ids: string[] = Array.from(
- new Set<string>(
- [
- (await item.get(
- this.referencing_field
- )) as string,
- item.original_body.getEncoded(
- this.referencing_field
- ) as string,
- ].filter(
- (e) =>
- e /* is truthy, not null or undefined*/
- )
- ).values()
- );
- this.app.Logger.debug3(
- "REVERSE SINGLE REFERENCE",
- "resource_getter for after edit",
- { affected_ids }
- );
- return affected_ids;
- }
- ),
- ],
+ refresh_on: [],
get_value: (context: Context, item: CollectionItem) => {
return this.getValueOnChange(context, item);
},
initial_value: [],
});
this.referencing_field = params.referencing_field;
this.referencing_collection = params.referencing_collection;
}
+ async init(app: App, collection: Collection) {
+ this.refresh_on = [
+ new CollectionRefreshCondition(
+ this.referencing_collection,
+ "after:create",
+ async ([, item]) => {
+ const ret = [item.get(this.referencing_field) as string];
+ this.app.Logger.debug3(
+ "REVERSE SINGLE REFERENCE",
+ "resource_getter for after create",
+ { ret, item }
+ );
+ return ret;
+ }
+ ),
+ new CollectionRefreshCondition(
+ this.referencing_collection,
+ "after:remove",
+ async ([, item]) => {
+ this.app.Logger.debug3(
+ "REVERSE SINGLE REFERENCE",
+ "handling the after:remove event"
+ );
+ const search_value =
+ this.getValueFromReferencingCollection(item);
+ const affected = await this.app.Datastore.find(
+ this.collection.name,
+ {
+ [await this.getValuePath()]: search_value,
+ }
+ );
+ const ret = affected.map(
+ (document: { id: string }) => document.id
+ );
+ this.app.Logger.debug3(
+ "REVERSE SINGLE REFERENCE",
+ "resource_getter for after delete",
+ { ret }
+ );
+ return ret;
+ }
+ ),
+ new CollectionRefreshCondition(
+ this.referencing_collection,
+ "after:edit",
+ async ([, item]) => {
+ if (!item.body.changed_fields.has(this.referencing_field)) {
+ this.app.Logger.debug3(
+ "REVERSE SINGLE REFERENCE",
+ `Update does not concern the ${this.name} field, skipping hook...`
+ );
+ return [];
+ }
+ this.app.Logger.debug3(
+ "REVERSE SINGLE REFERENCE",
+ "started resource_getter for after edit"
+ );
+
+ const affected_ids: string[] = Array.from(
+ new Set<string>(
+ [
+ (await item.get(
+ this.referencing_field
+ )) as string,
+ item.original_body.getEncoded(
+ this.referencing_field
+ ) as string,
+ ].filter(
+ (e) => e /* is truthy, not null or undefined*/
+ )
+ ).values()
+ );
+ this.app.Logger.debug3(
+ "REVERSE SINGLE REFERENCE",
+ "resource_getter for after edit",
+ { affected_ids }
+ );
+ return affected_ids;
+ }
+ ),
+ ];
+ super.init(app, collection);
+ }
+
async getValueOnChange(context: Context, item: CollectionItem) {
context.app.Logger.debug2("REVERSE SINGLE REFERENCE", "get_value", {
affected_id: item.id,
});
const list = await new ItemList(
this.getReferencingCollection(),
context
)
.filter({ [this.referencing_field]: item.id })
.fetch();
const ret = list.items.map((item) => item.id);
context.app.Logger.debug2("REVERSE SINGLE REFERENCE", "get_value", {
affected_id: item.id,
ret,
});
return ret;
}
getReferencingCollection() {
return this.app.collections[this.referencing_collection];
}
async getMatchQueryValue(context: Context, field_filter: any) {
if (typeof field_filter !== "object") {
return {
$eq: field_filter,
};
}
context.app.Logger.debug3(
"REVERSE SINGLE REFERENCE",
"Querying items matching query:",
field_filter
);
const { items } = await this.app.collections[
this.referencing_collection
]
.list(context)
.filter(field_filter)
.fetch();
return {
$in: items.map((resource) => resource.id),
};
}
async getMatchQuery(context: Context, filter: any) {
return {
[await this.getValuePath()]: await this.getMatchQueryValue(
context,
filter
),
};
}
async getAttachments(
context: Context,
target_id_lists: string[][],
attachment_options?: AttachmentOptions<any>,
format: any = {}
) {
context.app.Logger.debug2(
"REVERSE SINGLE REFERENCE",
"getAttachments",
target_id_lists
);
const merged_ids: string[] = target_id_lists.reduce(
(a, b) => a.concat(b),
[]
);
const ret = new ItemList<any>(
this.getReferencingCollection(),
context
).ids(merged_ids);
if (format) {
ret.format(format);
}
if (typeof attachment_options === "object") {
ret.attach(attachment_options);
}
return ret.fetch();
}
getValueFromReferencingCollection(item: CollectionItem) {
// this returns what need to be stored as one of the values in the array
// that becomes this field's value
return item.id;
}
}
diff --git a/src/chip-types/collection.ts b/src/chip-types/collection.ts
index 2dfc642f..9871bac2 100644
--- a/src/chip-types/collection.ts
+++ b/src/chip-types/collection.ts
@@ -1,468 +1,468 @@
import Router from "@koa/router";
import Emittery from "emittery";
import type { ActionName } from "../action.js";
import type { App } from "../app/app.js";
import Public from "../app/policy-types/public.js";
import type Context from "../context.js";
import parseBody from "../http/parse-body.js";
import { BadContext, NotFound } from "../response/errors.js";
import type CalculatedField from "./calculated-field.js";
import CollectionItem, { ItemMetadata } from "./collection-item.js";
import CollectionItemBody from "./collection-item-body.js";
import type Field from "./field.js";
import type {
FieldsetEncoded,
FieldsetInput,
FieldsetOutput,
} from "./fieldset.js";
import ItemList from "./item-list.js";
import type Policy from "./policy.js";
import type SpecialFilter from "./special-filter.js";
export type CollectionEvent =
| "before:create"
| "after:create"
| "before:remove"
| "after:remove"
| "before:edit"
| "after:edit";
export type CollectionCallback = ([context, item, event]: [
Context,
CollectionItem,
CollectionEvent
]) => Promise<void>;
export type CollectionValidationResult = { error: string; fields: string[] }[];
export type CollectionOutput<T extends Collection> = FieldsetOutput<
T["fields"]
>;
export type CollectionInput<T extends Collection> = FieldsetInput<T["fields"]>;
export type CollectionEncoded<T extends Collection> = FieldsetEncoded<
T["fields"]
>;
export type Fieldnames<T extends Collection> = keyof CollectionInput<T> &
string;
/** Creates a collection. All collections are automatically served via
* the REST API, with permissions set by the Policies */
export default abstract class Collection {
abstract fields: Record<string, Field<any>>;
private emitter = new Emittery();
/** the name of the collection, will be used as part of the URI in
* the REST API */
name: string;
/** policies for this collection. Who can do what */
policies: Partial<{ [a in ActionName]: Policy }> = {};
/** The policy to use when deciding on an action not specified in
* `policies` */
defaultPolicy: Policy = new Public();
/** The app this collection is tied to */
app: App;
named_filters: Record<string, SpecialFilter> = {};
calculated_fields: Record<string, CalculatedField<unknown>> = {};
/** initializes the fields @internal */
async initFieldDetails(): Promise<void> {
const promises = [];
for (const [field_name, field] of Object.entries(this.fields)) {
field.setCollection(this);
- field.setName(field_name);
promises.push(field.init(this.app, this));
+ field.setName(field_name);
}
await Promise.all(promises);
}
async suCreate(data: CollectionInput<this>): Promise<CollectionItem<this>> {
return this.create(new this.app.SuperContext(), data);
}
/* the "unsafe" flavor of CRUD functions are meant for cases where you want
to just get the errror message and parse it - for example, when you want to
get a nice error that can be used to display errors in a form */
async suCreateUnsafe(
data: Record<string, unknown>
): Promise<CollectionItem<this>> {
return this.createUnsafe(new this.app.SuperContext(), data);
}
async create(
context: Context,
data: CollectionInput<this>
): Promise<CollectionItem<this>> {
return this.make(data).save(context);
}
/* the "unsafe" flavor of CRUD functions are meant for cases where you want
to just get the errror message and parse it - for example, when you want to
get a nice error that can be used to display errors in a form */
async createUnsafe(
context: Context,
data: Record<string, unknown>
): Promise<CollectionItem<this>> {
return this.make(data as Partial<CollectionInput<this>>).save(context);
}
/** Makes a new item object that can be saved later */
make(input?: Partial<CollectionInput<this>>): CollectionItem<this> {
return new CollectionItem<this>(
this,
new CollectionItemBody(this, input, {}, {})
);
}
async suGetByID(id: string): Promise<CollectionItem<this>> {
return this.getByID(new this.app.SuperContext(), id);
}
async getByID(
context: Context,
id: string,
give_descriptive_errors = false
): Promise<CollectionItem<this>> {
const policy = this.getPolicy("show");
if (!(await policy.isItemSensitive(context))) {
const checkResult = await policy.check(context);
if (!checkResult?.allowed) {
throw new BadContext(checkResult?.reason as string);
}
}
const restrictedListResult = (await context.app.Datastore.aggregate(
this.name,
[
{ $match: { id } },
...(await policy.getRestrictingQuery(context)).toPipeline(),
]
)) as Record<string, unknown>[];
if (!restrictedListResult.length) {
if (!give_descriptive_errors) {
throw new NotFound(`${this.name}: id ${id} not found`);
}
const unrestrictedListResult =
(await context.app.Datastore.aggregate(this.name, [
{ $match: { id } },
])) as Record<string, unknown>[];
if (!unrestrictedListResult.length) {
throw new NotFound(`${this.name}: id ${id} not found`);
}
const checkResult = await policy.checkerFunction(
context,
async () =>
new CollectionItem(
this,
new CollectionItemBody(
this,
{},
{},
unrestrictedListResult[0] as unknown as FieldsetEncoded<
this["fields"]
>
),
unrestrictedListResult[0]._metadata as ItemMetadata,
id
)
);
throw new BadContext(checkResult?.reason as string);
}
const ret = new CollectionItem(
this,
new CollectionItemBody(
this,
{},
{},
restrictedListResult[0] as unknown as FieldsetEncoded<
this["fields"]
>
),
restrictedListResult[0]._metadata as ItemMetadata,
id
);
await ret.decode(context);
return ret;
}
async suRemoveByID(
id: string,
wait_for_after_events = true
): Promise<void> {
await this.removeByID(
new this.app.SuperContext(),
id,
wait_for_after_events
);
}
async removeByID(
context: Context,
id: string,
wait_for_after_events = true
): Promise<void> {
const item =
this.emitter.listenerCount("before:remove") ||
this.emitter.listenerCount("after:remove")
? await this.getByID(context, id)
: null;
if (this.emitter.listenerCount("before:remove")) {
void this.emit("before:remove", [context, item]);
}
const result = await this.getPolicy("delete").check(context, () =>
this.getByID(context, id)
);
if (!result?.allowed) {
throw new BadContext(result?.reason as string);
}
await context.app.Datastore.remove(this.name, { id: id }, true);
if (this.emitter.listenerCount("after:remove")) {
const promise = this.emit("after:remove", [context, item]);
if (wait_for_after_events) {
await promise;
} else {
void promise;
}
}
}
/** Get a policy for given action, an inherited policy, or the default
* policy, if no policy is specified for this action */
getPolicy(action: ActionName): Policy {
const policy = this.policies[action];
if (policy !== undefined) {
return policy;
}
// show and list are actions that can use each others' policies.
if (action === "show" && this.policies["list"]) {
return this.policies["list"];
} else if (action === "list" && this.policies["show"]) {
return this.policies["show"];
}
return this.defaultPolicy;
}
/** Initialize all the fields and filters
* @internal
*/
async init(app: App, collection_name: string): Promise<void> {
this.name = collection_name;
this.app = app;
await this.initFieldDetails();
for (const filter of Object.values(this.named_filters)) {
filter.init(app);
}
}
/** Whether or not any of the fields' behavior depends on the
* current values of themselves or other fields
*
* @param action_name
* the action for which to check @internal
*/
isOldValueSensitive(action_name: ActionName): boolean {
for (const field_name in this.fields) {
if (this.fields[field_name].isOldValueSensitive(action_name)) {
return true;
}
}
return false;
}
/** Return a named filter from the collection
* @param filter_name the name of the filter
*/
getNamedFilter(filter_name: string): SpecialFilter {
return this.named_filters[filter_name];
}
suList(): ItemList<this> {
return this.list(new this.app.SuperContext());
}
list(context: Context): ItemList<this> {
return new ItemList<this>(this, context);
}
createFromDB(document: Record<string, unknown>): CollectionItem<this> {
const id = document?.id;
delete document.id;
delete document._id;
return new CollectionItem<this>(
this,
new CollectionItemBody(
this,
{},
{},
document as unknown as FieldsetEncoded<this["fields"]>
),
document._metadata as ItemMetadata,
id as string
);
}
on(
event_name: CollectionEvent,
cb: CollectionCallback
): Emittery.UnsubscribeFn {
return this.emitter.on(event_name, cb);
}
getRequiredFields(): Field<unknown>[] {
return Object.values(this.fields).filter((field) => field.required);
}
setPolicy(action: ActionName, policy: Policy): this {
this.policies[action] = policy;
return this;
}
getRouter(): Router {
const router = new Router();
router.get(["/", "/@:filter1", "/@:filter1/@:filter2"], async (ctx) => {
const list = this.list(ctx.$context).setParams(ctx.query);
for (const key of ["filter1", "filter2"]) {
if (ctx.params[key]) {
list.namedFilter(ctx.params[key]);
}
}
ctx.body = (
await list.fetch({ is_http_api_request: true })
).serialize();
});
router.post("/", parseBody(), async (ctx) => {
const item = this.make();
item.setMultiple(ctx.request.body);
await item.save(ctx.$context, true);
await item.decode(ctx.$context, {}, true);
ctx.body = item.serializeBody();
ctx.status = 201;
});
router.get("/:id", async (ctx) => {
const [ret] = await this.list(ctx.$context)
.ids([ctx.params.id])
.safeFormat(ctx.query.format)
.fetch();
const format = ctx.query.format;
await ret.safeLoadAttachments(
ctx.$context,
ctx.query.attachments,
typeof format == "object" && format ? format : {}
);
ctx.body = ret.serialize();
});
router.patch("/:id", parseBody(), async (ctx) => {
const item = await this.getByID(ctx.$context, ctx.params.id);
item.setMultiple(ctx.request.body);
await item.save(ctx.$context);
await item.decode(ctx.$context);
ctx.body = item.serialize();
});
router.put("/:id", parseBody(), async (ctx) => {
const item = await this.getByID(ctx.$context, ctx.params.id);
item.replace(ctx.request.body);
await item.save(ctx.$context);
await item.decode(ctx.$context);
ctx.body = item.serialize();
});
router.delete("/:id", async (ctx) => {
await (
await this.getByID(ctx.$context, ctx.params.id)
).remove(ctx.$context);
ctx.status = 204; // "No content"
});
return router;
}
clearListeners() {
this.emitter.clearListeners();
}
emit(event_name: string, event_data?: any): Promise<void> {
return this.emitter.emitSerial(event_name, event_data);
}
async validate(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
context: Context,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
new_body: CollectionItemBody<any>,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
old_body: CollectionItemBody<any>,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
action: "create" | "edit"
): Promise<CollectionValidationResult> {
// empty function, meant to be overwritten in order to implement custom validation logic
return [];
}
static getFieldnames<C extends Collection>(
collection: C
): Array<keyof FieldsetInput<C["fields"]> & string> {
return Object.keys(collection.fields) as Array<
keyof FieldsetInput<C["fields"]> & string
>;
}
async upsert<
C extends Collection,
IdentityField extends keyof CollectionInput<C>
>(
context: Context,
identify_by: IdentityField,
entries: ({ [key in IdentityField]: unknown } & CollectionInput<C>)[]
): Promise<void> {
await Promise.all(
entries.map(async (entry) => {
const {
items: [item],
} = await context.app.collections[this.name]
.list(context)
.filter({ [identify_by]: entry[identify_by] })
.paginate({ items: 1 })
.fetch();
if (item) {
let has_changes = false;
for (const key of Object.keys(
entry
) as (keyof typeof entry)[]) {
if (entry[key] != item.get(key as string)) {
has_changes = true;
} else {
delete entry[key];
}
}
if (has_changes) {
item.setMultiple(entry);
await item.save(context);
}
} else {
return context.app.collections[this.name].create(
context,
entry
);
}
})
);
}
}

File Metadata

Mime Type
text/x-diff
Expires
Tue, Feb 25, 00:41 (1 d, 6 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
610377
Default Alt Text
(47 KB)

Event Timeline