Page MenuHomeSealhub

No OneTemporary

diff --git a/src/app/base-chips/field-types/array-actions/array-action.ts b/src/app/base-chips/field-types/array-actions/array-action.ts
deleted file mode 100644
index efdeb859..00000000
--- a/src/app/base-chips/field-types/array-actions/array-action.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import {
- hasShape,
- type Predicate,
- type Shape,
- type ShapeToType,
-} from "@sealcode/ts-predicates";
-import type Context from "../../../../context.js";
-
-export abstract class ArrayAction<
- InputShape extends Record<string, unknown>,
- ParsedShape extends Record<string, unknown>
-> {
- constructor(
- public element_predicate: Predicate,
- public element_validator: (
- context: Context,
- element: unknown,
- index: number
- ) => Promise<{
- valid: boolean;
- reason: string;
- }>
- ) {}
-
- abstract InputShape: Shape;
-
- async validate<T>(
- context: Context,
- action: unknown,
- array: T[]
- ): Promise<{ valid: boolean; reason: string }> {
- if (!hasShape(this.InputShape, action)) {
- return { valid: false, reason: "Wrong action shape" };
- }
- return this._validate(context, action, array);
- }
-
- async _validate<T>(
- context: Context,
- action: ShapeToType<this["InputShape"]>,
- array: T[]
- ): Promise<{ valid: boolean; reason: string }> {
- return { valid: true, reason: "Correct shape" };
- }
-
- abstract _parse<T>(
- context: Context,
- input: InputShape,
- array: T[],
- empty_element: T
- ): Promise<ParsedShape | null>;
-
- abstract run<T>(
- context: Context,
- action: ParsedShape,
- array: T[],
- empty_element: T
- ): Promise<T[]>;
-
- async parse<T>(
- context: Context,
- input: unknown,
- array: T[],
- empty_element: T
- ) {
- const validate_result = await this.validate(context, input, array);
- if (!validate_result.valid) {
- return null;
- }
- return this._parse(context, input as InputShape, array, empty_element);
- }
-}
diff --git a/src/app/base-chips/field-types/array-actions/insert.ts b/src/app/base-chips/field-types/array-actions/insert.ts
deleted file mode 100644
index 4654cf09..00000000
--- a/src/app/base-chips/field-types/array-actions/insert.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-import { predicates, type ShapeToType } from "@sealcode/ts-predicates";
-import type Context from "../../../../context.js";
-import { ArrayAction } from "./array-action.js";
-
-const InsertInputShape = {
- insert: predicates.shape({
- index: predicates.maybe(
- predicates.or(predicates.number, predicates.string)
- ),
- value: predicates.maybe(predicates.any),
- }),
-};
-const InsertParsedShape = {
- insert: predicates.shape({
- index: predicates.number,
- value: predicates.any,
- }),
-};
-
-export class Insert extends ArrayAction<
- ShapeToType<typeof InsertInputShape>,
- ShapeToType<typeof InsertParsedShape>
-> {
- InputShape = InsertInputShape;
- async _validate<T>(
- context: Context,
- action: ShapeToType<typeof InsertInputShape>,
- array: T[]
- ): Promise<{ valid: boolean; reason: string }> {
- const validation_result = await this.element_validator(
- context,
- action.insert.value || {},
- action.insert.index == undefined
- ? array.length
- : parseInt(action.insert.index.toString())
- );
- if (!validation_result.valid) {
- return {
- valid: false,
- reason: validation_result.reason,
- };
- }
- return { valid: true, reason: "Insert action shape ok" };
- }
-
- async _parse<T>(
- _context: Context,
- input: ShapeToType<typeof InsertInputShape>,
- array: T[],
- emptyValue: T
- ): Promise<ShapeToType<typeof InsertParsedShape> | null> {
- return {
- insert: {
- index: parseInt(
- (input.insert.index || array.length).toString()
- ),
- value: input.insert.value || emptyValue,
- },
- };
- }
- async run<T>(
- _context: Context,
- action: ShapeToType<typeof InsertParsedShape>,
- array: T[],
- emptyElement: T
- ) {
- const n = parseInt(
- (action.insert.index === undefined
- ? array.length
- : action.insert.index
- ).toString()
- );
- array = [
- ...array.slice(0, n),
- action.insert.value === undefined
- ? emptyElement
- : action.insert.value,
- ...array.slice(n),
- ];
- return array;
- }
-}
diff --git a/src/app/base-chips/field-types/array-actions/remove.ts b/src/app/base-chips/field-types/array-actions/remove.ts
deleted file mode 100644
index 1e24d973..00000000
--- a/src/app/base-chips/field-types/array-actions/remove.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { predicates, type ShapeToType } from "@sealcode/ts-predicates";
-import type Context from "../../../../context.js";
-import { ArrayAction } from "./array-action.js";
-
-const RemoveInputShape = {
- remove: predicates.or(predicates.string, predicates.number),
-};
-const RemoveParsedShape = { remove: predicates.number };
-export class Remove extends ArrayAction<
- ShapeToType<typeof RemoveInputShape>,
- ShapeToType<typeof RemoveParsedShape>
-> {
- InputShape = RemoveInputShape;
- async _parse(
- _context: Context,
- input: ShapeToType<typeof RemoveInputShape>
- ): Promise<ShapeToType<typeof RemoveParsedShape> | null> {
- return { remove: parseInt(input.remove.toString()) };
- }
- async run<T>(
- _context: Context,
- action: ShapeToType<typeof RemoveParsedShape>,
- array: T[]
- ) {
- array = array.filter((_, i) => {
- return i !== action.remove;
- });
- return array;
- }
-}
diff --git a/src/app/base-chips/field-types/array-actions/replace.ts b/src/app/base-chips/field-types/array-actions/replace.ts
deleted file mode 100644
index 6d20fc6a..00000000
--- a/src/app/base-chips/field-types/array-actions/replace.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import type { ShapeToType } from "@sealcode/ts-predicates";
-import type Context from "../../../../context.js";
-import { ArrayAction } from "./array-action.js";
-
-const ReplaceInputShape = {};
-const ReplaceParsedShape = {};
-export class Replace extends ArrayAction<
- ShapeToType<typeof ReplaceInputShape>,
- ShapeToType<typeof ReplaceParsedShape>
-> {
- InputShape = ReplaceInputShape;
- async _parse(
- _context: Context
- ): Promise<ShapeToType<typeof ReplaceParsedShape> | null> {
- return {};
- }
- async run<T>(
- _context: Context,
- action: ShapeToType<typeof ReplaceParsedShape>,
- array: T[]
- ) {
- return array;
- }
-}
diff --git a/src/app/base-chips/field-types/array-actions/swap.ts b/src/app/base-chips/field-types/array-actions/swap.ts
deleted file mode 100644
index 8601f461..00000000
--- a/src/app/base-chips/field-types/array-actions/swap.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import { predicates, type ShapeToType } from "@sealcode/ts-predicates";
-import type Context from "../../../../context.js";
-import { ArrayAction } from "./array-action.js";
-
-const SwapInputShape = {
- swap: predicates.array(predicates.or(predicates.string, predicates.number)),
-};
-const SwapParsedShape = { swap: predicates.array(predicates.number) };
-export class Swap extends ArrayAction<
- ShapeToType<typeof SwapInputShape>,
- ShapeToType<typeof SwapParsedShape>
-> {
- InputShape = SwapInputShape;
- async _validate<T>(
- _context: Context,
- action: ShapeToType<typeof SwapInputShape>,
- array: T[]
- ): Promise<{ valid: boolean; reason: string }> {
- if (action.swap.length != 2) {
- return {
- valid: false,
- reason: "swap action parameter should be a list of two numbers",
- };
- }
- if (
- action.swap.some((index) => {
- index = parseInt(index.toString());
- return index >= array.length || index < 0;
- })
- ) {
- return {
- valid: false,
- reason: "swap action parameter out of range",
- };
- }
- return {
- valid: true,
- reason: "swap action parameters ok",
- };
- }
-
- async _parse(
- _context: Context,
- input: ShapeToType<typeof SwapInputShape>
- ): Promise<ShapeToType<typeof SwapParsedShape> | null> {
- if (
- typeof input.swap[0] === "number" &&
- typeof input.swap[1] === "number"
- ) {
- return {
- swap: [
- parseInt(input.swap[0].toString()),
- parseInt(input.swap[1].toString()),
- ],
- };
- } else {
- return null;
- }
- }
- async run<T>(
- _context: Context,
- action: ShapeToType<typeof SwapParsedShape>,
- array: T[]
- ) {
- if (
- typeof action.swap[0] === "number" &&
- typeof action.swap[1] === "number"
- ) {
- const temp = array[action.swap[0]];
- array[action.swap[0]] = array[action.swap[1]] as T;
- array[action.swap[1]] = temp as T;
- return array;
- } else {
- return array;
- }
- }
-}
diff --git a/src/app/base-chips/field-types/array-storage.remarkup b/src/app/base-chips/field-types/array-storage.remarkup
index 13505b7d..aff76aa4 100644
--- a/src/app/base-chips/field-types/array-storage.remarkup
+++ b/src/app/base-chips/field-types/array-storage.remarkup
@@ -1,180 +1,83 @@
# ArrayStorage
`ArrayStorage` is an abstract field type that other Array-related field types
can extend. Fields that use `ArrayStorage` include:
- `EnumMultiple`
- `StructuredArray`
Such fields have additional abilities built-in, that are not documented separately.
## Filtering
You can filter the array by individual item values. Let's assume you have the
following items stored in the database:
```
lang=ts
await app.collections.cakes.suCreate({
ingredients: ["flour", "water", "carrot"],
});
await app.collections.cakes.suCreate({
ingredients: ["carrot", "water", "flour"],
});
await app.collections.cakes.suCreate({
ingredients: ["flour", "water", "eggs"],
});
await app.collections.cakes.suCreate({
ingredients: ["flour", "salt"],
});
```
Now, if you filter the `cakes` collection by a single value in the `ingredients`
field, it will return all items that contain that particular ingredient:
```
lang=ts
const { items: watery } = await app.collections.cakes
.suList()
.filter({ ingredients: "water" })
.fetch();
assert.strictEqual(watery.length, 3);
```
You can also filter by multiple ingredients. To do so, you provide a single
object as a filter. That object has to have one key (`all`, `exact`, `any`) with an
array value.
```
lang=ts
const { items: carrot_nonreverse } = await app.collections.cakes
.suList()
.filter({
ingredients: {
exact: ["flour", "water", "carrot"],
},
})
.fetch();
assert.strictEqual(carrot_nonreverse.length, 1);
const { items: carrot_any_direction } =
await app.collections.cakes
.suList()
.filter({
ingredients: {
all: ["flour", "water", "carrot"],
},
})
.fetch();
assert.strictEqual(carrot_any_direction.length, 2);
const { items: eggs_or_salt } = await app.collections.cakes
g .suList()
.filter({
ingredients: {
any: ["eggs", "salt"],
},
})
.fetch();
assert.strictEqual(eggs_or_salt.length, 2);
```
-## Modifying the array
-
-You can modify fields that use ArrayStorage in two ways - either by providing an
-entire new value for the whole array, or by passing an action as the new
-value. If you pass the action, it changes the old value of the array.
-
-### Add an element to the array
-
-```
-lang=ts
-const invoice = await app.collections.invoices.suCreate({
- entries: [
- { title: "pen", price: 1.1 },
- { title: "apple", price: 2.2 },
- ],
-});
-invoice.set("entries", {
- insert: {
- value: { title: "pineapple", price: 3.3 },
- index: 1,
- },
-});
-await invoice.save(new app.SuperContext());
-assert.deepStrictEqual(invoice.get("entries"), [
- { title: "pen", price: 1.1 },
- { title: "pineapple", price: 3.3 },
- { title: "apple", price: 2.2 },
-]);
-```
-
-### Delete an element of the array
-
-```
-lang=ts
-const invoice = await app.collections.invoices.suCreate({
- entries: [
- { title: "pen", price: 1.1 },
- { title: "apple", price: 2.2 },
- { title: "pineapple", price: 3.3 },
- ],
-});
-invoice.set("entries", { remove: 0 });
-await invoice.save(new app.SuperContext());
-assert.deepStrictEqual(invoice.get("entries"), [
- { title: "apple", price: 2.2 },
- { title: "pineapple", price: 3.3 },
-]);
-```
-
-### Swap elements of the array
-
-```
-const invoice = await app.collections.invoices.suCreate({
- entries: [
- { title: "pen", price: 1.1 },
- { title: "apple", price: 2.2 },
- { title: "pineapple", price: 3.3 },
- ],
-});
-invoice.set("entries", { swap: [0, 1] });
-await invoice.save(new app.SuperContext());
-assert.deepStrictEqual(invoice.get("entries"), [
- { title: "apple", price: 2.2 },
- { title: "pen", price: 1.1 },
- { title: "pineapple", price: 3.3 },
-]);
-```
-
-### Combine atomic actions with other edits in the array
-
-You can submit a new version of the array and then run the action on that new
-version, in one step. This is useful if you want to for example have a form where the user can edit some entries within the array and then submit an action that edits the array, before the other edits are saved. This way the action will be performed on the newest version of the user's input:
-
-```
-lang=ts
-const invoice = await app.collections.invoices.suCreate({
- entries: [
- { title: "pen", price: 1.1 },
- { title: "apple", price: 2.2 },
- ],
-});
-invoice.set("entries", {
- insert: {
- value: { title: "pineapple", price: 3.3 },
- index: 1,
- },
- data: [
- { title: "Pen", price: 100 },
- { title: "Apple", price: 200 },
- ],
-});
-await invoice.save(new app.SuperContext());
-assert.deepStrictEqual(invoice.get("entries"), [
- { title: "Pen", price: 100 },
- { title: "pineapple", price: 3.3 },
- { title: "Apple", price: 200 },
-]);
-```
+
\ No newline at end of file
diff --git a/src/app/base-chips/field-types/array-storage.ts b/src/app/base-chips/field-types/array-storage.ts
index 1fd2165b..dbef9c45 100644
--- a/src/app/base-chips/field-types/array-storage.ts
+++ b/src/app/base-chips/field-types/array-storage.ts
@@ -1,227 +1,162 @@
import {
hasFieldOfType,
is,
type Predicate,
predicates,
} from "@sealcode/ts-predicates";
import type { ActionName } from "../../../action.js";
import { Field, Context, type ValidationResult } from "../../../main.js";
-import { Insert } from "./array-actions/insert.js";
-import { Remove } from "./array-actions/remove.js";
-import { Replace } from "./array-actions/replace.js";
-import { Swap } from "./array-actions/swap.js";
-export type ArrayStorageAction<ContentType> = (
- | { remove: number }
- | { swap: [number, number] }
- | {
- insert: { value?: ContentType; index?: number | string };
- }
- | {}
-) & { data?: ContentType[] };
-
-export type ArrayStorageInput<ContentType> =
- | ContentType[]
- | ArrayStorageAction<ContentType>;
+export type ArrayStorageInput<ContentType> = ContentType[];
export abstract class ArrayStorage<
- T extends string | number | Record<string, unknown>
+ T extends string | number | Record<string, unknown>,
> extends Field<T[], ArrayStorageInput<T>> {
constructor(public value_predicate: Predicate) {
super();
}
abstract getEmptyElement(context: Context): Promise<T>;
isOldValueSensitive(_: ActionName): boolean {
return true;
}
async isProperElement(
_context: Context,
element: unknown,
_index: number
): Promise<{ valid: boolean; reason: string }> {
if (is(element, this.value_predicate)) {
return { valid: true, reason: "Matches predicate" };
} else {
return { valid: false, reason: "Doesn't match predicate" };
}
}
async validateElements(context: Context, elements: unknown[]) {
const results = await Promise.all(
elements.map((value, index) =>
this.isProperElement(context, value, index)
)
);
if (results.some((result) => !result.valid)) {
return {
valid: false,
reason: `Didn't pass validation: ${results
.filter((result) => !result.valid)
.map((result) => result.reason)
.join(", ")}`,
};
}
return { valid: true, reason: "elements valid" };
}
async isProperValue(
context: Context,
new_value: unknown,
old_value: T[] | undefined,
_new_value_blessing_token: symbol | null
): Promise<ValidationResult> {
if (is(new_value, predicates.object) && !Array.isArray(new_value)) {
if (old_value === undefined) {
return {
valid: false,
reason: "The value is an array action description, but this array field does not yet have a value",
};
}
- let found_matching_action = false;
- for (const Action of [Remove, Swap, Insert, Replace]) {
- const action = new Action(
- this.value_predicate,
- this.isProperElement.bind(this)
- );
- const result = await action.validate(
- context,
- new_value,
- old_value || []
- );
- if (result.valid) {
- found_matching_action = true;
- break;
- }
- }
- if (!found_matching_action) {
- return {
- valid: false,
- reason: `No action matches the description: ${JSON.stringify(
- new_value
- )}`,
- };
- }
if (hasFieldOfType(new_value, "data", predicates.any)) {
if (!is(new_value.data, predicates.array(predicates.object))) {
return {
valid: false,
reason: ".data should be an array of objects",
};
}
const result = await this.validateElements(
context,
new_value.data
);
if (!result.valid) {
return result;
}
}
return {
valid: true,
reason: "The value is an array action description",
};
}
if (!is(new_value, predicates.array(this.value_predicate))) {
return {
valid: false,
reason: `${new_value} is not an array of objects`,
};
}
const result = await this.validateElements(context, new_value);
if (!result.valid) {
return result;
}
return { valid: true, reason: `Proper form` };
}
async encode(
- context: Context,
+ _context: Context,
value: ArrayStorageInput<T> | null,
- old_value: T[]
+ _old_value: T[]
): Promise<T[]> {
if (value === null) {
return [];
}
- if (!Array.isArray(value)) {
- const value_to_modify = value.data ? value.data : old_value;
- let result = value_to_modify;
- const empty_element = await this.getEmptyElement(context);
- for (const Action of [Remove, Swap, Insert, Replace]) {
- const action = new Action(
- this.value_predicate,
- this.isProperElement.bind(this)
- );
- const parsed_action = await action.parse(
- context,
- value,
- result,
- empty_element
- );
- if (parsed_action) {
- result = await action.run(
- context,
- parsed_action as any,
- result,
- empty_element
- );
- }
- }
- return result;
- }
return value;
}
async getMatchQuery(
_context: Context,
filter:
| T
| {
exact: T[]; // in order, no other extra elements
}
| {
all: T[]; // out of order, may contain other elements
}
| {
any: T[]; // includes at least one of those elements
}
) {
const value_path = await this.getValuePath();
if (!is(filter, predicates.object)) {
return { [value_path]: filter };
} else if (
hasFieldOfType(
filter,
"exact",
predicates.array(this.value_predicate)
)
) {
return { [value_path]: filter.exact };
} else if (
hasFieldOfType(
filter,
"all",
predicates.array(this.value_predicate)
)
) {
if (filter.all.length == 0) {
return {};
} else {
return { [value_path]: { $all: filter.all } };
}
} else if (
hasFieldOfType(
filter,
"any",
predicates.array(this.value_predicate)
)
) {
return {
$or: filter.any.map((value) => ({ [value_path]: value })),
};
}
}
}
diff --git a/src/app/base-chips/field-types/structured-array.test.ts b/src/app/base-chips/field-types/structured-array.test.ts
index 07472d47..464d486f 100644
--- a/src/app/base-chips/field-types/structured-array.test.ts
+++ b/src/app/base-chips/field-types/structured-array.test.ts
@@ -1,388 +1,145 @@
import assert from "assert";
import Collection from "../../../chip-types/collection.js";
import { FieldTypes } from "../../../main.js";
import { assertThrowsAsync } from "../../../test_utils/test-utils.js";
import { withRunningApp } from "../../../test_utils/with-test-app.js";
import { App } from "../../app.js";
import { StructuredArray } from "./structured-array.js";
describe("structured-array", () => {
it("accepts a simple valid value and rejects an invalid one", async () =>
withRunningApp(
(testapp) =>
class extends testapp {
collections = {
...App.BaseCollections,
invoices: new (class extends Collection {
fields = {
entries: new StructuredArray({
title: new FieldTypes.Text(),
price: new FieldTypes.Float(),
}),
};
})(),
};
},
async ({ app }) => {
await app.collections.invoices.suCreate({
entries: [
{ title: "pen", price: 1.2 },
{ title: "apple", price: 2.2 },
],
});
assertThrowsAsync(() =>
app.collections.invoices.suCreate({
// @ts-ignore
entries: [{ title: [], price: 1.2 }],
})
);
}
));
- it("handles entry removal properly", async () =>
- withRunningApp(
- (testapp) =>
- class extends testapp {
- collections = {
- ...App.BaseCollections,
- invoices: new (class extends Collection {
- fields = {
- entries: new StructuredArray({
- title: new FieldTypes.Text(),
- price: new FieldTypes.Float(),
- }),
- };
- })(),
- };
- },
- async ({ app }) => {
- const invoice = await app.collections.invoices.suCreate({
- entries: [
- { title: "pen", price: 1.1 },
- { title: "apple", price: 2.2 },
- { title: "pineapple", price: 3.3 },
- ],
- });
- invoice.set("entries", { remove: 0 });
- await invoice.save(new app.SuperContext());
- assert.deepStrictEqual(invoice.get("entries"), [
- { title: "apple", price: 2.2 },
- { title: "pineapple", price: 3.3 },
- ]);
-
- const same_invoice = await app.collections.invoices.suGetByID(
- invoice.id
- );
- same_invoice.set("entries", { remove: 1 });
- await same_invoice.save(new app.SuperContext());
- assert.deepStrictEqual(same_invoice.get("entries"), [
- { title: "apple", price: 2.2 },
- ]);
- }
- ));
-
- it("handles entry swap properly", async () =>
- withRunningApp(
- (testapp) =>
- class extends testapp {
- collections = {
- ...App.BaseCollections,
- invoices: new (class extends Collection {
- fields = {
- entries: new StructuredArray({
- title: new FieldTypes.Text(),
- price: new FieldTypes.Float(),
- }),
- };
- })(),
- };
- },
- async ({ app }) => {
- const invoice = await app.collections.invoices.suCreate({
- entries: [
- { title: "pen", price: 1.1 },
- { title: "apple", price: 2.2 },
- { title: "pineapple", price: 3.3 },
- ],
- });
- invoice.set("entries", { swap: [0, 1] });
- await invoice.save(new app.SuperContext());
- assert.deepStrictEqual(invoice.get("entries"), [
- { title: "apple", price: 2.2 },
- { title: "pen", price: 1.1 },
- { title: "pineapple", price: 3.3 },
- ]);
- }
- ));
-
- it("handles insert action properly", async () =>
- withRunningApp(
- (testapp) =>
- class extends testapp {
- collections = {
- ...App.BaseCollections,
- invoices: new (class extends Collection {
- fields = {
- entries: new StructuredArray({
- title: new FieldTypes.Text(),
- price: new FieldTypes.Float(),
- }),
- };
- })(),
- };
- },
- async ({ app }) => {
- const invoice = await app.collections.invoices.suCreate({
- entries: [
- { title: "pen", price: 1.1 },
- { title: "apple", price: 2.2 },
- ],
- });
- invoice.set("entries", {
- insert: {
- value: { title: "pineapple", price: 3.3 },
- index: 1,
- },
- });
- await invoice.save(new app.SuperContext());
- assert.deepStrictEqual(invoice.get("entries"), [
- { title: "pen", price: 1.1 },
- { title: "pineapple", price: 3.3 },
- { title: "apple", price: 2.2 },
- ]);
-
- invoice.set("entries", {
- insert: {
- value: { title: "last", price: 4.4 },
- },
- });
- await invoice.save(new app.SuperContext());
- assert.deepStrictEqual(invoice.get("entries"), [
- { title: "pen", price: 1.1 },
- { title: "pineapple", price: 3.3 },
- { title: "apple", price: 2.2 },
- { title: "last", price: 4.4 },
- ]);
- }
- ));
-
- it("handles actions with data property preset", async () =>
- // this is helpful when handling form input, some fields are changed but
- // not yet saved and then the users pressed "remove this row"
- withRunningApp(
- (testapp) =>
- class extends testapp {
- collections = {
- ...App.BaseCollections,
- invoices: new (class extends Collection {
- fields = {
- entries: new StructuredArray({
- title: new FieldTypes.Text(),
- price: new FieldTypes.Float(),
- }),
- };
- })(),
- };
- },
- async ({ app }) => {
- const invoice = await app.collections.invoices.suCreate({
- entries: [
- { title: "pen", price: 1.1 },
- { title: "apple", price: 2.2 },
- ],
- });
- invoice.set("entries", {
- insert: {
- value: { title: "pineapple", price: 3.3 },
- index: 1,
- },
- data: [
- { title: "Pen", price: 100 },
- { title: "Apple", price: 200 },
- ],
- });
- await invoice.save(new app.SuperContext());
- assert.deepStrictEqual(invoice.get("entries"), [
- { title: "Pen", price: 100 },
- { title: "pineapple", price: 3.3 },
- { title: "Apple", price: 200 },
- ]);
- }
- ));
-
- it("just updates the array if the action is empty", async () =>
- withRunningApp(
- (testapp) =>
- class extends testapp {
- collections = {
- ...App.BaseCollections,
- invoices: new (class extends Collection {
- fields = {
- entries: new StructuredArray({
- title: new FieldTypes.Text(),
- price: new FieldTypes.Float(),
- }),
- };
- })(),
- };
- },
- async ({ app }) => {
- const invoice = await app.collections.invoices.suCreate({
- entries: [
- { title: "pen", price: 1.1 },
- { title: "apple", price: 2.2 },
- ],
- });
- invoice.set("entries", {
- data: [
- { title: "Pen", price: 100 },
- { title: "Apple", price: 200 },
- ],
- });
- await invoice.save(new app.SuperContext());
- assert.deepStrictEqual(invoice.get("entries"), [
- { title: "Pen", price: 100 },
- { title: "Apple", price: 200 },
- ]);
- }
- ));
-
- it("Handles insert action where indexes are strings and array is currently empty", async () =>
- withRunningApp(
- (testapp) =>
- class extends testapp {
- collections = {
- ...App.BaseCollections,
- invoices: new (class extends Collection {
- fields = {
- entries: new StructuredArray({
- title: new FieldTypes.Text(),
- price: new FieldTypes.Float(),
- }),
- };
- })(),
- };
- },
- async ({ app }) => {
- const invoice = await app.collections.invoices.suCreate({
- entries: [],
- });
- invoice.set("entries", {
- insert: {
- index: "0",
- },
- });
- await invoice.save(new app.SuperContext());
- assert.deepStrictEqual(invoice.get("entries"), [{}]);
- }
- ));
-
it("reports the output type as an array", async () =>
withRunningApp(
(testapp) =>
class extends testapp {
collections = {
...App.BaseCollections,
invoices: new (class extends Collection {
fields = {
entries: new StructuredArray({
title: new FieldTypes.Text(),
price: new FieldTypes.Float(),
}),
};
})(),
};
},
async ({ app }) => {
const invoice = await app.collections.invoices.suCreate({
- entries: [],
- });
- invoice.set("entries", {
- insert: {
- index: "0",
- },
+ entries: [{ title: "Hello", price: 999 }],
});
await invoice.save(new app.SuperContext());
// this would throw a TS error if the types were wrong:
invoice.get("entries")?.[0];
}
));
it("Init subfields properly so it works with single-reference as a subfield", async () =>
withRunningApp(
(testapp) =>
class extends testapp {
collections = {
...App.BaseCollections,
products: new (class extends Collection {
fields = {
name: new FieldTypes.Text(),
};
})(),
invoices: new (class extends Collection {
fields = {
entries: new StructuredArray({
product: new FieldTypes.SingleReference(
"products"
),
price: new FieldTypes.Float(),
}),
};
})(),
};
},
async ({ app }) => {
const pen = await app.collections.products.suCreate({
name: "pen",
});
await app.collections.invoices.suCreate({
entries: [{ product: pen.id, price: 1.2 }],
});
}
));
it("should support attachments", async () =>
withRunningApp(
(testapp) =>
class extends testapp {
collections = {
...App.BaseCollections,
products: new (class extends Collection {
fields = {
name: new FieldTypes.Text(),
};
})(),
invoices: new (class extends Collection {
fields = {
entries: new StructuredArray({
product: new FieldTypes.SingleReference(
"products"
),
}),
product: new FieldTypes.SingleReference(
"products"
),
};
})(),
};
},
async ({ app }) => {
const pen = await app.collections.products.suCreate({
name: "pen",
});
await app.collections.invoices.suCreate({
entries: [{ product: pen.id }],
});
const list = app.collections.invoices
.suList()
.attach({ entries: { product: true }, product: true });
const result = await list.fetch();
assert.strictEqual(result.items.length, 1);
assert.strictEqual(
result.items[0]?.getAttachments("entries")[0]?.get("name"),
"pen"
);
}
));
});
diff --git a/src/chip-types/field-base.ts b/src/chip-types/field-base.ts
index cc58e5d9..dd2c2894 100644
--- a/src/chip-types/field-base.ts
+++ b/src/chip-types/field-base.ts
@@ -1,366 +1,374 @@
import type Collection from "./collection.js";
import type Context from "../context.js";
import type { ActionName } from "../action.js";
import type { QueryStage } from "../datastore/query-stage.js";
import type { MatchBody } from "../datastore/query-stage.js";
import { BadSubjectAction } from "../response/errors.js";
import isEmpty from "../utils/is-empty.js";
import type { App } from "../app/app.js";
import { ItemListResult } from "./item-list-result.js";
import { OpenApiTypeMapping, OpenApiTypes } from "../schemas/open-api-types.js";
export type Depromisify<T> = T extends Promise<infer V> ? V : T;
export type ExtractParams<F extends Field<unknown, unknown, unknown>> =
Parameters<F["setParams"]>[0];
export type ExtractFilterParams<F extends Field<unknown, unknown, unknown>> =
Parameters<F["getMatchQueryValue"]>[1];
export type ValidationResult = {
valid: boolean;
reason?: string;
};
export type ExtractFieldDecoded<F extends Field<unknown, unknown, unknown>> =
F extends Field<infer T, unknown, unknown> ? T : never;
export type ExtractFieldInput<F extends Field<unknown, unknown, unknown>> =
F extends Field<unknown, infer T, unknown> ? T : never;
export type ExtractFieldStorage<F extends Field<unknown, unknown, unknown>> =
F extends Field<unknown, unknown, infer T> ? T : never;
export type RequiredField<DecodedType, InputType, StorageType> = Field<
DecodedType,
InputType,
StorageType
> & { required: true };
/** The field class itself. Stores information on the field name, and
* methods that decide waht values are valid and how they are
* stored. The {@link Field} class describes a type of field in
* general (like "Text" and "Number"), and a {@link Field} instance
* describes one particular field in a collection (like "name" and
* "age").
*
* Extend this class to create fields with custom behavior.
*
* **The recommended way to create a field for a collection is {@link
* FieldDefinitionHelper}, as it performs type checking of the
* field params.**
*
* Some of the most useful field types include:
* * {@link Boolean}
* * {@link DateField | Date}
* * {@link Datetime}
* * {@link Email}
* * {@link Enum}
* * {@link FileField | Field}
* * {@link Float}
* * {@link Html}
* * {@link Image}
* * {@link Int}
* * {@link SingleReference}
* * {@link Text}
*/
export abstract class Field<
DecodedType,
InputType = DecodedType,
StorageType = DecodedType,
> {
/** the name of the field */
name: string;
/** the app that the field exists in
* @internal
*/
app: App;
/** The display hints specified for this field */
display_hints: any;
/** Whether or not the field handles large data
* @todo: see if there's any viability in storing this
*/
handles_large_data = false;
/** The collection this field is attached to */
collection: Collection;
/** Whether or not this field should always have a value. Creating
* a resource with a value missing for a required field will throw
* an error */
required: boolean;
transitionChecker: (
ctx: Context,
old_value: StorageType | undefined,
- new_value: InputType
+ new_value: DecodedType
) => Promise<ValidationResult> = async () => ({ valid: true });
setTransitionChecker(
checker: (
ctx: Context,
old_value: StorageType,
- new_value: InputType
+ new_value: DecodedType
) => Promise<ValidationResult>
): this {
this.transitionChecker = checker;
return this;
}
/** Sets the collection @internal */
setCollection(collection: Collection) {
this.collection = collection;
}
setRequired(
required: boolean
): RequiredField<DecodedType, InputType, StorageType> {
this.required = required;
return this as RequiredField<DecodedType, InputType, StorageType>;
}
/** Sets the name @internal */
setName(name: string) {
if (name.includes(":")) {
throw new Error("Field names cannot contain the `:` character.");
}
this.name = name;
}
/** This method is used to set and process the params upon the
* field's creation when the app starts up. The type of argument
* of this method determines type checking that's performed by
* @{link FieldDefinitionHelper}. */
setParams(_: any): void {}
/** Return a summary of this field */
getSpecification() {
return {
name: this.name,
type: this.typeName,
display_hints: this.display_hints,
};
}
/** Whether or not this field should have a dedicated index in the
* database */
async hasIndex(): Promise<
boolean | "text" | { [subfield_name: string]: boolean | "text" }
> {
return false;
}
/** Value path is where inside a single record should the DB look
* for the field's value when filtering resources. Some fields use
* complex objects for storage and overwrite this method, and
* thanks to that they don't have to reimplement {@link
* Field.getAggregationStages} */
async getValuePath(): Promise<string> {
return this.name;
}
abstract typeName: string;
// base open api type - sets type & format
abstract open_api_type: OpenApiTypes;
// generates field schema - can be overriden to add extra fields
// (for example to add max_length & min_length fields to text types)
async getOpenApiSchema(context: Context): Promise<Record<string, unknown>> {
return {
...OpenApiTypeMapping[this.open_api_type],
...(this.hasDefaultValue?.()
? {
default:
this.hasDefaultValue?.() &&
(await this.getDefaultValue(context)),
}
: {}),
// nullable: ? // not sure if/when applicable
// readOnly: ? // not sure if/when applicable
};
}
protected abstract isProperValue(
context: Context,
new_value: unknown,
old_value: unknown,
new_value_blessing_token: symbol | null
): Promise<ValidationResult>;
public async checkValue(
context: Context,
new_value: unknown,
old_value: unknown,
new_value_blessing_token: symbol | null
): Promise<ValidationResult> {
if (isEmpty(new_value) && this.required) {
return Field.invalid(`Missing value for field '${this.name}'.`);
} else if (isEmpty(new_value)) {
return Field.valid();
}
const basic_validation = await this.isProperValue(
context,
new_value,
old_value,
new_value_blessing_token
);
if (!basic_validation.valid) {
return basic_validation;
}
+ const encoded = await this.encode(context, new_value as InputType);
+ const decoded = await this.decode(
+ context,
+ encoded as StorageType,
+ old_value,
+ {},
+ false
+ );
return this.transitionChecker(
context,
old_value as StorageType,
- new_value as InputType
+ decoded as DecodedType
);
}
/** Decides how to store the given value in the database, based on
* the context and previous value of the field */
async encode(
_: Context,
value: InputType | null,
__?: any
): Promise<StorageType | null> {
return value as any;
}
/** Reverse to the {@link Field.encode} function. Takes what's inside the database and returns the value in a given format */
async decode(
context: Context,
storage_value: StorageType,
old_value: any,
format_params: any,
is_http_api_request = false
): Promise<DecodedType | null> {
context.app.Logger.debug3("FIELD DECODE", this.name, {
storage_value,
old_value,
});
return storage_value as unknown as DecodedType;
}
/** Generates a mongo query based on the filter value */
async getMatchQueryValue(context: Context, filter: any): Promise<any> {
return this.encode(context, filter);
}
async getMatchQuery(
context: Context,
filter: any,
value_path: string
): Promise<any> {
return {
[value_path]: await this.getMatchQueryValue(context, filter),
};
}
/** Whether or not the db should create a fulltext index on this field */
async fullTextSearchEnabled(): Promise<boolean> {
return false;
}
/** Whether or not a field has a default value - that is, a value
* given to the field if no value is provided */
hasDefaultValue() {
return true;
}
/** The default value that will be assigned to the field if no
* value is given */
async getDefaultValue(_: Context): Promise<InputType | null> {
return null;
}
/** Whether or not any of the methods of the field depend on the
* previous value of the field */
isOldValueSensitive(_: ActionName) {
return false;
}
/** Used to signal a positive decision from within {@link
* Field.isProperValue}. */
static valid(): ValidationResult {
return { valid: true };
}
/** Used to signal a negative decition from within {@link
* Field.isProperValue}. */
static invalid(reason: string): ValidationResult {
return { valid: false, reason };
}
/** Runs when the app is being started. Hooks can be set up within
* this function */
async init(app: App, collection: Collection): Promise<void> {
this.app = app;
this.collection = collection;
}
async getAttachments(
context: Context,
values: any[], // this method gets called once for multiple resources, to limit the number of queries. Field values of all the resources are passed in this array
attachment_options: any,
format_params: any
): Promise<ItemListResult<any>> {
if (attachment_options !== undefined) {
throw new BadSubjectAction(
`Field '${this.name}' does not support attachments`
);
}
return new ItemListResult([], [], {});
}
/** Creates parts of a Mongo Pipieline that will be used to filter
* the items when listing items of a collection */
async getAggregationStages(
context: Context,
field_filter: unknown
): Promise<QueryStage[]> {
context.app.Logger.debug2(
"FIELD",
`${this.name}.getAggregationStages`,
field_filter
);
if (field_filter === undefined) return [];
const value_path = await this.getValuePath();
let $match: MatchBody = {};
if (field_filter === null) {
$match = {
$or: [
{ [value_path]: { $exists: false } },
{ [value_path]: null },
],
};
} else if (field_filter instanceof Array) {
$match = {
[value_path]: {
$in: await Promise.all(
field_filter.map((value) => this.encode(context, value))
),
},
};
} else {
$match = await this.getMatchQuery(
context,
field_filter,
await this.getValuePath()
);
context.app.Logger.debug3("FIELD", "getAggregationStages", {
value_path,
$match,
field_type: this.typeName,
});
}
return [{ $match }];
}
getPostgreSqlFieldDefinitions(): string[] {
return [`"${this.name}" JSONB`];
}
getAttachmentIDs(value: DecodedType): string[] {
return [];
}
}
diff --git a/src/chip-types/field-hybrid.ts b/src/chip-types/field-hybrid.ts
index 93e82532..52a3db20 100644
--- a/src/chip-types/field-hybrid.ts
+++ b/src/chip-types/field-hybrid.ts
@@ -1,142 +1,142 @@
import Field from "./field.js";
import type Context from "../context.js";
import type {
App,
Collection,
ItemListResult,
ValidationResult,
} from "../main.js";
import { OpenApiTypes } from "../schemas/open-api-types.js";
/*
A hybrid field is one that takes a field type as a param. All
uncustomized methods should be taken from that given field type
*/
export default abstract class HybridField<
ParsedType,
InputType,
StorageType,
InnerParsedType,
InnerInputType,
InnerStorageType,
T extends Field<InnerParsedType, InnerInputType, InnerStorageType>,
> extends Field<ParsedType, InputType, StorageType> {
virtual_field: T;
open_api_type = OpenApiTypes.NONE;
async getOpenApiSchema(context: Context): Promise<Record<string, unknown>> {
return await this.virtual_field.getOpenApiSchema(context);
}
transitionChecker: (
ctx: Context,
old_value: StorageType | undefined,
- new_value: InputType
+ new_value: ParsedType
) => Promise<ValidationResult>;
constructor(base_field: T) {
super();
this.virtual_field = base_field;
this.open_api_type = base_field.open_api_type;
this.transitionChecker = (
ctx: Context,
old_value: StorageType | undefined,
- new_value: InputType
+ new_value: ParsedType
) => {
return this.virtual_field.transitionChecker(
ctx,
old_value as InnerStorageType,
- new_value as unknown as InnerInputType
+ new_value as unknown as InnerParsedType
);
};
}
setName(name: string) {
super.setName(name);
this.virtual_field.setName(name);
}
setCollection(collection: Collection) {
super.setCollection(collection);
this.virtual_field.setCollection(collection);
}
async encode(
context: Context,
value: InputType | null,
old_value?: InputType
) {
return this.virtual_field.encode(
context,
value as unknown as InnerInputType,
old_value
) as StorageType;
}
async getMatchQueryValue(context: Context, filter: any) {
return this.virtual_field.getMatchQueryValue(context, filter);
}
async getMatchQuery(context: Context, filter: any) {
return this.virtual_field.getMatchQuery(
context,
filter,
await this.getValuePath()
);
}
async isProperValue(
context: Context,
new_value: Parameters<T["checkValue"]>[1],
old_value: Parameters<T["checkValue"]>[2],
new_value_blessing_token: symbol | null
) {
return this.virtual_field.checkValue(
context,
new_value,
old_value,
new_value_blessing_token
);
}
async decode(
context: Context,
storage_value: StorageType,
old_value: Parameters<T["decode"]>[2],
format: Parameters<T["decode"]>[3]
) {
return this.virtual_field.decode(
context,
storage_value as unknown as InnerStorageType,
old_value,
format
) as ParsedType;
}
async getAttachments(
context: Context,
values: any[],
attachment_options: any,
format: Parameters<T["decode"]>[3]
): Promise<ItemListResult<any>> {
return this.virtual_field.getAttachments(
context,
values,
attachment_options,
format
);
}
async init(app: App, collection: Collection) {
await super.init(app, collection);
await this.virtual_field.init(app, collection);
}
getPostgreSqlFieldDefinitions(): string[] {
return this.virtual_field.getPostgreSqlFieldDefinitions();
}
}
diff --git a/src/chip-types/field.test.ts b/src/chip-types/field.test.ts
index 6f478eb4..86f15f43 100644
--- a/src/chip-types/field.test.ts
+++ b/src/chip-types/field.test.ts
@@ -1,92 +1,90 @@
import { App } from "../app/app.js";
import { withRunningApp } from "../test_utils/with-test-app.js";
import { Collection, FieldTypes } from "../main.js";
import { assertThrowsAsync } from "../test_utils/assert-throws-async.js";
import assert from "assert";
describe("field class", () => {
describe("transition behavior", () => {
it("uses the transition checker to allow / disallow certain value changes", () =>
withRunningApp(
(t) =>
class extends t {
collections = {
...App.BaseCollections,
history: new (class extends Collection {
fields = {
timestamp:
new FieldTypes.Int().setTransitionChecker(
async (_, old_value, new_value) => {
return old_value == undefined ||
- parseInt(
- String(new_value)
- ) > old_value
+ new_value > old_value
? { valid: true }
: {
valid: false,
reason: "timestamps cannot go back in time",
};
}
),
};
})(),
};
},
async ({ app }) => {
const event = await app.collections.history.suCreate({
timestamp: 0,
});
event.set("timestamp", 1);
await event.save(new app.SuperContext());
await assertThrowsAsync(async () => {
event.set("timestamp", 0);
await event.save(new app.SuperContext());
});
}
));
it("doesn't call the transition checker if the value didn't change", async () => {
let call_count = 0;
await withRunningApp(
(t) =>
class extends t {
collections = {
...App.BaseCollections,
history: new (class extends Collection {
fields = {
title: new FieldTypes.Text(),
timestamp:
new FieldTypes.Int().setTransitionChecker(
async () => {
call_count++;
return {
valid: true,
};
}
),
};
})(),
};
},
async ({ app }) => {
// one
const event = await app.collections.history.suCreate({
timestamp: 0,
});
// two
event.set("timestamp", 1);
await event.save(new app.SuperContext());
// but not three
event.set("title", "hehe");
await event.save(new app.SuperContext());
assert.strictEqual(call_count, 2);
}
);
});
});
});

File Metadata

Mime Type
text/x-diff
Expires
Sat, Oct 11, 08:37 (18 h, 40 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
984061
Default Alt Text
(46 KB)

Event Timeline