Page MenuHomeSealhub

No OneTemporary

diff --git a/src/chip-types/item-list.test.ts b/src/chip-types/item-list.test.ts
index f47b7cec..249c723c 100644
--- a/src/chip-types/item-list.test.ts
+++ b/src/chip-types/item-list.test.ts
@@ -1,213 +1,274 @@
import assert, { strictEqual, deepStrictEqual } from "assert";
import Int from "../app/base-chips/field-types/int.js";
-import { App, Collection, FieldTypes, Query } from "../main.js";
+import { App, Collection, FieldTypes, Policies, Query } from "../main.js";
import { sleep } from "../test_utils/sleep.js";
import { TestApp } from "../test_utils/test-app.js";
import { withRunningApp } from "../test_utils/with-test-app.js";
class Entries extends Collection {
fields = {
name: new FieldTypes.Text(),
};
}
class EntriesCSV extends Collection {
fields = {
name: new FieldTypes.Text(),
age: new FieldTypes.Int(),
};
}
describe("ItemList", () => {
it("allows to sort by modified_date", () =>
withRunningApp(
(test_app) =>
class extends test_app {
collections = {
...App.BaseCollections,
entries: new Entries(),
};
},
async ({ app }) => {
await app.collections.entries.suCreate({ name: "older" });
await sleep(100);
await app.collections.entries.suCreate({ name: "newer" });
const { items: desc } = await app.collections.entries
.suList()
.sort({ "_metadata.modified_at": "desc" })
.fetch();
strictEqual(desc[0]!.get("name"), "newer");
strictEqual(desc[1]!.get("name"), "older");
const { items: asc } = await app.collections.entries
.suList()
.sort({ "_metadata.modified_at": "asc" })
.fetch();
strictEqual(asc[0]!.get("name"), "older");
strictEqual(asc[1]!.get("name"), "newer");
}
));
it("properly parses params in HTTP GET", () =>
withRunningApp(
(test_app) =>
class extends test_app {
collections = {
...App.BaseCollections,
entries: new Entries(),
};
},
async ({ rest_api }) => {
// shouldn't throw
await rest_api.get(
"/api/v1/collections/entries?pagination[items]=10"
);
}
));
it("generate CSV string", () =>
withRunningApp(
(test_app) =>
class extends test_app {
collections = {
...App.BaseCollections,
entries: new EntriesCSV(),
};
},
async ({ app }) => {
await app.collections.entries.suCreate({
name: "older",
age: 15,
});
const csv = await app.collections.entries.suList().toCSV();
deepStrictEqual(csv.split("\n")[0]!.split(",").sort(), [
"age",
"name",
]);
deepStrictEqual(csv.split("\n")[1]!.split(",").sort(), [
"15",
"older",
]);
}
));
it("allows to add a custom aggregation stage", async () =>
withRunningApp(
(test_app) =>
class extends test_app {
collections = {
...App.BaseCollections,
entries: new (class extends Collection {
fields = {
number: new Int(),
};
})(),
};
},
async ({ app }) => {
await app.collections.entries.suCreate({
number: 15,
});
await app.collections.entries.suCreate({
number: 16,
});
await app.collections.entries.suCreate({
number: 17,
});
const result = (
await app.collections.entries
.suList()
.filter({ number: { ">": 15 } })
.addCustomAggregationStages(
Query.fromSingleMatch({
number: { $gt: 16 },
}).toPipeline()
)
.fetch()
).serialize();
assert.deepStrictEqual(result.items.length, 1);
assert.deepStrictEqual(result.items[0]!.number, 17);
}
));
it("should return the items in the order they were provided in the .ids() function", async () =>
withRunningApp(
(test_app) =>
class extends test_app {
collections = {
...App.BaseCollections,
entries: new (class extends Collection {
fields = {
number: new Int(),
};
})(),
};
},
async ({ app }) => {
const one = await app.collections.entries.suCreate({
number: 1,
});
const two = await app.collections.entries.suCreate({
number: 1,
});
const result1 = await app.collections.entries
.suList()
.ids([one.id, two.id])
.fetch();
assert.deepStrictEqual(
result1.items.map(({ id }) => id),
[one.id, two.id]
);
const result2 = await app.collections.entries
.suList()
.ids([two.id, one.id])
.fetch();
assert.deepStrictEqual(
result2.items.map(({ id }) => id),
[two.id, one.id]
);
}
));
it("should return empty array if all referenced items have been deleted", async () =>
withRunningApp(
(test_app) => {
const A = new (class extends Collection {
name = "A";
fields = {
reference_to_b: new FieldTypes.SingleReference("B"),
};
})();
const B = new (class extends Collection {
name = "B";
fields = { number: new FieldTypes.Int() };
})();
return class extends test_app {
collections = {
...TestApp.BaseCollections,
A,
B,
};
};
},
async ({ app }) => {
const b = await app.collections.B.suCreate({ number: 2 });
const a = await app.collections.A.suCreate({
reference_to_b: b.id,
});
await b.delete(new app.SuperContext());
const { items } = await app.collections.A.suList()
.attach({ reference_to_b: true })
.fetch();
assert.strictEqual(items.length, 1);
assert.deepStrictEqual(
items[0]!.getAttachments("reference_to_b"),
[]
);
assert.deepStrictEqual(
items[0]!.getAttachments("reference_to_b").length,
0
);
}
));
+
+ it("should use the 'show' policy when using .ids()", async () =>
+ withRunningApp(
+ (test_app) => {
+ return class extends test_app {
+ collections = {
+ ...TestApp.BaseCollections,
+ posts: new (class extends Collection {
+ name = "posts";
+ fields = {
+ title: new FieldTypes.Text(),
+ };
+ policies = {
+ list: new Policies.Noone(),
+ show: new Policies.Public(),
+ };
+ })(),
+ };
+ };
+ },
+ async ({ app }) => {
+ const post = await app.collections.posts.suCreate({
+ title: "Hello",
+ });
+ const { items } = await app.collections.posts
+ .list(new app.Context())
+ .ids([post.id])
+ .fetch();
+ assert.strictEqual(items.length, 1);
+ }
+ ));
+
+ it("should use the 'LIST' policy when using .list() without .ids()", async () =>
+ withRunningApp(
+ (test_app) => {
+ return class extends test_app {
+ collections = {
+ ...TestApp.BaseCollections,
+ posts: new (class extends Collection {
+ name = "posts";
+ fields = {
+ title: new FieldTypes.Text(),
+ };
+ policies = {
+ list: new Policies.Noone(),
+ show: new Policies.Public(),
+ };
+ })(),
+ };
+ };
+ },
+ async ({ app }) => {
+ const post = await app.collections.posts.suCreate({
+ title: "Hello",
+ });
+ const { items } = await app.collections.posts
+ .list(new app.Context())
+ .fetch();
+ assert.strictEqual(items.length, 0);
+ }
+ ));
});
diff --git a/src/chip-types/item-list.ts b/src/chip-types/item-list.ts
index 18cd79d9..31a6e4e3 100644
--- a/src/chip-types/item-list.ts
+++ b/src/chip-types/item-list.ts
@@ -1,438 +1,441 @@
import Collection from "./collection.js";
import {
BadContext,
NotFound,
BadSubjectAction,
ValidationError,
} from "../response/errors.js";
import type { QueryStage } from "../datastore/query-stage.js";
import sealious_to_mongo_sort_param from "../utils/mongo-sorts.js";
import { stringify as csvStringify } from "csv-stringify/sync";
import type { ExtractFilterParams } from "./field.js";
import type Context from "../context.js";
import Query from "../datastore/query.js";
import type CollectionItem from "./collection-item.js";
import { ItemListResult } from "./item-list-result.js";
type FilterT<T extends Collection> = Partial<{
[FieldName in keyof T["fields"]]: ExtractFilterParams<
T["fields"][FieldName]
> | null;
}>;
type PaginationParams = {
page: number;
items: number;
forward_buffer: number;
};
export type SortParams<T extends Collection> = Partial<
{
[key in keyof T["fields"]]: keyof typeof sealious_to_mongo_sort_param;
} & Partial<{
"_metadata.created_at": keyof typeof sealious_to_mongo_sort_param;
"_metadata.modified_at": keyof typeof sealious_to_mongo_sort_param;
}>
>;
type FormatParam<T extends Collection> = Partial<{
[key in keyof T["fields"]]: any;
}>;
type AllInOneParams<T extends Collection> = {
search: Parameters<ItemList<T>["search"]>[0];
sort: Parameters<ItemList<T>["sort"]>[0];
filter: Parameters<ItemList<T>["filter"]>[0];
pagination: Parameters<ItemList<T>["paginate"]>[0];
attachments: Parameters<ItemList<T>["attach"]>[0];
format: Parameters<ItemList<T>["format"]>[0];
};
/** Which fields to fetch attachments for. Can be nested, as one
* resource can point to another one and that one can also have
* attachments
*/
export type AttachmentOptions<T extends Collection> = Partial<{
[key in keyof T["fields"]]: any;
}>;
export default class ItemList<T extends Collection> {
public fields_with_attachments_fetched: string[] = [];
private _attachments_options: AttachmentOptions<T> = {};
private _filter: FilterT<T>;
private _format: FormatParam<T>;
private _ids: string[];
private _search: string;
private _sort: SortParams<T>;
private context: Context;
private collection: Collection;
private aggregation_stages: QueryStage[] = [];
private await_before_fetch: Promise<any>[] = [];
private is_paginated = false;
private is_sorted = false;
private pagination: Partial<PaginationParams> = {};
constructor(collection: T, context: Context) {
this.context = context;
this.collection = collection;
- this.await_before_fetch = [
- this.collection
- .getPolicy("list")
- .getRestrictingQuery(context)
- .then((query) => {
- const pipeline = query.toPipeline();
- return pipeline;
- })
- .then((stages) => this.aggregation_stages.push(...stages)),
- ];
}
filter(filter?: FilterT<T>): ItemList<T> {
if (this._filter) {
throw new Error("Filter already set");
}
if (!filter) {
return this;
}
this._filter = filter;
for (const [field_name, filter_value] of Object.entries(filter)) {
this.context.app.Logger.debug3(
"ITEM",
"Setting filter for field:",
{ [field_name]: filter_value }
);
if (!this.collection.fields[field_name]) {
throw new Error(
`Unknown field: '${field_name}' in '${this.collection.name}' collection`
);
}
const fieldName = this.collection.fields[field_name];
if (!fieldName) {
throw Error("collection field is missing");
}
const promise = fieldName
.getAggregationStages(this.context, filter_value)
.then((stages) => {
this.aggregation_stages.push(...stages);
this.context.app.Logger.debug3(
"ITEM",
"Adding aggregation stage for field",
JSON.stringify({ [field_name]: stages })
);
});
this.await_before_fetch.push(promise);
}
return this;
}
validateFormatParam(format: unknown): FormatParam<T> {
if (format === undefined) {
return {};
}
if (typeof format !== "object") {
throw new ValidationError("Format should be a proper object");
}
for (const key in format) {
if (!(key in this.collection.fields)) {
throw new ValidationError(
`Invalid field name in filter: ${key}`
);
}
}
return format as FormatParam<T>;
}
// this method should only be used when dealing with user input. Otherwise use the `format` method, as it's type safe and any issues should arise during the build process
safeFormat(format: unknown): this {
this.validateFormatParam(format);
return this.format(format as FormatParam<T>);
}
format(format?: FormatParam<T>): this {
if (this._format) {
throw new Error("Already formatted!");
}
if (format) {
this._format = format;
}
return this;
}
static parsePaginationParams(
params: Partial<PaginationParams>
): Partial<PaginationParams> {
return Object.fromEntries(
Object.entries(params).map(([key, value]) => [
key,
typeof value === "string" ? parseInt(value) : value,
])
);
}
paginate(pagination_params?: Partial<PaginationParams>): ItemList<T> {
if (pagination_params) {
this.pagination = ItemList.parsePaginationParams(pagination_params);
this.is_paginated = true;
}
return this;
}
search(term?: string): ItemList<T> {
if (!term) {
return this;
}
if (this._search) {
throw new Error("Search term already set");
}
this.aggregation_stages.push({
$match: {
$text: {
$search: term.toString(),
$caseSensitive: false,
$diacriticSensitive: false,
},
},
});
return this;
}
ids(ids: string[]): ItemList<T> {
if (this._ids) {
throw new Error("ids already filtered");
}
this.aggregation_stages.push(
...Query.fromSingleMatch({
id: { $in: ids },
}).toPipeline()
);
this._ids = ids;
return this;
}
namedFilter(filter_name: string): ItemList<T> {
const filterName = this.collection.named_filters[filter_name];
if (!filterName) {
throw Error("collection filter is missing");
}
this.await_before_fetch.push(
filterName.getFilteringQuery().then((query) => {
this.aggregation_stages.push(...query.toPipeline());
})
);
return this;
}
attach(attachment_options?: AttachmentOptions<T>): ItemList<T> {
if (attachment_options === undefined || !attachment_options) {
return this;
}
this.context.app.Logger.debug3(
"ITEM LIST",
"Attaching fields:",
attachment_options
);
//can be called multiple times
for (const [field_name] of Object.entries(attachment_options)) {
if (!this.collection.fields[field_name]) {
field_name;
throw new NotFound(
`Given field ${field_name} is not declared in collection!`
);
}
this.fields_with_attachments_fetched.push(
field_name as unknown as keyof T["fields"] & string
);
}
this._attachments_options = attachment_options;
return this;
}
private async fetchAttachments(items: CollectionItem<T>[]) {
const promises: Promise<any>[] = [];
let attachments: { [id: string]: CollectionItem<T> } = {};
for (const field_name of this.fields_with_attachments_fetched) {
const collection = this.collection;
this.context.app.Logger.debug2(
"ATTACH",
`Loading attachments for ${field_name}`
);
const field = collection.fields[field_name];
if (!field) {
throw Error("collection field is missing");
}
promises.push(
field
.getAttachments(
this.context,
items.map(
(item) => item.get(field_name as any) as unknown
),
this._attachments_options[
field_name as keyof T["fields"]
],
this._format?.[field_name]
)
.then((attachmentsList) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
attachments = {
...attachments,
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
...attachmentsList.flattenWithAttachments(),
};
this.context.app.Logger.debug3(
"ATTACH",
`Fetched attachments for ${field_name}:`,
attachmentsList
);
})
);
}
await Promise.all(promises);
return attachments;
}
+ async addPolicyStagesToPipeline() {
+ const method = this._ids?.length > 0 ? "show" : "list";
+ const stages = await this.collection
+ .getPolicy(method)
+ .getRestrictingQuery(this.context)
+ .then((query) => {
+ const pipeline = query.toPipeline();
+ return pipeline;
+ });
+ this.aggregation_stages.push(...stages);
+ }
+
/**
* execute crated database request
*/
async fetch(
{ is_http_api_request } = { is_http_api_request: false }
): Promise<ItemListResult<T>> {
+ await this.addPolicyStagesToPipeline();
const result = await this.collection
.getPolicy("show")
.check(this.context);
if (result !== null && !result.allowed) {
throw new BadContext(result.reason);
}
const aggregation_stages = await this.getAggregationStages();
const documents = await this.context.app.Datastore.aggregate(
this.collection.name,
aggregation_stages,
{}
);
const item_promises: Promise<CollectionItem<T>>[] = [];
for (const document of documents) {
const item = this.collection.createFromDB(document);
item_promises.push(
item.decode(
this.context,
this._format,
is_http_api_request
) as Promise<CollectionItem<T>>
);
}
let items = await Promise.all(item_promises);
if (this._ids) {
items = items.sort((a, b) =>
this._ids.indexOf(a.id) < this._ids.indexOf(b.id) ? -1 : 1
);
}
const attachments = await this.fetchAttachments(items);
return new ItemListResult(
items,
this.fields_with_attachments_fetched,
attachments
);
}
async toCSV(): Promise<string> {
const result = await this.fetch();
const rows = [Collection.getFieldnames(this.collection)];
for (const item of result.items) {
const row = [];
if (!rows[0]) {
throw Error("collection filed is empty");
}
for (const field of rows[0]) {
row.push(String(item.get(field as any)));
}
rows.push(row);
}
return csvStringify(rows);
}
public async getAggregationStages() {
await Promise.all(this.await_before_fetch);
this.await_before_fetch = [];
return [
...this.aggregation_stages,
...this.getSortingStages(),
...this.getPaginationStages(),
];
}
public sort(sort_params?: SortParams<T>) {
if (!sort_params) {
return this;
}
this.is_sorted = true;
this._sort = sort_params;
return this;
}
public setParams(params: Partial<AllInOneParams<T>>) {
return this.filter(params.filter)
.paginate(params.pagination)
.sort(params?.sort)
.search(params?.search)
.attach(params?.attachments)
.format(params?.format);
}
private getSortingStages() {
if (!this.is_sorted) {
return [];
}
const $sort: { [field_name: string]: -1 | 1 } = {};
for (const [field_name, sort_value] of Object.entries(this._sort)) {
if (sort_value === undefined) {
continue;
}
const mongo_sort_param = sealious_to_mongo_sort_param[sort_value];
if (!mongo_sort_param) {
const available_sort_keys = Object.keys(
sealious_to_mongo_sort_param
).join(", ");
throw new BadSubjectAction(
`Unknown sort key: ${JSON.stringify(
this._sort[field_name]
)}. Available sort keys are: ${available_sort_keys}.`
);
}
$sort[field_name] = mongo_sort_param as -1 | 1;
}
return [{ $sort }];
}
private getPaginationStages(): QueryStage[] {
if (!this.is_paginated) {
return [];
}
const full_pagination_params: PaginationParams = {
page: 1,
items: 10,
forward_buffer: 0,
...this.pagination,
};
const $skip =
(full_pagination_params.page - 1) * full_pagination_params.items;
const $limit =
full_pagination_params.items +
full_pagination_params.forward_buffer || 0;
// prettier-ignore
return [ {$skip}, {$limit} ];
}
addCustomAggregationStages(stages: QueryStage[]) {
this.aggregation_stages.push(...stages);
return this;
}
}

File Metadata

Mime Type
text/x-diff
Expires
Sat, Nov 8, 05:12 (22 h, 36 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1034047
Default Alt Text
(19 KB)

Event Timeline