spacer
Skip to content

@pnp/queryable/queryable

Queryable is the base class for both the sp and graph fluent interfaces and provides the structure to which observers are registered. As a background to understand more of the mechanics please see the articles on Timeline, moments, and observers. For reuse it is recommended to compose your observer registrations with behaviors.

Queryable Constructor

By design the library is meant to allow creating the next part of a url from the current part. In this way each queryable instance is built from a previous instance. As such understanding the Queryable constructor's behavior is important. The constructor takes two parameters, the first required and the second optional.

The first parameter can be another queryable, a string, or a tuple of [Queryable, string].

Parameter Behavior
Queryable The new queryable inherits all of the supplied queryable's observers. Any supplied path (second constructor param) is appended to the supplied queryable's url becoming the url of the newly constructed queryable
string The new queryable will have NO registered observers. Any supplied path (second constructor param) is appended to the string becoming the url of the newly constructed queryable
[Queryable, string] The observers from the supplied queryable are used by the new queryable. The url is a combination of the second tuple argument (absolute url string) and any supplied path.

The tuple constructor call can be used to rebase a queryable to call a different host in an otherwise identical way to another queryable. When using the tuple constructor the url provided must be absolute.

Examples

// represents a fully configured queryable with url and registered observers
// url: https://something.com
const baseQueryable;

// child1 will:
// - reference the observers of baseQueryable
// - have a url of "https://something.com/subpath"
const child1 = Child(baseQueryable, "subpath");

// child2 will:
// - reference the observers of baseQueryable
// - have a url of "https://something.com"
const child2 = Child(baseQueryable);

// nonchild1 will:
// - have NO registered observers or connection to baseQueryable
// - have a url of "https://somethingelse.com"
const nonchild1 = Child("https://somethingelse.com");

// nonchild2 will:
// - have NO registered observers or connection to baseQueryable
// - have a url of "https://somethingelse.com/subpath"
const nonchild2 = Child("https://somethingelse.com", "subpath");

// rebased1 will:
// - reference the observers of baseQueryable
// - have a url of "https://somethingelse.com"
const rebased1 = Child([baseQueryable, "https://somethingelse.com"]);

// rebased2 will:
// - reference the observers of baseQueryable
// - have a url of "https://somethingelse.com/subpath"
const rebased2 = Child([baseQueryable, "https://somethingelse.com"], "subpath");

Queryable Lifecycle

The Queryable lifecycle is:

  • construct (Added in 3.5.0)
  • init
  • pre
  • auth
  • send
  • parse
  • post
  • data
  • dispose

As well log and error can emit at any point during the lifecycle.

No observers registered for this request

If you see an error thrown with the message No observers registered for this request. it means at the time of execution the given object has no actions to take. Because all the request logic is defined within observers, an absence of observers is likely an error condition. If the object was created by a method within the library please report an issue as it is likely a bug. If you created the object through direct use of one of the factory functions, please be sure you have registered observers with using or on as appropriate. More information on observers is available in this article.

If you for some reason want to execute a queryable with no registred observers, you can simply register a noop observer to any of the moments.

Queryable Observers

This section outlines how to write observers for the Queryable lifecycle, and the expectations of each moment's observer behaviors.

In the below samples consider the variable query to mean any valid Queryable derived object.

log

Anything can log to a given timeline's log using the public log method and to intercept those message you can subscribed to the log event.

The log observer's signature is: (this: Timeline<T>, message: string, level: number) => void

query.on.log((message, level) => {

    // log only warnings or errors
    if (level > 1) {
        console.log(message);
    }
});

The level value is a number indicating the severity of the message. Internally we use the values from the LogLevel enum in @pnp/logging: Verbose = 0, Info = 1, Warning = 2, Error = 3. Be aware that nothing enforces those values other than convention and log can be called with any value for level.

As well we provide easy support to use PnP logging within a Timeline derived class:

import { LogLevel, PnPLogging } from "@pnp/logging";

// any messages of LogLevel Info or higher (1) will be logged to all subscribers of the logging framework
query.using(PnPLogging(LogLevel.Info));

More details on the pnp logging framework

error

Errors can happen at anytime and for any reason. If you are using the RejectOnError behavior, and both sp and graph include that in the defaults, the request promise will be rejected as expected and you can handle the error that way.

The error observer's signature is: (this: Timeline<T>, err: string | Error) => void

import { spfi, DefaultInit, DefaultHeaders } from "@pnp/sp";
import { BrowserFetchWithRetry, DefaultParse } from "@pnp/queryable";
import "@pnp/sp/webs";

const sp = spfi().using(DefaultInit(), DefaultHeaders(), BrowserFetchWithRetry(), DefaultParse());

try {

    const result = await sp.web();

} catch(e) {

    // any errors emitted will result in the promise being rejected
    // and ending up in the catch block as expected
}

In addition to the default behavior you can register your own observers on error, though it is recommended you leave the default behavior in place.

query.on.error((err) => {

    if (err) {
        console.error(err);
        // do other stuff with the error (send it to telemetry)
    }
});

construct

Added in 3.5.0

This moment exists to assist behaviors that need to transfer some information from a parent to a child through the fluent chain. We added this to support cancelable scopes for the Cancelable behavior, but it may have other uses. It is invoked AFTER the new instance is fully realized via new and supplied with the parameters used to create the new instance. As with all moments the "this" within the observer is the current (NEW) instance.

For your observers on the construct method to work correctly they must be registered before the instance is created.

The construct moment is NOT async and is designed to support simple operations.

query.on.construct(function (this: Queryable, init: QueryableInit, path?: string): void {
    if (typeof init !== "string") {

        // get a ref to the parent Queryable instance used to create this new instance
        const parent = isArray(init) ? init[0] : init;

        if (Reflect.has(parent, "SomeSpecialValueKey")) {

            // copy that specail value to the new child
            this["SomeSpecialValueKey"] = parent["SomeSpecialValueKey"];
        }
    }     
});

query.on.pre(async function(url, init, result) {

    // we have access to the copied special value throughout the lifecycle
    this.log(this["SomeSpecialValueKey"]);

    return [url, init, result];
});

query.on.dispose(() => {

    // be a good citizen and clean up your behavior's values when you're done
    delete this["SomeSpecialValueKey"];
});

init

Along with dispose, init is a special moment that occurs before any of the other lifecycle providing a first chance at doing any tasks before the rest of the lifecycle starts. It is not await aware so only sync operations are supported in init by design.

The init observer's signature is: (this: Timeline<T>) => void

In the case of init you manipulate the Timeline instance itself

query.on.init(function (this: Queryable) {

    // init is a great place to register additioanl observers ahead of the lifecycle
    this.on.pre(async function (this: Quyerable, url, init, result) {
        // stuff happens
        return [url, init, result];
    });
});

pre

Pre is used by observers to configure the request before sending. Note there is a dedicated auth moment which is prefered by convention to handle auth related tasks.

The pre observer's signature is: (this: IQueryable, url: string, init: RequestInit, result: any) => Promise<[string, RequestInit, any]>

The pre, auth, parse, and post are asyncReduce moments, meaning you are expected to always asyncronously return a tuple of the arguments supplied to the function. These are then passed to the next observer registered to the moment.

Example of when to use pre are updates to the init, caching scenarios, or manipulation of the url (ensuring it is absolute). The init passed to pre (and auth) is the same object that will be eventually passed to fetch, meaning you can add any properties/congifuration you need. The result should always be left undefined unless you intend to end the lifecycle. If pre completes and result has any value other than undefined that value will be emitted to data and the timeline lifecycle will end.

query.on.pre(async function(url, init, result) {

    init.cache = "no-store";

    return [url, init, result];
});

query.on.pre(async function(url, init, result) {

    // setting result causes no moments after pre to be emitted other than data
    // once data is emitted (resolving the request promise by default) the lifecycle ends
    result = "My result";

    return [url, init, result];
});

auth

Auth functions very much like pre except it does not have the option to set the result, and the url is considered immutable by convention. Url manipulation should be done in pre. Having a seperate moment for auth allows for easily changing auth specific behavior without having to so a lot of complicated parsing of pre observers.

The auth observer's signature is: (this: IQueryable, url: URL, init: RequestInit) => Promise<[URL, RequestInit]>.

The pre, auth, parse, and post are asyncReduce moments, meaning you are expected to always asyncronously return a tuple of the arguments supplied to the function. These are then passed to the next observer registered to the moment.

query.on.auth(async function(url, init) {

    // some code to get a token
    const token = getToken();

    init.headers["Authorization"] = `Bearer ${token}`;

    return [url, init];
});

send

Send is implemented using the request moment which uses the first registered observer and invokes it expecting an async Response.

The send observer's signature is: (this: IQueryable, url: URL, init: RequestInit) => Promise<Response>.

query.on.send(async function(url, init) {

    // this could represent reading a file, querying a database, or making a web call
    return fetch(url.toString(), init);
});

parse

Parse is responsible for turning the raw Response into something usable. By default we handle errors and parse JSON responses, but any logic could be injected here. Perhaps your company encrypts things and you need to decrypt them before parsing further.

The parse observer's signature is: (this: IQueryable, url: URL, response: Response, result: any | undefined) => Promise<[URL, Response, any]>.

The pre, auth, parse, and post are asyncReduce moments, meaning you are expected to always asyncronously return a tuple of the arguments supplied to the function. These are then passed to the next observer registered to the moment.

// you should be careful running multiple parse observers so we replace with our functionality
// remember every registered observer is run, so if you set result and a later observer sets a
// different value last in wins.
query.on.parse.replace(async function(url, response, result) {

    if (response.ok) {

        result = await response.json();

    } else {

        // just an example
        throw Error(response.statusText);
    }

    return [url, response, result];
});

post

Post is run after parse, meaning you should have a valid fully parsed result, and provides a final opportunity to do caching, some final checks, or whatever you might need immediately prior to the request promise resolving with the value. It is recommened to NOT manipulate the result within post though nothing prevents you from doing so.

The post observer's signature is: (this: IQueryable, url: URL, result: any | undefined) => Promise<[URL, any]>.

The pre, auth, parse, and post are asyncReduce moments, meaning you are expected to always asyncronously return a tuple of the arguments supplied to the function. These are then passed to the next observer registered to the moment.

query.on.post(async function(url, result) {

    // here we do some caching of a result
    const key = hash(url);
    cache(key, result);   

    return [url, result];
});

data

Data is called with the result of the Queryable lifecycle produced by send, understood by parse, and passed through post. By default the request promise will resolve with the value, but you can add any additional observers you need.

The data observer's signature is: (this: IQueryable, result: T) => void.

Clearing the data moment (ie. .on.data.clear()) after the lifecycle has started will result in the request promise never resolving

query.on.data(function(result) {

    console.log(`Our result! ${JSON.stringify(result)}`);
});

dispose

Along with init, dispose is a special moment that occurs after all other lifecycle moments have completed. It is not await aware so only sync operations are supported in dispose by design.

The dispose observer's signature is: (this: Timeline<T>) => void

In the case of dispose you manipulate the Timeline instance itself

query.on.dispose(function (this: Queryable) {

    // maybe your queryable calls a database?
    db.connection.close();
});

Other Methods

Queryable exposes some additional methods beyond the observer registration.

concat

Appends the supplied string to the url without mormalizing slashes.

// url: something.com/items
query.concat("(ID)");
// url: something.com/items(ID)

toRequestUrl

Converts the queryable's internal url parameters (url and query) into a relative or absolute url.

const s = query.toRequestUrl();

query

Map used to manage any query string parameters that will be included. Anything added here will be represented in toRequestUrl's output.

query.query.add("$select", "Title");

toUrl

Returns the url currently represented by the Queryable, without the querystring part

const s = query.toUrl();