diff --git a/docs/sp/items.md b/docs/sp/items.md index b0784e5da..126024646 100644 --- a/docs/sp/items.md +++ b/docs/sp/items.md @@ -113,6 +113,89 @@ const r = await sp.web.lists.getByTitle("TaxonomyList").getItemsByCAMLQuery({ }); ``` +### Filter using fluent filter + +>Note: This feature is currently in preview and may not work as expected. + +PnPjs supports a fluent filter for all OData endpoints, including the items endpoint. this allows you to write a strongly fluent filter that will be parsed into an OData filter. + +```TypeScript +import { spfi } from "@pnp/sp"; +import "@pnp/sp/webs"; +import "@pnp/sp/lists"; + +const sp = spfi(...); + +const r = await sp.web.lists.filter(l => l.number("ItemCount").greaterThan(5000))(); +``` + +The following field types are supported in the fluent filter: + +- Text +- Choice +- MultiChoice +- Number +- Date +- Boolean +- Lookup +- LookupId + +The following operations are supported in the fluent filter: + +| Field Type | Operators/Values | +| -------------------- | -------------------------------------------------------------------------------------------- | +| All field types | `equals`, `notEquals`, `in`, `notIn` | +| Text & choice fields | `startsWith`, `contains` | +| Numeric fields | `greaterThan`, `greaterThanOrEquals`, `lessThan`, `lessThanOrEquals` | +| Date fields | `greaterThan`, `greaterThanOrEquals`, `lessThan`, `lessThanOrEquals`, `isBetween`, `isToday` | +| Boolean fields | `isTrue`, `isFalse`, `isFalseOrNull` | +| Lookup | `id`, Text and Number field types | + +#### Complex Filter + +For all the regular endpoints, the fluent filter will infer the type automatically, but for the list items filter, you'll need to provide your own types to make the parser work. + +You can use the `and` and `or` operators to create complex filters that nest different grouping. + +```TypeScript +import { spfi } from "@pnp/sp"; +import "@pnp/sp/webs"; +import "@pnp/sp/lists"; +import "@pnp/sp/items"; + +const sp = spfi(...); + +interface ListItem extends IListItem { + FirstName: string; + LastName: string; + Age: number; + Manager: IListItem; + StartDate: Date; +} + + +// Get all employees named John +const r = await sp.web.lists.getByTitle("ListName").items.filter(f => f.text("FirstName").equal("John"))(); + +// Get all employees not named John who are over 30 +const r1 = await sp.web.lists.getByTitle("ListName").items.filter(f => f.text("FirstName").notEquals("John").and().number("Age").greaterThan(30))(); + +// Get all employees that are named John Doe or Jane Doe +const r2 = await sp.web.lists.getByTitle("ListName").items.filter(f => f.or( + f.and( + f.text("FirstName").equals("John"), + f.text("LastName").equals("Doe") + ), + f.and( + f.text("FirstName").equals("Jane"), + f.text("LastName").equals("Doe") + ) +))(); + +// Get all employees who are managed by John and start today +const r3 = await sp.web.lists.getByTitle("ListName").items.filter(f => f.lookup("Manager").text("FirstName").equals("John").and().date("StartDate").isToday())(); +``` + ### Retrieving PublishingPageImage The PublishingPageImage and some other publishing-related fields aren't stored in normal fields, rather in the MetaInfo field. To get these values you need to use the technique shown below, and originally outlined in [this thread](https://github.com/SharePoint/PnP-JS-Core/issues/178). Note that a lot of information can be stored in this field so will pull back potentially a significant amount of data, so limit the rows as possible to aid performance. @@ -326,6 +409,8 @@ const sp = spfi(...); // you are getting back a collection here const items: any[] = await sp.web.lists.getByTitle("MyList").items.top(1).filter("Title eq 'A Title'")(); +// Using fluent filter +const items1: any[] = await sp.web.lists.getByTitle("MyList").items.top(1).filter(f => f.text("Title").equals("A Title"))(); // see if we got something if (items.length > 0) { @@ -425,6 +510,9 @@ const sp = spfi(...); // first we need to get the hidden field's internal name. // The Title of that hidden field is, in my case and in the linked article just the visible field name with "_0" appended. const fields = await sp.web.lists.getByTitle("TestList").fields.filter("Title eq 'MultiMetaData_0'").select("Title", "InternalName")(); +// Using fluent filter +const fields1 = await sp.web.lists.getByTitle("TestList").fields.filter(f => f.text("Title").equals("MultiMetaData_0")).select("Title", "InternalName")(); + // get an item to update, here we just create one for testing const newItem = await sp.web.lists.getByTitle("TestList").items.add({ Title: "Testing", @@ -593,6 +681,15 @@ const response = .filter(`Hidden eq false and Title eq '[Field's_Display_Name]'`) (); +// Using fluent filter +const response1 = + await sp.web.lists + .getByTitle('[Lists_Title]') + .fields + .select('Title, EntityPropertyName') + .filter(l => l.boolean("Hidden").isFalse().and().text("Title").equals("[Field's_Display_Name]")) + (); + console.log(response.map(field => { return { Title: field.Title, diff --git a/docs/sp/webs.md b/docs/sp/webs.md index c9e6e1b40..b57627542 100644 --- a/docs/sp/webs.md +++ b/docs/sp/webs.md @@ -254,12 +254,15 @@ const infos2 = await web.webinfos.select("Title", "Description")(); // or filter const infos3 = await web.webinfos.filter("Title eq 'MyWebTitle'")(); +// Using fluent filter +const infos4 = await web.webinfos.filter(w => w.text("Title").equals('MyWebTitle'))(); + // or both -const infos4 = await web.webinfos.select("Title", "Description").filter("Title eq 'MyWebTitle'")(); +const infos5 = await web.webinfos.select("Title", "Description").filter(w => w.text("Title").equals('MyWebTitle'))(); // get the top 4 ordered by Title -const infos5 = await web.webinfos.top(4).orderBy("Title")(); +const infos6 = await web.webinfos.top(4).orderBy("Title")(); ``` > Note: webinfos returns [IWebInfosData](#IWebInfosData) which is a subset of all the available fields on IWebInfo. @@ -537,9 +540,12 @@ const folders = await sp.web.folders(); // you can also filter and select as with any collection const folders2 = await sp.web.folders.select("ServerRelativeUrl", "TimeLastModified").filter("ItemCount gt 0")(); +// Using fluent filter +const folders3 = await sp.web.folders.select("ServerRelativeUrl", "TimeLastModified").filter(f => f.number("ItemCount").greaterThan(0))(); + // or get the most recently modified folder -const folders2 = await sp.web.folders.orderBy("TimeLastModified").top(1)(); +const folders4 = await sp.web.folders.orderBy("TimeLastModified").top(1)(); ``` ### rootFolder @@ -856,6 +862,9 @@ const users = await sp.web.siteUsers(); const users2 = await sp.web.siteUsers.top(5)(); const users3 = await sp.web.siteUsers.filter(`startswith(LoginName, '${encodeURIComponent("i:0#.f|m")}')`)(); +// Using fluent filter +const user4 = await sp.web.siteUsers.filter(u => u.text("LoginName").startsWith(encodeURIComponent("i:0#.f|m")))(); + ``` ### currentUser diff --git a/packages/sp/spqueryable.ts b/packages/sp/spqueryable.ts index 0eaa0669a..f6733b92a 100644 --- a/packages/sp/spqueryable.ts +++ b/packages/sp/spqueryable.ts @@ -141,8 +141,16 @@ export class _SPCollection extends _SPQueryable { * * @param filter The string representing the filter query */ - public filter(filter: string): this { - this.query.set("$filter", filter); + public filter>(filter: string | ComparisonResult | ((f: InitialFieldQuery) => ComparisonResult)): this { + if (typeof filter === "object") { + this.query.set("$filter", filter.toString()); + return this; + } + if (typeof filter === "function") { + this.query.set("$filter", filter(SPOData.Where()).toString()); + return this; + } + this.query.set("$filter", filter.toString()); return this; } @@ -254,3 +262,296 @@ export const spPostDeleteETag = (o: ISPQueryable, init?: RequestIn export const spDelete = (o: ISPQueryable, init?: RequestInit): Promise => op(o, del, init); export const spPatch = (o: ISPQueryable, init?: RequestInit): Promise => op(o, patch, init); + + + +type KeysMatching = { [K in keyof T]: T[K] extends V ? K : never }[keyof T]; +type KeysMatchingObjects = { [K in keyof T]: T[K] extends object ? (T[K] extends Date ? never : K) : never }[keyof T]; +type UnwrapArray = T extends (infer U)[] ? U : T; + +enum FilterOperation { + Equals = "eq", + NotEquals = "ne", + GreaterThan = "gt", + GreaterThanOrEqualTo = "ge", + LessThan = "lt", + LessThanOrEqualTo = "le", + StartsWith = "startswith", + SubstringOf = "substringof" +} + +enum FilterJoinOperator { + And = "and", + AndWithSpace = " and ", + Or = "or", + OrWithSpace = " or " +} + +class SPOData { + public static Where() { + return new InitialFieldQuery([]); + } +} + +// Linting complains that TBaseInterface is unused, but without it all the intellisense is lost since it's carrying it through the chain +class BaseQuery { + protected query: string[] = []; + + constructor(query: string[]) { + this.query = query; + } +} + + +class QueryableFields extends BaseQuery { + constructor(q: string[]) { + super(q); + } + + public text(internalName: KeysMatching): TextField { + return new TextField([...this.query, (internalName as string)]); + } + + public choice(internalName: KeysMatching): TextField { + return new TextField([...this.query, (internalName as string)]); + } + + public multiChoice(internalName: KeysMatching): TextField { + return new TextField([...this.query, (internalName as string)]); + } + + public number(internalName: KeysMatching): NumberField { + return new NumberField([...this.query, (internalName as string)]); + } + + public date(internalName: KeysMatching): DateField { + return new DateField([...this.query, (internalName as string)]); + } + + public boolean(internalName: KeysMatching): BooleanField { + return new BooleanField([...this.query, (internalName as string)]); + } + + public lookup>(internalName: TKey): LookupQueryableFields { + return new LookupQueryableFields([...this.query], internalName as string); + } + + public lookupId>(internalName: TKey): NumberField { + const col: string = (internalName as string).endsWith("Id") ? internalName as string : `${internalName as string}Id`; + return new NumberField([...this.query, col]); + } +} + +class QueryableAndResult extends QueryableFields { + public or(...queries: (ComparisonResult | ((f: QueryableFields) => ComparisonResult))[]): ComparisonResult { + return new ComparisonResult([...this.query, `(${queries.map(x => x.toString()).join(FilterJoinOperator.OrWithSpace)})`]); + } +} + +class QueryableOrResult extends QueryableFields { + public and(...queries: (ComparisonResult | ((f: QueryableFields) => ComparisonResult))[]): ComparisonResult { + return new ComparisonResult([...this.query, `(${queries.map(x => x.toString()).join(FilterJoinOperator.AndWithSpace)})`]); + } +} + +class InitialFieldQuery extends QueryableFields { + public or(): QueryableFields; + public or(...queries: (ComparisonResult | ((f: QueryableFields) => ComparisonResult))[]): ComparisonResult; + public or(...queries: (ComparisonResult | ((f: QueryableFields) => ComparisonResult))[]): (ComparisonResult | QueryableFields) { + if (queries == null || queries.length == 0) + return new QueryableFields([...this.query, FilterJoinOperator.Or]); + return new ComparisonResult([...this.query, `(${queries.map(x => x.toString()).join(FilterJoinOperator.OrWithSpace)})`]); + } + + public and(): QueryableFields; + public and(...queries: (ComparisonResult | ((f: QueryableFields) => ComparisonResult))[]): ComparisonResult + public and(...queries: (ComparisonResult | ((f: QueryableFields) => ComparisonResult))[]): (ComparisonResult | QueryableFields) { + if (queries == null || queries.length == 0) + return new QueryableFields([...this.query, FilterJoinOperator.And]); + return new ComparisonResult([...this.query, `(${queries.map(x => x.toString()).join(FilterJoinOperator.AndWithSpace)})`]); + } +} + + + +class LookupQueryableFields extends BaseQuery { + private LookupField: string; + constructor(q: string[], LookupField: string) { + super(q); + this.LookupField = LookupField; + } + + public Id(id: number): ComparisonResult { + return new ComparisonResult([...this.query, `${this.LookupField}/Id`, FilterOperation.Equals, id.toString()]); + } + + public text(internalName: KeysMatching): TextField { + return new TextField([...this.query, `${this.LookupField}/${internalName as string}`]); + } + + public number(internalName: KeysMatching): NumberField { + return new NumberField([...this.query, `${this.LookupField}/${internalName as string}`]); + } + + // Support has been announced, but is not yet available in SharePoint Online + // https://www.microsoft.com/en-ww/microsoft-365/roadmap?filters=&searchterms=100503 + // public boolean(InternalName: KeysMatching): BooleanField { + // return new BooleanField([...this.query, `${this.LookupField}/${InternalName as string}`]); + // } +} + +class NullableField extends BaseQuery { + protected LastIndex: number; + protected InternalName: string; + + constructor(q: string[]) { + super(q); + this.LastIndex = q.length - 1; + this.InternalName = q[this.LastIndex]; + } + + protected toODataValue(value: TInputValueType): string { + return `'${value}'`; + } + + public isNull(): ComparisonResult { + return new ComparisonResult([...this.query, FilterOperation.Equals, "null"]); + } + + public isNotNull(): ComparisonResult { + return new ComparisonResult([...this.query, FilterOperation.NotEquals, "null"]); + } +} + +class ComparableField extends NullableField { + public equals(value: TInputValueType): ComparisonResult { + return new ComparisonResult([...this.query, FilterOperation.Equals, this.toODataValue(value)]); + } + + public notEquals(value: TInputValueType): ComparisonResult { + return new ComparisonResult([...this.query, FilterOperation.NotEquals, this.toODataValue(value)]); + } + + public in(...values: TInputValueType[]): ComparisonResult { + return SPOData.Where().or(...values.map(x => this.equals(x))); + } + + public notIn(...values: TInputValueType[]): ComparisonResult { + return SPOData.Where().and(...values.map(x => this.notEquals(x))); + } +} + +class TextField extends ComparableField { + public startsWith(value: string): ComparisonResult { + const filter = `${FilterOperation.StartsWith}(${this.InternalName}, ${this.toODataValue(value)})`; + this.query[this.LastIndex] = filter; + return new ComparisonResult([...this.query]); + } + + public contains(value: string): ComparisonResult { + const filter = `${FilterOperation.SubstringOf}(${this.toODataValue(value)}, ${this.InternalName})`; + this.query[this.LastIndex] = filter; + return new ComparisonResult([...this.query]); + } +} + +class BooleanField extends NullableField { + protected override toODataValue(value: boolean | null): string { + return `${value == null ? "null" : value ? 1 : 0}`; + } + + public isTrue(): ComparisonResult { + return new ComparisonResult([...this.query, FilterOperation.Equals, this.toODataValue(true)]); + } + + public isFalse(): ComparisonResult { + return new ComparisonResult([...this.query, FilterOperation.Equals, this.toODataValue(false)]); + } + + public isFalseOrNull(): ComparisonResult { + const filter = `(${[ + this.InternalName, + FilterOperation.Equals, + this.toODataValue(null), + FilterJoinOperator.Or, + this.InternalName, + FilterOperation.Equals, + this.toODataValue(false), + ].join(" ")})`; + this.query[this.LastIndex] = filter; + return new ComparisonResult([...this.query]); + } +} + +class NumericField extends ComparableField { + public greaterThan(value: TInputValueType): ComparisonResult { + return new ComparisonResult([...this.query, FilterOperation.GreaterThan, this.toODataValue(value)]); + } + + public greaterThanOrEquals(value: TInputValueType): ComparisonResult { + return new ComparisonResult([...this.query, FilterOperation.GreaterThanOrEqualTo, this.toODataValue(value)]); + } + + public lessThan(value: TInputValueType): ComparisonResult { + return new ComparisonResult([...this.query, FilterOperation.LessThan, this.toODataValue(value)]); + } + + public lessThanOrEquals(value: TInputValueType): ComparisonResult { + return new ComparisonResult([...this.query, FilterOperation.LessThanOrEqualTo, this.toODataValue(value)]); + } +} + + +class NumberField extends NumericField { + protected override toODataValue(value: number): string { + return `${value}`; + } +} + +class DateField extends NumericField { + protected override toODataValue(value: Date): string { + return `'${value.toISOString()}'`; + } + + public isBetween(startDate: Date, endDate: Date): ComparisonResult { + const filter = `(${[ + this.InternalName, + FilterOperation.GreaterThan, + this.toODataValue(startDate), + FilterJoinOperator.And, + this.InternalName, + FilterOperation.LessThan, + this.toODataValue(endDate), + ].join(" ")})`; + this.query[this.LastIndex] = filter; + return new ComparisonResult([...this.query]); + } + + public isToday(): ComparisonResult { + const StartToday = new Date(); StartToday.setHours(0, 0, 0, 0); + const EndToday = new Date(); EndToday.setHours(23, 59, 59, 999); + return this.isBetween(StartToday, EndToday); + } +} + +class ComparisonResult extends BaseQuery { + public and(): QueryableAndResult; + public and(...queries: (ComparisonResult | ((f: QueryableFields) => ComparisonResult))[]): ComparisonResult + public and(...queries: (ComparisonResult | ((f: QueryableFields) => ComparisonResult))[]): (ComparisonResult | QueryableAndResult) { + if (queries == null || queries.length == 0) + return new QueryableAndResult([...this.query, FilterJoinOperator.And]); + return new ComparisonResult([...this.query, FilterJoinOperator.And, `(${queries.map(x => x.toString()).join(FilterJoinOperator.AndWithSpace)})`]); + } + + public or(): QueryableOrResult; + public or(...queries: (ComparisonResult | ((f: QueryableFields) => ComparisonResult))[]): ComparisonResult; + public or(...queries: (ComparisonResult | ((f: QueryableFields) => ComparisonResult))[]): (ComparisonResult | QueryableOrResult) { + if (queries == null || queries.length == 0) + return new QueryableOrResult([...this.query, FilterJoinOperator.Or]); + return new ComparisonResult([...this.query, FilterJoinOperator.Or, `(${queries.map(x => x.toString()).join(FilterJoinOperator.OrWithSpace)})`]); + } + + public toString(): string { + return this.query.join(" "); + } +} \ No newline at end of file