@chiselstrike/api
Advanced tools
Comparing version
@@ -0,25 +1,20 @@ | ||
/// <reference lib="deno.core" /> | ||
/// <reference lib="dom" /> | ||
/// <reference lib="dom.iterable" /> | ||
declare enum OpType { | ||
BaseEntity = "BaseEntity", | ||
Take = "Take", | ||
Skip = "Skip", | ||
ColumnsSelect = "ColumnsSelect", | ||
RestrictionFilter = "RestrictionFilter", | ||
PredicateFilter = "PredicateFilter", | ||
Sort = "Sort" | ||
ExpressionFilter = "ExpressionFilter", | ||
SortBy = "SortBy" | ||
} | ||
/** | ||
* Base class for various Operators applicable on `ChiselCursor`. Each operator | ||
* should extend this class and pass on its `type` identifier from the `OpType` | ||
* enum. | ||
*/ | ||
declare abstract class Operator { | ||
declare abstract class Operator<T> { | ||
readonly type: OpType; | ||
readonly inner: Operator | undefined; | ||
constructor(type: OpType, inner: Operator | undefined); | ||
/** Applies specified Operator `op` on each element of passed iterable | ||
* `iter` creating a new iterable. | ||
*/ | ||
abstract apply(iter: AsyncIterable<Record<string, unknown>>): AsyncIterable<Record<string, unknown>>; | ||
readonly inner: Operator<T> | undefined; | ||
constructor(type: OpType, inner: Operator<T> | undefined); | ||
abstract apply(iter: AsyncIterable<T>): AsyncIterable<T>; | ||
containsType(opType: OpType): boolean; | ||
} | ||
/** ChiselCursor is a lazy iterator that will be used by ChiselStrike to construct an optimized query. */ | ||
export declare class ChiselCursor<T> { | ||
@@ -30,170 +25,52 @@ private baseConstructor; | ||
new (): T; | ||
}, inner: Operator); | ||
/** Force ChiselStrike to fetch just the `...columns` that are part of the colums list. */ | ||
}, inner: Operator<T>); | ||
select<C extends (keyof T)[]>(...columns: C): ChiselCursor<Pick<T, C[number]>>; | ||
/** Restricts this cursor to contain only at most `count` elements */ | ||
take(count: number): ChiselCursor<T>; | ||
/** | ||
* Restricts this cursor to contain only elements that match the given @predicate. | ||
*/ | ||
skip(count: number): ChiselCursor<T>; | ||
filter(predicate: (arg: T) => boolean): ChiselCursor<T>; | ||
/** | ||
* Restricts this cursor to contain just the objects that match the `Partial` | ||
* object `restrictions`. | ||
*/ | ||
filter(restrictions: Partial<T>): ChiselCursor<T>; | ||
/** | ||
* Sorts cursor elements. | ||
* | ||
* @param comparator determines the sorting order. For two elements of the | ||
* iterable returns true if `lhs` is considered less than `rhs`, | ||
* false otherwise. | ||
* | ||
* Note: the sort is not guaranteed to be stable. | ||
*/ | ||
sort(comparator: (lhs: T, rhs: T) => boolean): ChiselCursor<T>; | ||
/** | ||
* Sorts cursor elements. | ||
* | ||
* @param key specifies which attribute of `T` is to be used as a sort key. | ||
* @param ascending if true, the sort will be ascending. Descending otherwise. | ||
* | ||
* Note: the sort is not guaranteed to be stable. | ||
*/ | ||
__filterWithExpression(predicate: (arg: T) => boolean, expression: Record<string, unknown>): ChiselCursor<T>; | ||
sortBy(key: keyof T, ascending?: boolean): ChiselCursor<T>; | ||
/** Executes the function `func` for each element of this cursor. */ | ||
forEach(func: (arg: T) => void): Promise<void>; | ||
/** Converts this cursor to an Array. | ||
* | ||
* Use this with caution as the result set can be very big. | ||
* It is recommended that you take() first to cap the maximum number of elements. */ | ||
toArray(): Promise<Partial<T>[]>; | ||
/** ChiselCursor implements asyncIterator, meaning you can use it in any asynchronous context. */ | ||
[Symbol.asyncIterator](): AsyncGenerator<Awaited<T>, void, unknown>; | ||
/** Performs recursive descent via Operator.inner examining the whole operator | ||
* chain. If PredicateFilter or Sort is encountered, a backend query is generated | ||
* and all consecutive operations are applied on the resulting async iterable | ||
* in TypeScript. In such a case, the function returns the resulting AsyncIterable. | ||
* If no PredicateFilter or Sort is found, undefined is returned. | ||
*/ | ||
[Symbol.asyncIterator](): AsyncIterator<T>; | ||
private makeTransformedQueryIter; | ||
private makeQueryIter; | ||
/** Recursively examines operator chain searching for ColumnsSelect operator. | ||
* Returns true if found, false otherwise. | ||
*/ | ||
private containsSelect; | ||
} | ||
export declare class ChiselRequest extends Request { | ||
version: string; | ||
endpoint: string; | ||
pathParams: string; | ||
user?: OAuthUser | undefined; | ||
constructor(input: string, init: RequestInit, version: string, endpoint: string, pathParams: string, user?: OAuthUser | undefined); | ||
pathComponents(): string[]; | ||
} | ||
export declare function chiselIterator<T>(type: { | ||
new (): T; | ||
}): ChiselCursor<T>; | ||
/** ChiselEntity is a class that ChiselStrike user-defined entities are expected to extend. | ||
* | ||
* It provides properties that are inherent to a ChiselStrike entity, like an id, and static | ||
* methods that can be used to obtain a `ChiselCursor`. | ||
*/ | ||
export declare class ChiselEntity { | ||
/** UUID identifying this object. */ | ||
id?: string; | ||
/** | ||
* Builds a new entity. | ||
* | ||
* @param properties The properties of the created entity. If more than one property | ||
* is passed, the expected order of assignment is the same as Object.assign. | ||
* | ||
* @example | ||
* ```typescript | ||
* export class User extends ChiselEntity { | ||
* username: string, | ||
* email: string, | ||
* } | ||
* // Create an entity from object literal: | ||
* const user = User.build({ username: "alice", email: "alice@example.com" }); | ||
* // Create an entity from JSON: | ||
* const userJson = JSON.parse('{"username": "alice", "email": "alice@example.com"}'); | ||
* const anotherUser = User.build(userJson); | ||
* | ||
* // Create an entity from different JSON objects: | ||
* const otherUserJson = JSON.parse('{"username": "alice"}, {"email": "alice@example.com"}'); | ||
* const yetAnotherUser = User.build(userJson); | ||
* | ||
* // now optionally save them to the backend | ||
* await user.save(); | ||
* await anotherUser.save(); | ||
* await yetAnotherUser.save(); | ||
* ``` | ||
* @returns The persisted entity with given properties and the `id` property set. | ||
*/ | ||
static build<T extends ChiselEntity>(this: { | ||
new (): T; | ||
}, ...properties: Record<string, unknown>[]): T; | ||
/** saves the current object into the backend */ | ||
save(): Promise<void>; | ||
/** Returns a `ChiselCursor` containing all elements of type T known to ChiselStrike. | ||
* | ||
* Note that `ChiselCursor` is a lazy iterator, so this doesn't mean a query will be generating fetching all elements at this point. */ | ||
static cursor<T>(this: { | ||
new (): T; | ||
}): ChiselCursor<T>; | ||
/** Restricts this iterator to contain just the objects that match the `Partial` object `restrictions`. */ | ||
static findAll<T>(this: { | ||
new (): T; | ||
}, take?: number): Promise<Partial<T>[]>; | ||
static findMany<T>(this: { | ||
new (): T; | ||
}, restrictions: Partial<T>, take?: number): Promise<Partial<T>[]>; | ||
/** Returns a single object that matches the `Partial` object `restrictions` passed as its parameter. | ||
* | ||
* If more than one match is found, any is returned. */ | ||
static findOne<T extends ChiselEntity>(this: { | ||
new (): T; | ||
}, restrictions: Partial<T>): Promise<T | undefined>; | ||
/** | ||
* Deletes all entities that match the `restrictions` object. | ||
* | ||
* @example | ||
* ```typescript | ||
* export class User extends ChiselEntity { | ||
* username: string, | ||
* email: string, | ||
* } | ||
* const user = User.build({ username: "alice", email: "alice@example.com" }); | ||
* await user.save(); | ||
* | ||
* await User.delete({ email: "alice@example.com"}) | ||
* ``` | ||
*/ | ||
static delete<T extends ChiselEntity>(this: { | ||
new (): T; | ||
}, restrictions: Partial<T>): Promise<void>; | ||
/** | ||
* Generates endpoint code to handle REST methods GET/PUT/POST/DELETE for this entity. | ||
* | ||
* @example | ||
* | ||
* Put this in the file 'endpoints/comments.ts': | ||
* ```typescript | ||
* import { Comment } from "../models/comment"; | ||
* export default Comment.crud(); | ||
* ``` | ||
* | ||
* This results in a /comments endpoint that correctly handles all REST methods over Comment. The default endpoints are: | ||
* | ||
* * **POST:** | ||
* * `/comments` Creates a new object. Payload is a JSON with the properties of `Comment` as keys. | ||
* | ||
* * **GET:** | ||
* * `/comments` returns an array with all elements (use carefully in datasets expected to be large) | ||
* * `/comments?f={key:val}` returns all elements that match the filter specified by the json object given as search param. | ||
* * `/comments/:id` returns the element with the given ID. | ||
* | ||
* * **DELETE:** | ||
* * `/comments/:id` deletes the element with the given ID. | ||
* * `/comments?f={key:val}` deletes all elements that match the filter specified by the json object given as search param. | ||
* * `/comments?f={}` deletes all elements | ||
* | ||
* * **PUT:** | ||
* * `/comments/:id` overwrites the element with the given ID. Payload is a JSON with the properties of `Comment` as keys | ||
* | ||
* If you need more control over which method to generate and their behavior, see the top-level `crud()` function | ||
* | ||
* @returns A request-handling function suitable as a default export in an endpoint. | ||
*/ | ||
static crud(_ignored?: string): (req: Request) => Promise<Response>; | ||
static create<T extends ChiselEntity>(this: { | ||
new (): T; | ||
}, ...properties: Record<string, unknown>[]): Promise<T>; | ||
} | ||
@@ -203,10 +80,2 @@ export declare class OAuthUser extends ChiselEntity { | ||
} | ||
export declare function buildReadableStreamForBody(rid: number): ReadableStream<string>; | ||
/** | ||
* Gets a secret from the environment | ||
* | ||
* To allow a secret to be used, the server has to be run with * --allow-env <YOUR_SECRET> | ||
* | ||
* In development mode, all of your environment variables are accessible | ||
*/ | ||
declare type JSONValue = string | number | boolean | null | { | ||
@@ -218,5 +87,10 @@ [x: string]: JSONValue; | ||
export declare function labels(..._val: string[]): <T>(_target: T, _propertyName: string) => void; | ||
export declare function unique(): void; | ||
/** Returns the currently logged-in user or null if no one is logged in. */ | ||
export declare function unique(_target: unknown, _name: string): void; | ||
export declare function loggedInUser(): Promise<OAuthUser | undefined>; | ||
export declare const requestContext: { | ||
path: string; | ||
method: string; | ||
apiVersion: string; | ||
userId?: string; | ||
}; | ||
export declare function regExParamParse(str: string, loose: boolean): { | ||
@@ -234,46 +108,13 @@ keys: string[]; | ||
delete: (restrictions: Partial<T>) => Promise<void>; | ||
cursor: () => ChiselCursor<T>; | ||
}; | ||
declare type GenericChiselEntityClass = ChiselEntityClass<ChiselEntity>; | ||
/** | ||
* Get the filters to be used with a ChiselEntity from a URL. | ||
* | ||
* This will get the URL search parameter "f" and assume it's a JSON object. | ||
* @param _entity the entity class that will be filtered | ||
* @param url the url that provides the search parameters | ||
* @returns the filter object, if found and successfully parsed; undefined if not found; throws if parsing failed | ||
*/ | ||
export declare function getEntityFiltersFromURL<T extends ChiselEntity, E extends ChiselEntityClass<T>>(_entity: E, url: URL): Partial<T> | undefined; | ||
/** | ||
* Creates a path parser from a template using regexparam. | ||
* | ||
* @param pathTemplate the path template such as `/static`, `/param/:id/:otherParam`... | ||
* @param loose if true, it can match longer paths. False by default | ||
* @returns function that can parse paths given as string. | ||
* @see https://deno.land/x/regexparam@v2.0.0 | ||
*/ | ||
export declare function createPathParser<T extends Record<string, unknown>>(pathTemplate: string, loose?: boolean): ((path: string) => T); | ||
/** | ||
* Creates a path parser from a template using regexparam. | ||
* | ||
* @param pathTemplate the path template such as `/static`, `/param/:id/:otherParam`... | ||
* @param loose if true, it can match longer paths. False by default | ||
* @returns function that can parse paths given in URL.pathname. | ||
* @see https://deno.land/x/regexparam@v2.0.0 | ||
*/ | ||
export declare function createURLPathParser<T extends Record<string, unknown>>(pathTemplate: string, loose?: boolean): ((url: URL) => T); | ||
/** Creates a Response object from response body and status. */ | ||
export declare type CRUDCreateResponse = (body: unknown, status: number) => (Promise<Response> | Response); | ||
export declare type CRUDBaseParams = { | ||
/** identifier of the object being manipulated, if any */ | ||
id?: string; | ||
/** ChiselStrike's version/branch the server is running, | ||
* such as 'dev' for endpoint '/dev/example' | ||
* when using 'chisel apply --version dev' | ||
*/ | ||
chiselVersion: string; | ||
}; | ||
export declare type CRUDMethodSignature<T extends ChiselEntity, E extends ChiselEntityClass<T>, P extends CRUDBaseParams = CRUDBaseParams> = (entity: E, req: Request, params: P, url: URL, createResponse: CRUDCreateResponse) => Promise<Response>; | ||
/** | ||
* A dictionary mapping HTTP verbs into corresponding REST methods that process a Request and return a Response. | ||
*/ | ||
export declare type CRUDMethods<T extends ChiselEntity, E extends ChiselEntityClass<T>, P extends CRUDBaseParams = CRUDBaseParams> = { | ||
@@ -288,19 +129,2 @@ GET: CRUDMethodSignature<T, E, P>; | ||
}; | ||
/** | ||
* These methods can be used as `customMethods` in `ChiselStrike.crud()`. | ||
* | ||
* @example | ||
* Put this in the file 'endpoints/comments.ts': | ||
* ```typescript | ||
* import { Comment } from "../models/comment"; | ||
* export default crud( | ||
* Comment, | ||
* ':id', | ||
* { | ||
* PUT: standardCRUDMethods.notFound, // do not update, instead returns 404 | ||
* DELETE: standardCRUDMethods.methodNotAllowed, // do not delete, instead returns 405 | ||
* }, | ||
* ); | ||
* ``` | ||
*/ | ||
export declare const standardCRUDMethods: { | ||
@@ -311,32 +135,2 @@ readonly forbidden: (_entity: GenericChiselEntityClass, _req: Request, _params: CRUDBaseParams, _url: URL, createResponse: CRUDCreateResponse) => Promise<Response>; | ||
}; | ||
/** | ||
* Generates endpoint code to handle REST methods GET/PUT/POST/DELETE for this entity. | ||
* @example | ||
* Put this in the file 'endpoints/comments.ts': | ||
* ```typescript | ||
* import { Comment } from "../models/comment"; | ||
* export default crud(Comment, ":id"); | ||
* ``` | ||
* This results in a /comments endpoint that correctly handles all REST methods over Comment. | ||
* @param entity Entity type | ||
* @param urlTemplateSuffix A suffix to be added to the Request URL (see https://deno.land/x/regexparam for syntax). | ||
* Some CRUD methods rely on parts of the URL to identify the resource to apply to. Eg, GET /comments/1234 | ||
* returns the comment entity with id=1234, while GET /comments returns all comments. This parameter describes | ||
* how to find the relevant parts in the URL. Default CRUD methods (see `defaultCrudMethods`) look for the :id | ||
* part in this template to identify specific entity instances. If there is no :id in the template, then ':id' | ||
* is automatically added to its end. Custom methods can use other named parts. | ||
* @param config Configure the CRUD behavior: | ||
* - `customMethods`: custom request handlers overriding the defaults. | ||
* Each present property overrides that method's handler. You can use `standardCRUDMethods` members here to | ||
* conveniently reject some actions. When `customMethods` is absent, we use methods from `defaultCrudMethods`. | ||
* Note that these default methods look for the `id` property in their `params` argument; if set, its value is | ||
* the id of the entity to process. Conveniently, the default `urlTemplate` parser sets this property from the | ||
* `:id` pattern. | ||
* - `createResponses`: if present, a dictionary of method-specific Response creators. | ||
* - `defaultCreateResponse`: default function to create all responses if `createResponses` entry is not provided. | ||
* Defaults to `responseFromJson()`. | ||
* - `parsePath`: parses the URL path instead of https://deno.land/x/regexparam. The parsing result is passed to | ||
* CRUD methods as the `params` argument. | ||
* @returns A request-handling function suitable as a default export in an endpoint. | ||
*/ | ||
export declare function crud<T extends ChiselEntity, E extends ChiselEntityClass<T>, P extends CRUDBaseParams = CRUDBaseParams>(entity: E, urlTemplateSuffix: string, config?: { | ||
@@ -343,0 +137,0 @@ customMethods?: Partial<CRUDMethods<T, ChiselEntityClass<T>, P>>; |
@@ -1,4 +0,1 @@ | ||
// SPDX-FileCopyrightText: © 2021 ChiselStrike <info@chiselstrike.com> | ||
/// <reference types="./lib.deno_core.d.ts" /> | ||
/// <reference lib="dom" /> | ||
var OpType; | ||
@@ -8,12 +5,8 @@ (function (OpType) { | ||
OpType["Take"] = "Take"; | ||
OpType["Skip"] = "Skip"; | ||
OpType["ColumnsSelect"] = "ColumnsSelect"; | ||
OpType["RestrictionFilter"] = "RestrictionFilter"; | ||
OpType["PredicateFilter"] = "PredicateFilter"; | ||
OpType["Sort"] = "Sort"; | ||
OpType["ExpressionFilter"] = "ExpressionFilter"; | ||
OpType["SortBy"] = "SortBy"; | ||
})(OpType || (OpType = {})); | ||
/** | ||
* Base class for various Operators applicable on `ChiselCursor`. Each operator | ||
* should extend this class and pass on its `type` identifier from the `OpType` | ||
* enum. | ||
*/ | ||
class Operator { | ||
@@ -26,6 +19,14 @@ type; | ||
} | ||
containsType(opType) { | ||
if (this.type == opType) { | ||
return true; | ||
} | ||
else if (this.inner === undefined) { | ||
return false; | ||
} | ||
else { | ||
return this.inner.containsType(opType); | ||
} | ||
} | ||
} | ||
/** | ||
* Specifies Entity whose elements are to be fetched. | ||
*/ | ||
class BaseEntity extends Operator { | ||
@@ -41,6 +42,2 @@ name; | ||
} | ||
/** | ||
* Take operator takes first `count` elements from a collection. | ||
* The rest is ignored. | ||
*/ | ||
class Take extends Operator { | ||
@@ -70,5 +67,22 @@ count; | ||
} | ||
/** | ||
* Forces fetch of just the `columns` (fields) of a given entity. | ||
*/ | ||
class Skip extends Operator { | ||
count; | ||
constructor(count, inner) { | ||
super(OpType.Skip, inner); | ||
this.count = count; | ||
} | ||
apply(iter) { | ||
const count = this.count; | ||
return { | ||
[Symbol.asyncIterator]: async function* () { | ||
let i = 0; | ||
for await (const e of iter) { | ||
if (++i > count) { | ||
yield e; | ||
} | ||
} | ||
}, | ||
}; | ||
} | ||
} | ||
class ColumnsSelect extends Operator { | ||
@@ -97,6 +111,2 @@ columns; | ||
} | ||
/** | ||
* PredicateFilter operator applies `predicate` on each element and keeps | ||
* only those for which the `predicate` returns true. | ||
*/ | ||
class PredicateFilter extends Operator { | ||
@@ -121,24 +131,16 @@ predicate; | ||
} | ||
/** | ||
* RestrictionFilter operator applies `restrictions` on each element | ||
* and keeps only those where field value of a field, specified | ||
* by restriction key, equals to restriction value. | ||
*/ | ||
class RestrictionFilter extends Operator { | ||
restrictions; | ||
constructor(restrictions, inner) { | ||
super(OpType.RestrictionFilter, inner); | ||
this.restrictions = restrictions; | ||
class ExpressionFilter extends Operator { | ||
predicate; | ||
expression; | ||
constructor(predicate, expression, inner) { | ||
super(OpType.ExpressionFilter, inner); | ||
this.predicate = predicate; | ||
this.expression = expression; | ||
} | ||
apply(iter) { | ||
const restrictions = Object.entries(this.restrictions); | ||
const predicate = this.predicate; | ||
return { | ||
[Symbol.asyncIterator]: async function* () { | ||
for await (const arg of iter) { | ||
verifyMatch: { | ||
for (const [key, value] of restrictions) { | ||
if (arg[key] != value) { | ||
break verifyMatch; | ||
} | ||
} | ||
if (predicate(arg)) { | ||
yield arg; | ||
@@ -151,15 +153,13 @@ } | ||
} | ||
/** | ||
* Sort operator sorts elements using `comparator` which | ||
* for two elements of the iterable returns true if `lhs` | ||
* is considered less than `rhs`, false otherwise. | ||
*/ | ||
class Sort extends Operator { | ||
comparator; | ||
constructor(comparator, inner) { | ||
super(OpType.Sort, inner); | ||
this.comparator = comparator; | ||
class SortBy extends Operator { | ||
key; | ||
ascending; | ||
constructor(key, ascending = true, inner) { | ||
super(OpType.SortBy, inner); | ||
this.key = key; | ||
this.ascending = ascending; | ||
} | ||
apply(iter) { | ||
const cmp = this.comparator; | ||
const key = this.key; | ||
const ord = this.ascending ? -1 : 1; | ||
return { | ||
@@ -172,3 +172,3 @@ [Symbol.asyncIterator]: async function* () { | ||
elements.sort((lhs, rhs) => { | ||
return cmp(lhs, rhs) ? -1 : 1; | ||
return lhs[key] < rhs[key] ? ord : (-1) * ord; | ||
}); | ||
@@ -182,3 +182,2 @@ for (const e of elements) { | ||
} | ||
/** ChiselCursor is a lazy iterator that will be used by ChiselStrike to construct an optimized query. */ | ||
export class ChiselCursor { | ||
@@ -191,11 +190,11 @@ baseConstructor; | ||
} | ||
/** Force ChiselStrike to fetch just the `...columns` that are part of the colums list. */ | ||
select(...columns) { | ||
return new ChiselCursor(this.baseConstructor, new ColumnsSelect(columns, this.inner)); | ||
} | ||
/** Restricts this cursor to contain only at most `count` elements */ | ||
take(count) { | ||
return new ChiselCursor(this.baseConstructor, new Take(count, this.inner)); | ||
} | ||
// Common implementation for filter overloads. | ||
skip(count) { | ||
return new ChiselCursor(this.baseConstructor, new Skip(count, this.inner)); | ||
} | ||
filter(arg1) { | ||
@@ -206,32 +205,27 @@ if (typeof arg1 == "function") { | ||
else { | ||
return new ChiselCursor(this.baseConstructor, new RestrictionFilter(arg1, this.inner)); | ||
const restrictions = arg1; | ||
const expr = restrictionsToFilterExpr(restrictions); | ||
if (expr === undefined) { | ||
return this; | ||
} | ||
const predicate = (arg) => { | ||
for (const key in restrictions) { | ||
if (restrictions[key] === undefined) { | ||
continue; | ||
} | ||
if (arg[key] != restrictions[key]) { | ||
return false; | ||
} | ||
} | ||
return true; | ||
}; | ||
return new ChiselCursor(this.baseConstructor, new ExpressionFilter(predicate, expr, this.inner)); | ||
} | ||
} | ||
/** | ||
* Sorts cursor elements. | ||
* | ||
* @param comparator determines the sorting order. For two elements of the | ||
* iterable returns true if `lhs` is considered less than `rhs`, | ||
* false otherwise. | ||
* | ||
* Note: the sort is not guaranteed to be stable. | ||
*/ | ||
sort(comparator) { | ||
return new ChiselCursor(this.baseConstructor, new Sort(comparator, this.inner)); | ||
__filterWithExpression(predicate, expression) { | ||
return new ChiselCursor(this.baseConstructor, new ExpressionFilter(predicate, expression, this.inner)); | ||
} | ||
/** | ||
* Sorts cursor elements. | ||
* | ||
* @param key specifies which attribute of `T` is to be used as a sort key. | ||
* @param ascending if true, the sort will be ascending. Descending otherwise. | ||
* | ||
* Note: the sort is not guaranteed to be stable. | ||
*/ | ||
sortBy(key, ascending = true) { | ||
const cmp = (lhs, rhs) => { | ||
return ascending === (lhs[key] < rhs[key]); | ||
}; | ||
return new ChiselCursor(this.baseConstructor, new Sort(cmp, this.inner)); | ||
return new ChiselCursor(this.baseConstructor, new SortBy(key, ascending, this.inner)); | ||
} | ||
/** Executes the function `func` for each element of this cursor. */ | ||
async forEach(func) { | ||
@@ -242,6 +236,2 @@ for await (const t of this) { | ||
} | ||
/** Converts this cursor to an Array. | ||
* | ||
* Use this with caution as the result set can be very big. | ||
* It is recommended that you take() first to cap the maximum number of elements. */ | ||
async toArray() { | ||
@@ -254,4 +244,3 @@ const arr = []; | ||
} | ||
/** ChiselCursor implements asyncIterator, meaning you can use it in any asynchronous context. */ | ||
async *[Symbol.asyncIterator]() { | ||
[Symbol.asyncIterator]() { | ||
let iter = this.makeTransformedQueryIter(this.inner); | ||
@@ -261,12 +250,4 @@ if (iter === undefined) { | ||
} | ||
for await (const it of iter) { | ||
yield it; | ||
} | ||
return iter[Symbol.asyncIterator](); | ||
} | ||
/** Performs recursive descent via Operator.inner examining the whole operator | ||
* chain. If PredicateFilter or Sort is encountered, a backend query is generated | ||
* and all consecutive operations are applied on the resulting async iterable | ||
* in TypeScript. In such a case, the function returns the resulting AsyncIterable. | ||
* If no PredicateFilter or Sort is found, undefined is returned. | ||
*/ | ||
makeTransformedQueryIter(op) { | ||
@@ -283,3 +264,3 @@ if (op.type == OpType.BaseEntity) { | ||
} | ||
else if (op.type == OpType.PredicateFilter || op.type == OpType.Sort) { | ||
else if (op.type == OpType.PredicateFilter) { | ||
iter = this.makeQueryIter(op.inner); | ||
@@ -293,9 +274,11 @@ return op.apply(iter); | ||
makeQueryIter(op) { | ||
const ctor = this.containsSelect(op) ? undefined : this.baseConstructor; | ||
const ctor = op.containsType(OpType.ColumnsSelect) | ||
? undefined | ||
: this.baseConstructor; | ||
return { | ||
[Symbol.asyncIterator]: async function* () { | ||
const rid = Deno.core.opSync("chisel_relational_query_create", op); | ||
const rid = Deno.core.opSync("op_chisel_relational_query_create", op, requestContext); | ||
try { | ||
while (true) { | ||
const properties = await Deno.core.opAsync("chisel_relational_query_next", rid); | ||
const properties = await Deno.core.opAsync("op_chisel_query_next", rid); | ||
if (properties == undefined) { | ||
@@ -320,16 +303,18 @@ break; | ||
} | ||
/** Recursively examines operator chain searching for ColumnsSelect operator. | ||
* Returns true if found, false otherwise. | ||
*/ | ||
containsSelect(op) { | ||
if (op.type == OpType.ColumnsSelect) { | ||
return true; | ||
} | ||
else if (op.inner === undefined) { | ||
return false; | ||
} | ||
else { | ||
return this.containsSelect(op.inner); | ||
} | ||
} | ||
export class ChiselRequest extends Request { | ||
version; | ||
endpoint; | ||
pathParams; | ||
user; | ||
constructor(input, init, version, endpoint, pathParams, user) { | ||
super(input, init); | ||
this.version = version; | ||
this.endpoint = endpoint; | ||
this.pathParams = pathParams; | ||
this.user = user; | ||
} | ||
pathComponents() { | ||
return this.pathParams.split("/").filter((n) => n.length != 0); | ||
} | ||
} | ||
@@ -340,39 +325,4 @@ export function chiselIterator(type) { | ||
} | ||
/** ChiselEntity is a class that ChiselStrike user-defined entities are expected to extend. | ||
* | ||
* It provides properties that are inherent to a ChiselStrike entity, like an id, and static | ||
* methods that can be used to obtain a `ChiselCursor`. | ||
*/ | ||
export class ChiselEntity { | ||
/** UUID identifying this object. */ | ||
id; | ||
/** | ||
* Builds a new entity. | ||
* | ||
* @param properties The properties of the created entity. If more than one property | ||
* is passed, the expected order of assignment is the same as Object.assign. | ||
* | ||
* @example | ||
* ```typescript | ||
* export class User extends ChiselEntity { | ||
* username: string, | ||
* email: string, | ||
* } | ||
* // Create an entity from object literal: | ||
* const user = User.build({ username: "alice", email: "alice@example.com" }); | ||
* // Create an entity from JSON: | ||
* const userJson = JSON.parse('{"username": "alice", "email": "alice@example.com"}'); | ||
* const anotherUser = User.build(userJson); | ||
* | ||
* // Create an entity from different JSON objects: | ||
* const otherUserJson = JSON.parse('{"username": "alice"}, {"email": "alice@example.com"}'); | ||
* const yetAnotherUser = User.build(userJson); | ||
* | ||
* // now optionally save them to the backend | ||
* await user.save(); | ||
* await anotherUser.save(); | ||
* await yetAnotherUser.save(); | ||
* ``` | ||
* @returns The persisted entity with given properties and the `id` property set. | ||
*/ | ||
static build(...properties) { | ||
@@ -383,8 +333,8 @@ const result = new this(); | ||
} | ||
/** saves the current object into the backend */ | ||
async save() { | ||
const jsonIds = await Deno.core.opAsync("chisel_store", { | ||
ensureNotGet(); | ||
const jsonIds = await Deno.core.opAsync("op_chisel_store", { | ||
name: this.constructor.name, | ||
value: this, | ||
}); | ||
}, requestContext); | ||
function backfillIds(this_, jsonIds) { | ||
@@ -403,10 +353,6 @@ for (const [fieldName, value] of Object.entries(jsonIds)) { | ||
} | ||
/** Returns a `ChiselCursor` containing all elements of type T known to ChiselStrike. | ||
* | ||
* Note that `ChiselCursor` is a lazy iterator, so this doesn't mean a query will be generating fetching all elements at this point. */ | ||
static cursor() { | ||
return chiselIterator(this); | ||
} | ||
/** Restricts this iterator to contain just the objects that match the `Partial` object `restrictions`. */ | ||
static async findMany(restrictions, take) { | ||
static async findAll(take) { | ||
let it = chiselIterator(this); | ||
@@ -416,7 +362,11 @@ if (take) { | ||
} | ||
return await it.filter(restrictions).toArray(); | ||
return await it.toArray(); | ||
} | ||
/** Returns a single object that matches the `Partial` object `restrictions` passed as its parameter. | ||
* | ||
* If more than one match is found, any is returned. */ | ||
static async findMany(restrictions, take) { | ||
let it = chiselIterator(this).filter(restrictions); | ||
if (take) { | ||
it = it.take(take); | ||
} | ||
return await it.toArray(); | ||
} | ||
static async findOne(restrictions) { | ||
@@ -429,82 +379,57 @@ const it = chiselIterator(this).filter(restrictions).take(1); | ||
} | ||
/** | ||
* Deletes all entities that match the `restrictions` object. | ||
* | ||
* @example | ||
* ```typescript | ||
* export class User extends ChiselEntity { | ||
* username: string, | ||
* email: string, | ||
* } | ||
* const user = User.build({ username: "alice", email: "alice@example.com" }); | ||
* await user.save(); | ||
* | ||
* await User.delete({ email: "alice@example.com"}) | ||
* ``` | ||
*/ | ||
static async delete(restrictions) { | ||
await Deno.core.opAsync("chisel_entity_delete", { | ||
type_name: this.name, | ||
restrictions: restrictions, | ||
}); | ||
ensureNotGet(); | ||
await Deno.core.opAsync("op_chisel_entity_delete", { | ||
typeName: this.name, | ||
filterExpr: restrictionsToFilterExpr(restrictions), | ||
}, requestContext); | ||
} | ||
/** | ||
* Generates endpoint code to handle REST methods GET/PUT/POST/DELETE for this entity. | ||
* | ||
* @example | ||
* | ||
* Put this in the file 'endpoints/comments.ts': | ||
* ```typescript | ||
* import { Comment } from "../models/comment"; | ||
* export default Comment.crud(); | ||
* ``` | ||
* | ||
* This results in a /comments endpoint that correctly handles all REST methods over Comment. The default endpoints are: | ||
* | ||
* * **POST:** | ||
* * `/comments` Creates a new object. Payload is a JSON with the properties of `Comment` as keys. | ||
* | ||
* * **GET:** | ||
* * `/comments` returns an array with all elements (use carefully in datasets expected to be large) | ||
* * `/comments?f={key:val}` returns all elements that match the filter specified by the json object given as search param. | ||
* * `/comments/:id` returns the element with the given ID. | ||
* | ||
* * **DELETE:** | ||
* * `/comments/:id` deletes the element with the given ID. | ||
* * `/comments?f={key:val}` deletes all elements that match the filter specified by the json object given as search param. | ||
* * `/comments?f={}` deletes all elements | ||
* | ||
* * **PUT:** | ||
* * `/comments/:id` overwrites the element with the given ID. Payload is a JSON with the properties of `Comment` as keys | ||
* | ||
* If you need more control over which method to generate and their behavior, see the top-level `crud()` function | ||
* | ||
* @returns A request-handling function suitable as a default export in an endpoint. | ||
*/ | ||
static crud(_ignored) { | ||
return crud(this, ""); | ||
} | ||
static async create(...properties) { | ||
const result = new this(); | ||
Object.assign(result, ...properties); | ||
await result.save(); | ||
return result; | ||
} | ||
} | ||
function restrictionsToFilterExpr(restrictions) { | ||
let expr = undefined; | ||
for (const key in restrictions) { | ||
if (restrictions[key] === undefined) { | ||
continue; | ||
} | ||
const cmpExpr = { | ||
exprType: "Binary", | ||
left: { | ||
exprType: "Property", | ||
object: { exprType: "Parameter", position: 0 }, | ||
property: key, | ||
}, | ||
op: "Eq", | ||
right: { | ||
exprType: "Literal", | ||
value: restrictions[key], | ||
}, | ||
}; | ||
if (expr === undefined) { | ||
expr = cmpExpr; | ||
} | ||
else { | ||
expr = { | ||
exprType: "Binary", | ||
left: cmpExpr, | ||
op: "And", | ||
right: expr, | ||
}; | ||
} | ||
} | ||
return expr; | ||
} | ||
export class OAuthUser extends ChiselEntity { | ||
username = undefined; | ||
} | ||
export function buildReadableStreamForBody(rid) { | ||
return new ReadableStream({ | ||
async pull(controller) { | ||
const chunk = await Deno.core.opAsync("chisel_read_body", rid); | ||
if (chunk) { | ||
controller.enqueue(chunk); | ||
} | ||
else { | ||
controller.close(); | ||
Deno.core.opSync("op_close", rid); | ||
} | ||
}, | ||
cancel() { | ||
Deno.core.opSync("op_close", rid); | ||
}, | ||
}); | ||
} | ||
export function getSecret(key) { | ||
const secret = Deno.core.opSync("chisel_get_secret", key); | ||
const secret = Deno.core.opSync("op_chisel_get_secret", key); | ||
if (secret === undefined || secret === null) { | ||
@@ -516,7 +441,6 @@ return undefined; | ||
export function responseFromJson(body, status = 200) { | ||
// https://fetch.spec.whatwg.org/#null-body-status | ||
const isNullBody = (status) => { | ||
return status == 101 || status == 204 || status == 205 || status == 304; | ||
}; | ||
const json = isNullBody(status) ? null : JSON.stringify(body); | ||
const json = isNullBody(status) ? null : JSON.stringify(body, null, 2); | ||
return new Response(json, { | ||
@@ -531,12 +455,9 @@ status: status, | ||
return (_target, _propertyName) => { | ||
// chisel-decorator, no content | ||
}; | ||
} | ||
export function unique() { | ||
// chisel-decorator, no content | ||
export function unique(_target, _name) { | ||
} | ||
/** Returns the currently logged-in user or null if no one is logged in. */ | ||
export async function loggedInUser() { | ||
const id = await Deno.core.opAsync("chisel_user", {}); | ||
if (id == null) { | ||
const id = requestContext.userId; | ||
if (id === undefined) { | ||
return undefined; | ||
@@ -546,30 +467,12 @@ } | ||
} | ||
// TODO: BEGIN: this should be in another file: crud.ts | ||
// TODO: BEGIN: when module import is fixed: | ||
// import { parse as regExParamParse } from "regexparam"; | ||
// or: | ||
// import { parse as regExParamParse } from "regexparam"; | ||
// In the meantime, the regExParamParse function is copied from | ||
// https://deno.land/x/regexparam@v2.0.0/src/index.js under MIT License included | ||
// below. ChiselStrike added the TS signature and minor cleanups. | ||
// | ||
// Copyright (c) Luke Edwards <luke.edwards05@gmail.com> (lukeed.com) | ||
// | ||
// Permission is hereby granted, free of charge, to any person obtaining a copy | ||
// of this software and associated documentation files (the "Software"), to deal | ||
// in the Software without restriction, including without limitation the rights | ||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
// copies of the Software, and to permit persons to whom the Software is | ||
// furnished to do so, subject to the following conditions: | ||
// | ||
// The above copyright notice and this permission notice shall be included in | ||
// all copies or substantial portions of the Software. | ||
// | ||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN | ||
// THE SOFTWARE. | ||
function ensureNotGet() { | ||
if (requestContext.method === "GET") { | ||
throw new Error("Mutating the backend is not allowed during GET"); | ||
} | ||
} | ||
export const requestContext = { | ||
path: "", | ||
method: "", | ||
apiVersion: "", | ||
}; | ||
export function regExParamParse(str, loose) { | ||
@@ -602,32 +505,2 @@ let tmp, pattern = ""; | ||
} | ||
/** | ||
* Get the filters to be used with a ChiselEntity from a URL. | ||
* | ||
* This will get the URL search parameter "f" and assume it's a JSON object. | ||
* @param _entity the entity class that will be filtered | ||
* @param url the url that provides the search parameters | ||
* @returns the filter object, if found and successfully parsed; undefined if not found; throws if parsing failed | ||
*/ | ||
export function getEntityFiltersFromURL(_entity, url) { | ||
// TODO: it's more common to have filters as regular query parameters, URI-encoded, | ||
// then entity may be used to get such field names | ||
// TODO: validate if unknown filters where given? | ||
const f = url.searchParams.get("f"); | ||
if (!f) { | ||
return undefined; | ||
} | ||
const o = JSON.parse(decodeURI(f)); | ||
if (o && typeof o === "object") { | ||
return o; | ||
} | ||
throw new Error(`provided search parameter 'f=${f}' is not a JSON object.`); | ||
} | ||
/** | ||
* Creates a path parser from a template using regexparam. | ||
* | ||
* @param pathTemplate the path template such as `/static`, `/param/:id/:otherParam`... | ||
* @param loose if true, it can match longer paths. False by default | ||
* @returns function that can parse paths given as string. | ||
* @see https://deno.land/x/regexparam@v2.0.0 | ||
*/ | ||
export function createPathParser(pathTemplate, loose = false) { | ||
@@ -647,10 +520,2 @@ const { pattern, keys: keysOrFalse } = regExParamParse(pathTemplate, loose); | ||
} | ||
/** | ||
* Creates a path parser from a template using regexparam. | ||
* | ||
* @param pathTemplate the path template such as `/static`, `/param/:id/:otherParam`... | ||
* @param loose if true, it can match longer paths. False by default | ||
* @returns function that can parse paths given in URL.pathname. | ||
* @see https://deno.land/x/regexparam@v2.0.0 | ||
*/ | ||
export function createURLPathParser(pathTemplate, loose = false) { | ||
@@ -660,13 +525,48 @@ const pathParser = createPathParser(pathTemplate, loose); | ||
} | ||
async function fetchEntitiesCrud(type, url) { | ||
const iter = { | ||
[Symbol.asyncIterator]: async function* () { | ||
const rid = Deno.core.opSync("op_chisel_crud_query_create", { | ||
typeName: type.name, | ||
url, | ||
}, requestContext); | ||
try { | ||
while (true) { | ||
const properties = await Deno.core.opAsync("op_chisel_query_next", rid); | ||
if (properties == undefined) { | ||
break; | ||
} | ||
const result = new type(); | ||
Object.assign(result, properties); | ||
yield result; | ||
} | ||
} | ||
finally { | ||
Deno.core.opSync("op_close", rid); | ||
} | ||
}, | ||
}; | ||
const arr = []; | ||
for await (const t of iter) { | ||
arr.push(t); | ||
} | ||
return arr; | ||
} | ||
async function deleteEntitiesCrud(type, url) { | ||
await Deno.core.opAsync("op_chisel_crud_delete", { | ||
typeName: type.name, | ||
url, | ||
}, requestContext); | ||
} | ||
const defaultCrudMethods = { | ||
// Returns a specific entity matching params.id (if present) or all entities matching the filter in the `f` URL parameter. | ||
GET: async (entity, _req, params, url, createResponse) => { | ||
const { id } = params; | ||
if (!id) { | ||
return createResponse(await entity.findMany(getEntityFiltersFromURL(entity, url) || {}), 200); | ||
if (id) { | ||
const u = await entity.findOne({ id }); | ||
return createResponse(u ?? "Not found", u ? 200 : 404); | ||
} | ||
const u = await entity.findOne({ id }); | ||
return createResponse(u ?? "Not found", u ? 200 : 404); | ||
else { | ||
return createResponse(await fetchEntitiesCrud(entity, url.href), 200); | ||
} | ||
}, | ||
// Creates and returns a new entity from the `req` payload. Ignores the payload's id property and assigns a fresh one. | ||
POST: async (entity, req, _params, _url, createResponse) => { | ||
@@ -678,3 +578,2 @@ const u = entity.build(await req.json()); | ||
}, | ||
// Updates and returns the entity matching params.id (which must be set) from the `req` payload. | ||
PUT: async (entity, req, params, _url, createResponse) => { | ||
@@ -690,3 +589,2 @@ const { id } = params; | ||
}, | ||
// Deletes the entity matching params.id (if present) or all entities matching the filter in the `f` URL parameter. One of the two must be present. | ||
DELETE: async (entity, _req, params, url, createResponse) => { | ||
@@ -698,27 +596,8 @@ const { id } = params; | ||
} | ||
const restrictions = getEntityFiltersFromURL(entity, url); | ||
if (restrictions) { | ||
await entity.delete(restrictions); | ||
return createResponse(`Deleted entities matching ${JSON.stringify(restrictions)}`, 200); | ||
else { | ||
await deleteEntitiesCrud(entity, url.href); | ||
return createResponse(`Deleted entities matching ${url.search}`, 200); | ||
} | ||
return createResponse("Neither ID nor filter found", 422); | ||
}, | ||
}; | ||
/** | ||
* These methods can be used as `customMethods` in `ChiselStrike.crud()`. | ||
* | ||
* @example | ||
* Put this in the file 'endpoints/comments.ts': | ||
* ```typescript | ||
* import { Comment } from "../models/comment"; | ||
* export default crud( | ||
* Comment, | ||
* ':id', | ||
* { | ||
* PUT: standardCRUDMethods.notFound, // do not update, instead returns 404 | ||
* DELETE: standardCRUDMethods.methodNotAllowed, // do not delete, instead returns 405 | ||
* }, | ||
* ); | ||
* ``` | ||
*/ | ||
export const standardCRUDMethods = { | ||
@@ -729,39 +608,8 @@ forbidden: (_entity, _req, _params, _url, createResponse) => Promise.resolve(createResponse("Forbidden", 403)), | ||
}; | ||
/** | ||
* Generates endpoint code to handle REST methods GET/PUT/POST/DELETE for this entity. | ||
* @example | ||
* Put this in the file 'endpoints/comments.ts': | ||
* ```typescript | ||
* import { Comment } from "../models/comment"; | ||
* export default crud(Comment, ":id"); | ||
* ``` | ||
* This results in a /comments endpoint that correctly handles all REST methods over Comment. | ||
* @param entity Entity type | ||
* @param urlTemplateSuffix A suffix to be added to the Request URL (see https://deno.land/x/regexparam for syntax). | ||
* Some CRUD methods rely on parts of the URL to identify the resource to apply to. Eg, GET /comments/1234 | ||
* returns the comment entity with id=1234, while GET /comments returns all comments. This parameter describes | ||
* how to find the relevant parts in the URL. Default CRUD methods (see `defaultCrudMethods`) look for the :id | ||
* part in this template to identify specific entity instances. If there is no :id in the template, then ':id' | ||
* is automatically added to its end. Custom methods can use other named parts. | ||
* @param config Configure the CRUD behavior: | ||
* - `customMethods`: custom request handlers overriding the defaults. | ||
* Each present property overrides that method's handler. You can use `standardCRUDMethods` members here to | ||
* conveniently reject some actions. When `customMethods` is absent, we use methods from `defaultCrudMethods`. | ||
* Note that these default methods look for the `id` property in their `params` argument; if set, its value is | ||
* the id of the entity to process. Conveniently, the default `urlTemplate` parser sets this property from the | ||
* `:id` pattern. | ||
* - `createResponses`: if present, a dictionary of method-specific Response creators. | ||
* - `defaultCreateResponse`: default function to create all responses if `createResponses` entry is not provided. | ||
* Defaults to `responseFromJson()`. | ||
* - `parsePath`: parses the URL path instead of https://deno.land/x/regexparam. The parsing result is passed to | ||
* CRUD methods as the `params` argument. | ||
* @returns A request-handling function suitable as a default export in an endpoint. | ||
*/ | ||
export function crud(entity, urlTemplateSuffix, config) { | ||
const endpointPath = Deno.core.opSync("chisel_current_path"); | ||
const pathTemplateRaw = "/:chiselVersion" + endpointPath + "/" + | ||
const pathTemplateRaw = "/:chiselVersion" + requestContext.path + "/" + | ||
(urlTemplateSuffix.includes(":id") | ||
? urlTemplateSuffix | ||
: `${urlTemplateSuffix}/:id`); | ||
const pathTemplate = pathTemplateRaw.replace(/\/+/g, "/"); // in case we end up with foo///bar somehow. | ||
const pathTemplate = pathTemplateRaw.replace(/\/+/g, "/"); | ||
const defaultCreateResponse = config?.defaultCreateResponse || | ||
@@ -776,3 +624,3 @@ responseFromJson; | ||
return (req) => { | ||
const methodName = req.method; // assume valid, will be handled gracefully | ||
const methodName = req.method; | ||
const createResponse = config?.createResponses?.[methodName] || | ||
@@ -789,2 +637,1 @@ defaultCreateResponse; | ||
} | ||
// TODO: END: this should be in another file: crud.ts |
{ | ||
"name": "@chiselstrike/api", | ||
"version": "0.8.4", | ||
"version": "0.9.0", | ||
"main": "lib/chisel.js", | ||
@@ -8,13 +8,5 @@ "types": "lib/chisel.d.ts", | ||
"license": "Proprietary", | ||
"prepare": "npm run build", | ||
"scripts": { | ||
"prepare": "npm run build", | ||
"build": "rimraf ./lib && tsc --noResolve && cp -La ../../api/src/lib.deno_core.d.ts lib/" | ||
"build": "true" | ||
}, | ||
"dependencies": { | ||
"typescript": "^4.5.4" | ||
}, | ||
"devDependencies": { | ||
"rimraf": "^3.0.2" | ||
}, | ||
"files": [ | ||
@@ -21,0 +13,0 @@ "/lib" |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
0
-100%0
-100%25514
-49.7%4
-20%739
-39.38%1
Infinity%- Removed
- Removed