UNPKG

@statezero/core

Version:

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

723 lines (722 loc) 27 kB
import { MultipleObjectsReturned, DoesNotExist, parseStateZeroError, } from "./errors.js"; import { Model } from "./model.js"; import { ModelSerializer, relationshipFieldSerializer, dateFieldSerializer } from "./serializers.js"; 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._optimisticOnly = config.optimisticOnly || 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, optimisticOnly: this._optimisticOnly, }, 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; } const serializedConditions = {}; for (const [fieldPath, value] of Object.entries(conditions)) { serializedConditions[fieldPath] = this._serializeValue(value); } return serializedConditions; } /** * Serializes a value based on its type (handles arrays, Model instances, Dates, primitives) * * @private * @param {any} value - The value to serialize * @returns {any} The serialized value */ _serializeValue(value) { // Handle arrays (for __in lookups) if (Array.isArray(value)) { return value.map(item => this._serializeValue(item)); } // Handle Model instances (objects with pk, serialize method, and constructor with configKey/modelName) // Note: Model instances are objects, not classes (typeof instance === 'object') if (value && typeof value === 'object' && !(value instanceof Date) && // Exclude Date objects 'pk' in value && 'serialize' in value && typeof value.serialize === 'function' && value.constructor && 'configKey' in value.constructor && 'modelName' in value.constructor) { return relationshipFieldSerializer.toInternal(value); } // Handle Date objects // Without field context, we default to datetime format (ISO string with time) // Unless it's exactly midnight in UTC, which likely indicates a date-only field if (value instanceof Date) { // Check if it's midnight UTC (likely a date-only value) const hours = value.getUTCHours(); const minutes = value.getUTCMinutes(); const seconds = value.getUTCSeconds(); const milliseconds = value.getUTCMilliseconds(); if (hours === 0 && minutes === 0 && seconds === 0 && milliseconds === 0) { // It's midnight UTC - serialize as date-only (YYYY-MM-DD) return value.toISOString().split('T')[0]; } else { // Has time component - serialize as full datetime return value.toISOString(); } } // Everything else (strings, numbers, booleans, null) - return as-is return value; } /** * 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; } /** * Internal method to create an optimistic-only QuerySet. * @private * @returns {QuerySet} A new QuerySet with optimistic-only mode enabled. */ _optimistic() { this.ensureNotMaterialized(); return new QuerySet(this.ModelClass, { ...this._getConfig(), optimisticOnly: true, }, this); } /** * Returns a QuerySet marked as optimistic-only, meaning operations will only * update local state without making backend API calls. * * @returns {QuerySet} A new QuerySet with optimistic-only mode enabled. */ get optimistic() { return this._optimistic(); } /** * 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 }); } /** * Creates multiple model instances using the provided model instances. * * @param {Array<Model>} modelInstances - Array of unsaved model instances to create. * @returns {Promise<Array<any>>} A promise that resolves to an array of newly created model instances. */ async bulkCreate(modelInstances) { this.ensureNotMaterialized(); if (!Array.isArray(modelInstances)) { throw new Error("bulkCreate expects an array of model instances"); } // Serialize each model instance using model.serialize() const serializedDataList = modelInstances.map(instance => { if (!instance || typeof instance.serialize !== 'function') { throw new Error("bulkCreate requires model instances. Did you pass a plain object instead?"); } return instance.serialize(); }); // Materialize for bulk create const newQs = new QuerySet(this.ModelClass, { ...this._getConfig(), materialized: true, }, this); return QueryExecutor.execute(newQs, "bulk_create", { data: serializedDataList }); } /** * 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 || this.ModelClass.schema.searchable_fields }; } 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, optimisticOnly: this._optimisticOnly, }; } /** * 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; } } }