@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
JavaScript
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;
}
}
}