UNPKG

@statezero/core

Version:

The type-safe frontend client for StateZero - connect directly to your backend models with zero boilerplate

625 lines (624 loc) 22.7 kB
import { MultipleObjectsReturned, DoesNotExist, parseStateZeroError, } from "./errors.js"; import { Model } from "./model.js"; import { ModelSerializer } from "./serializers.js"; // Import the ModelSerializer import axios from "axios"; import { QueryExecutor } from "./queryExecutor.js"; import { json } from "stream/consumers"; import { v7 } from "uuid"; import hash from "object-hash"; import rfdc from "rfdc"; const clone = rfdc(); /** * A QuerySet provides a fluent API for constructing and executing queries. * * @template T */ export class QuerySet { /** * Creates a new QuerySet. * * @param {ModelConstructor} ModelClass - The model constructor. * @param {Object} [config={}] - The configuration for the QuerySet. * @param {QueryNode[]} [config.nodes] - Array of query nodes. * @param {Array<{ field: string, direction: 'asc'|'desc' }>} [config.orderBy] - Ordering configuration. * @param {Set<string>} [config.fields] - Set of fields to retrieve. * @param {Aggregation[]} [config.aggregations] - Aggregation operations. * @param {string} [config.initialQueryset] - The initial queryset identifier. * @param {SerializerOptions} [config.serializerOptions] - Serializer options. * @param {boolean} [config.materialized] - Whether the queryset is materialized. */ constructor(ModelClass, config = {}, parent = null) { this.ModelClass = ModelClass; this.nodes = config.nodes || []; this._orderBy = config.orderBy; this._fields = config.fields || new Set(); this._aggregations = config.aggregations || []; this._initialQueryset = config.initialQueryset; this._serializerOptions = config.serializerOptions || {}; this._materialized = config.materialized || false; this.__uuid = v7(); this.__parent = parent; this.__reactivityId = parent?.__reactivityId; // Initialize the serializer for this model this._serializer = new ModelSerializer(this.ModelClass); } /** * Clones this QuerySet, creating a new instance with the same configuration. * * @returns {QuerySet} A new QuerySet instance. */ clone() { return new QuerySet(this.ModelClass, { nodes: [...this.nodes], orderBy: this._orderBy ? [...this._orderBy] : undefined, fields: new Set(this._fields), aggregations: [...this._aggregations], initialQueryset: this._initialQueryset, serializerOptions: { ...this._serializerOptions }, materialized: this._materialized, }, this); } get semanticKey() { return JSON.stringify({ ModelClass: { configKey: this.ModelClass.configKey, modelName: this.ModelClass.modelName, }, ast: this.build(), }); } get key() { return this.__uuid; } /** * Ensures the QuerySet is still lazy (not materialized). * * @private * @throws {Error} If the QuerySet is already materialized. */ ensureNotMaterialized() { if (this._materialized) { throw new Error("Cannot chain further operations on a materialized QuerySet."); } } /** * Returns the model constructor for this QuerySet. * * @returns {ModelConstructor} The model constructor. */ get modelClass() { return this.ModelClass; } /** * Sets serializer options for the QuerySet. * * @param {SerializerOptions} options - The serializer options to set. * @returns {QuerySet} This QuerySet instance for chaining. */ setSerializerOptions(options) { this._serializerOptions = { ...this._serializerOptions, ...options }; return this; } /** * Serializes filter conditions using the model serializer. * * @private * @param {Object} conditions - The filter conditions to serialize. * @returns {Object} The serialized conditions. */ _serializeConditions(conditions) { if (!conditions || typeof conditions !== "object") { return conditions; } // Use the model serializer to convert conditions to internal format return this._serializer.toInternal(conditions); } /** * Filters the QuerySet with the provided conditions. * * @param {Object} conditions - The filter conditions. * @returns {QuerySet} A new QuerySet with the filter applied. */ filter(conditions) { this.ensureNotMaterialized(); const { Q: qConditions, ...filters } = conditions; const newNodes = [...this.nodes]; if (Object.keys(filters).length > 0) { // Serialize the filter conditions before adding to the node const serializedFilters = this._serializeConditions(filters); newNodes.push({ type: "filter", conditions: serializedFilters, }); } if (qConditions && qConditions.length) { newNodes.push({ type: "and", children: qConditions.map((q) => this.processQObject(q)), }); } return new QuerySet(this.ModelClass, { ...this._getConfig(), nodes: newNodes, }, this); } /** * Excludes the specified conditions from the QuerySet. * * @param {Object} conditions - The conditions to exclude. * @returns {QuerySet} A new QuerySet with the exclusion applied. */ exclude(conditions) { this.ensureNotMaterialized(); const { Q: qConditions, ...filters } = conditions; const newNodes = [...this.nodes]; let childNode = null; if (Object.keys(filters).length > 0 && qConditions && qConditions.length) { // Serialize the filter conditions const serializedFilters = this._serializeConditions(filters); childNode = { type: "and", children: [ { type: "filter", conditions: serializedFilters }, { type: "and", children: qConditions.map((q) => this.processQObject(q)), }, ], }; } else if (Object.keys(filters).length > 0) { // Serialize the filter conditions const serializedFilters = this._serializeConditions(filters); childNode = { type: "filter", conditions: serializedFilters, }; } else if (qConditions && qConditions.length) { childNode = { type: "and", children: qConditions.map((q) => this.processQObject(q)), }; } const excludeNode = { type: "exclude", child: childNode, }; newNodes.push(excludeNode); return new QuerySet(this.ModelClass, { ...this._getConfig(), nodes: newNodes, }, this); } /** * Orders the QuerySet by the specified fields. * * @param {...string} fields - Fields to order by. * @returns {QuerySet} A new QuerySet with ordering applied. */ orderBy(...fields) { this.ensureNotMaterialized(); return new QuerySet(this.ModelClass, { ...this._getConfig(), orderBy: fields, }, this); } /** * Applies a search to the QuerySet using the specified search query and fields. * * @param {string} searchQuery - The search query. * @param {string[]} [searchFields] - The fields to search. * @returns {QuerySet} A new QuerySet with the search applied. */ search(searchQuery, searchFields) { this.ensureNotMaterialized(); const newNodes = [...this.nodes]; newNodes.push({ type: "search", searchQuery, searchFields: searchFields, }); return new QuerySet(this.ModelClass, { ...this._getConfig(), nodes: newNodes, }, this); } /** * Processes a Q object or condition into a QueryNode. * * @private * @param {QObject|QCondition} q - The query object or condition. * @returns {QueryNode} The processed QueryNode. */ processQObject(q) { if ("operator" in q && "conditions" in q) { return { type: q.operator === "AND" ? "and" : "or", children: Array.isArray(q.conditions) ? q.conditions.map((c) => this.processQObject(c)) : [], }; } else { // Serialize the conditions in Q objects as well const serializedConditions = this._serializeConditions(q); return { type: "filter", conditions: serializedConditions, }; } } /** * Aggregates the QuerySet using the specified function. * * @param {AggregateFunction} fn - The aggregation function. * @param {string} field - The field to aggregate. * @param {string} [alias] - An optional alias for the aggregated field. * @returns {QuerySet} A new QuerySet with the aggregation applied. */ aggregate(fn, field, alias) { this.ensureNotMaterialized(); return new QuerySet(this.ModelClass, { ...this._getConfig(), aggregations: [ ...this._aggregations, { function: fn, field: field, alias, }, ], }, this); } /** * Executes a count query on the QuerySet. * * @param {string} [field] - The field to count. * @returns {Promise<number>} A promise that resolves to the count. */ count(field) { this.ensureNotMaterialized(); const newQs = new QuerySet(this.ModelClass, { ...this._getConfig(), materialized: true, }, this); return QueryExecutor.execute(newQs, "count", { field: field || this.ModelClass.primaryKeyField, }); } /** * Executes a sum aggregation on the QuerySet. * * @param {string} field - The field to sum. * @returns {Promise<number>} A promise that resolves to the sum. */ sum(field) { this.ensureNotMaterialized(); const newQs = new QuerySet(this.ModelClass, { ...this._getConfig(), materialized: true, }, this); return QueryExecutor.execute(newQs, "sum", { field }); } /** * Executes an average aggregation on the QuerySet. * * @param {string} field - The field to average. * @returns {Promise<number>} A promise that resolves to the average. */ avg(field) { this.ensureNotMaterialized(); const newQs = new QuerySet(this.ModelClass, { ...this._getConfig(), materialized: true, }, this); return QueryExecutor.execute(newQs, "avg", { field }); } /** * Executes a min aggregation on the QuerySet. * * @param {string} field - The field to find the minimum value for. * @returns {Promise<any>} A promise that resolves to the minimum value. */ min(field) { this.ensureNotMaterialized(); const newQs = new QuerySet(this.ModelClass, { ...this._getConfig(), materialized: true, }, this); return QueryExecutor.execute(newQs, "min", { field }); } /** * Executes a max aggregation on the QuerySet. * * @param {string} field - The field to find the maximum value for. * @returns {Promise<any>} A promise that resolves to the maximum value. */ max(field) { this.ensureNotMaterialized(); const newQs = new QuerySet(this.ModelClass, { ...this._getConfig(), materialized: true, }, this); return QueryExecutor.execute(newQs, "max", { field }); } /** * Retrieves the first record of the QuerySet. * * @param {SerializerOptions} [serializerOptions] - Optional serializer options. * @returns {Promise<T|null>} A promise that resolves to the first record or null. */ first(serializerOptions) { this.ensureNotMaterialized(); const newQs = new QuerySet(this.ModelClass, { ...this._getConfig(), serializerOptions: serializerOptions ? { ...this._serializerOptions, ...serializerOptions } : this._serializerOptions, materialized: true, }, this); return QueryExecutor.execute(newQs, "first"); } /** * Retrieves the last record of the QuerySet. * * @param {SerializerOptions} [serializerOptions] - Optional serializer options. * @returns {Promise<T|null>} A promise that resolves to the last record or null. */ last(serializerOptions) { this.ensureNotMaterialized(); const newQs = new QuerySet(this.ModelClass, { ...this._getConfig(), serializerOptions: serializerOptions ? { ...this._serializerOptions, ...serializerOptions } : this._serializerOptions, materialized: true, }, this); return QueryExecutor.execute(newQs, "last"); } /** * Checks if any records exist in the QuerySet. * * @returns {Promise<boolean>} A promise that resolves to true if records exist, otherwise false. */ exists() { this.ensureNotMaterialized(); const newQs = new QuerySet(this.ModelClass, { ...this._getConfig(), materialized: true, }, this); return QueryExecutor.execute(newQs, "exists"); } /** * Applies serializer options to the QuerySet. * * @param {SerializerOptions} [serializerOptions] - Optional serializer options. * @returns {QuerySet} A new QuerySet with the serializer options applied. */ all(serializerOptions) { this.ensureNotMaterialized(); if (serializerOptions) { return new QuerySet(this.ModelClass, { ...this._getConfig(), serializerOptions: { ...this._serializerOptions, ...serializerOptions, }, }, this); } return this; } /** * Creates a new record in the QuerySet. * @param {Object} data - The fields and values for the new record. * @returns {Promise<any>} The created model instance. */ async create(data) { this.ensureNotMaterialized(); // Serialize the data before sending to backend const serializedData = this._serializer.toInternal(data); // Materialize for create const newQs = new QuerySet(this.ModelClass, { ...this._getConfig(), materialized: true, }, this); return QueryExecutor.execute(newQs, "create", { data: serializedData }); } /** * Updates records in the QuerySet. * * @param {Object} updates - The fields to update. * @returns {Promise<[number, Object]>} A promise that resolves to a tuple with the number of updated records and a mapping of model names to counts. */ update(updates) { if (arguments.length > 1) { throw new Error("Update accepts only accepts an object of the updates to apply. Use filter() before calling update() to select elements."); } this.ensureNotMaterialized(); // Serialize the updates before sending to backend const serializedUpdates = this._serializer.toInternal(updates); const newQs = new QuerySet(this.ModelClass, { ...this._getConfig(), materialized: true, }); return QueryExecutor.execute(newQs, "update", { data: serializedUpdates }); } /** * Deletes records in the QuerySet. * * @returns {Promise<[number, Object]>} A promise that resolves to a tuple with the number of deleted records and a mapping of model names to counts. */ delete() { if (arguments.length > 0) { throw new Error("delete() does not accept arguments and will delete the entire queryset. Use filter() before calling delete() to select elements."); } this.ensureNotMaterialized(); const newQs = new QuerySet(this.ModelClass, { ...this._getConfig(), materialized: true, }, this); return QueryExecutor.execute(newQs, "delete"); } /** * Retrieves a single record from the QuerySet. * * @param {Object} [filters] - Optional filters to apply. * @param {SerializerOptions} [serializerOptions] - Optional serializer options. * @returns {Promise<T>} A promise that resolves to the retrieved record. * @throws {MultipleObjectsReturned} If more than one record is found. * @throws {DoesNotExist} If no records are found. */ get(filters, serializerOptions) { this.ensureNotMaterialized(); let newQs = this; if (filters) { newQs = this.filter(filters); } if (serializerOptions) { newQs = new QuerySet(this.ModelClass, { ...newQs._getConfig(), serializerOptions: { ...newQs._serializerOptions, ...serializerOptions, }, }, this); } const materializedQs = new QuerySet(this.ModelClass, { ...newQs._getConfig(), materialized: true, }, this); const result = QueryExecutor.execute(materializedQs, "get"); if (result === null) { throw new DoesNotExist(); } return result; } /** * Gets or creates a record based on the provided lookup parameters. * * @param {Object} lookupParams - The lookup parameters to find the record. * @param {Object} [defaults={}] - Default values to use when creating a new record. * @returns {Promise<[T, boolean]>} A promise that resolves to a tuple of [instance, created]. */ async getOrCreate(lookupParams, defaults = {}) { this.ensureNotMaterialized(); // Serialize both lookup params and defaults const serializedLookup = this._serializer.toInternal(lookupParams); const serializedDefaults = this._serializer.toInternal(defaults); const newQs = new QuerySet(this.ModelClass, { ...this._getConfig(), materialized: true, }, this); return QueryExecutor.execute(newQs, "get_or_create", { lookup: serializedLookup, defaults: serializedDefaults, }); } /** * Updates or creates a record based on the provided lookup parameters. * * @param {Object} lookupParams - The lookup parameters to find the record. * @param {Object} [defaults={}] - Default values to use when creating or updating. * @returns {Promise<[T, boolean]>} A promise that resolves to a tuple of [instance, created]. */ async updateOrCreate(lookupParams, defaults = {}) { this.ensureNotMaterialized(); // Serialize both lookup params and defaults const serializedLookup = this._serializer.toInternal(lookupParams); const serializedDefaults = this._serializer.toInternal(defaults); const newQs = new QuerySet(this.ModelClass, { ...this._getConfig(), materialized: true, }, this); return QueryExecutor.execute(newQs, "update_or_create", { lookup: serializedLookup, defaults: serializedDefaults, }); } /** * Builds the final query object to be sent to the backend (simple jsonable object format). * * @returns {Object} The final query object. */ build() { let searchData = null; const nonSearchNodes = []; for (const node of this.nodes) { if (node.type === "search") { searchData = { searchQuery: node.searchQuery || "", searchFields: node.searchFields, }; } else { nonSearchNodes.push(node); } } const filterNode = nonSearchNodes.length === 0 ? null : nonSearchNodes.length === 1 ? nonSearchNodes[0] : { type: "and", children: nonSearchNodes, }; return clone({ filter: filterNode, search: searchData, aggregations: this._aggregations, orderBy: this._orderBy, serializerOptions: this._serializerOptions, }); } /** * Returns the current configuration of the QuerySet. * * @private * @returns {Object} The current QuerySet configuration. */ _getConfig() { return { nodes: this.nodes, orderBy: this._orderBy, fields: this._fields, aggregations: this._aggregations, initialQueryset: this._initialQueryset, serializerOptions: this._serializerOptions, }; } /** * Materializes the QuerySet into an array of model instances. * * @param {SerializerOptions} [serializerOptions] - Optional serializer options. * @returns {Promise<T[]>} A promise that resolves to an array of model instances. */ fetch(serializerOptions) { let querySet = this; if (serializerOptions) { querySet = new QuerySet(this.ModelClass, { ...this._getConfig(), serializerOptions: { ...this._serializerOptions, ...serializerOptions, }, }, this); } const materializedQs = new QuerySet(this.ModelClass, { ...querySet._getConfig(), materialized: true, }, this); return QueryExecutor.execute(materializedQs, "list"); } /** * Implements the async iterator protocol so that you can iterate over the QuerySet. * * @returns {AsyncIterator<T>} An async iterator over the model instances. */ async *[Symbol.asyncIterator]() { const items = await this.fetch(); for (const item of items) { yield item; } } }