convex-helpers
Version:
A collection of useful code to complement the official convex package.
421 lines (403 loc) • 15.9 kB
text/typescript
import {
FieldTypeFromFieldPath,
TableNamesInDataModel,
GenericDataModel,
GenericDatabaseReader,
DocumentByName,
SystemTableNames,
NamedIndex,
NamedTableInfo,
IndexNames,
FieldPaths,
} from "convex/server";
import { GenericId } from "convex/values";
import { asyncMap, nullThrows } from "..";
/**
* getAll returns a list of Documents (or null) for the `Id`s passed in.
*
* Nulls are returned for documents not found.
* @param db A DatabaseReader, usually passed from a mutation or query ctx.
* @param ids An list (or other iterable) of Ids pointing to a table.
* @returns The Documents referenced by the Ids, in order. `null` if not found.
*/
export async function getAll<
DataModel extends GenericDataModel,
TableName extends TableNamesInDataModel<DataModel>
>(
db: GenericDatabaseReader<DataModel>,
ids: Iterable<GenericId<TableName>> | Promise<Iterable<GenericId<TableName>>>
): Promise<(DocumentByName<DataModel, TableName> | null)[]> {
return asyncMap(ids, db.get);
}
/**
* getAllOrThrow returns a list of Documents for the `Id`s passed in.
*
* It throws if any documents are not found (null).
* @param db A DatabaseReader, usually passed from a mutation or query ctx.
* @param ids An list (or other iterable) of Ids pointing to a table.
* @returns The Documents referenced by the Ids, in order. `null` if not found.
*/
export async function getAllOrThrow<
DataModel extends GenericDataModel,
TableName extends TableNamesInDataModel<DataModel>
>(
db: GenericDatabaseReader<DataModel>,
ids: Iterable<GenericId<TableName>> | Promise<Iterable<GenericId<TableName>>>
): Promise<DocumentByName<DataModel, TableName>[]> {
return await asyncMap(ids, async (id) => nullThrows(await db.get(id)));
}
type UserIndexes<
DataModel extends GenericDataModel,
TableName extends TableNamesInDataModel<DataModel>
> = Exclude<
IndexNames<NamedTableInfo<DataModel, TableName>>,
"by_creation_time"
> &
string;
type TablesWithLookups<DataModel extends GenericDataModel> = {
[T in TableNamesInDataModel<DataModel>]: UserIndexes<
DataModel,
T
> extends never
? never
: T;
}[TableNamesInDataModel<DataModel>];
type FirstIndexField<
DataModel extends GenericDataModel,
TableName extends TableNamesInDataModel<DataModel>,
IndexName extends IndexNames<NamedTableInfo<DataModel, TableName>>
> = NamedIndex<NamedTableInfo<DataModel, TableName>, IndexName>[0];
type TypeOfFirstIndexField<
DataModel extends GenericDataModel,
TableName extends TableNamesInDataModel<DataModel>,
IndexName extends IndexNames<NamedTableInfo<DataModel, TableName>>
> = IndexName extends IndexNames<NamedTableInfo<DataModel, TableName>>
? FieldTypeFromFieldPath<
DocumentByName<DataModel, TableName>,
NamedIndex<NamedTableInfo<DataModel, TableName>, IndexName>[0]
>
: never;
// `FieldPath`s that have an index starting with them
// e.g. `.index("...", [FieldPath, ...])` on the table.
type LookupFieldPaths<
DataModel extends GenericDataModel,
TableName extends TableNamesInDataModel<DataModel>
> = {
[IndexName in UserIndexes<DataModel, TableName>]: FirstIndexField<
DataModel,
TableName,
IndexName
>;
}[UserIndexes<DataModel, TableName>];
// If the index is named after the first field, then the field name is optional.
// To be used as a spread argument to optionally require the field name.
// It also allows a field to have an index `by_${field}`, though this means
// it doesn't allow fields that start with a `by_` prefix.
type FieldIfDoesntMatchIndex<
DataModel extends GenericDataModel,
TableName extends TableNamesInDataModel<DataModel>,
IndexName extends UserIndexes<DataModel, TableName>
> = FirstIndexField<DataModel, TableName, IndexName> extends IndexName
? // Enforce the variable itself doesn't start with "by_"
IndexName extends `by_${infer _}`
? never
: [FirstIndexField<DataModel, TableName, IndexName>?]
: `by_${FirstIndexField<DataModel, TableName, IndexName>}` extends IndexName
? [FirstIndexField<DataModel, TableName, IndexName>?]
: [FirstIndexField<DataModel, TableName, IndexName>];
function firstIndexField<
DataModel extends GenericDataModel,
TableName extends TablesWithLookups<DataModel>,
IndexName extends UserIndexes<DataModel, TableName>
>(
index: IndexName,
field?: FirstIndexField<DataModel, TableName, IndexName>
): FirstIndexField<DataModel, TableName, IndexName> {
if (field) return field;
if (index.startsWith("by_")) return index.slice(3);
return index;
}
/**
* Get a document matching the given value for a specified field.
*
* `null` if not found.
* Useful for fetching a document with a one-to-one relationship via backref.
* Requires the table to have an index on the field named the same as the field.
* e.g. `defineTable({ fieldA: v.string() }).index("fieldA", ["fieldA"])`
*
* Getting 'string' is not assignable to parameter of type 'never'?
* Make sure your index is named after your field.
*
* @param db DatabaseReader, passed in from the function ctx
* @param table The table to fetch the target document from.
* @param index The index on that table to look up the specified value by.
* @param value The value to look up the document by, often an ID.
* @param field The field on that table that should match the specified value.
* Optional if the index is named after the field.
* @returns The document matching the value, or null if none found.
*/
export async function getOneFrom<
DataModel extends GenericDataModel,
TableName extends TablesWithLookups<DataModel>,
IndexName extends UserIndexes<DataModel, TableName>
>(
db: GenericDatabaseReader<DataModel>,
table: TableName,
index: IndexName,
value: TypeOfFirstIndexField<DataModel, TableName, IndexName>,
...fieldArg: FieldIfDoesntMatchIndex<DataModel, TableName, IndexName>
): Promise<DocumentByName<DataModel, TableName> | null> {
const field = firstIndexField(index, fieldArg[0]);
return db
.query(table)
.withIndex(index, (q) => q.eq(field, value))
.unique();
}
/**
* Get a document matching the given value for a specified field.
*
* Throws if not found.
* Useful for fetching a document with a one-to-one relationship via backref.
* Requires the table to have an index on the field named the same as the field.
* e.g. `defineTable({ fieldA: v.string() }).index("fieldA", ["fieldA"])`
*
* Getting 'string' is not assignable to parameter of type 'never'?
* Make sure your index is named after your field.
*
* @param db DatabaseReader, passed in from the function ctx
* @param table The table to fetch the target document from.
* @param index The index on that table to look up the specified value by.
* @param value The value to look up the document by, often an ID.
* @param field The field on that table that should match the specified value.
* Optional if the index is named after the field.
* @returns The document matching the value. Throws if not found.
*/
export async function getOneFromOrThrow<
DataModel extends GenericDataModel,
TableName extends TablesWithLookups<DataModel>,
IndexName extends UserIndexes<DataModel, TableName>
>(
db: GenericDatabaseReader<DataModel>,
table: TableName,
index: IndexName,
value: TypeOfFirstIndexField<DataModel, TableName, IndexName>,
...fieldArg: FieldIfDoesntMatchIndex<DataModel, TableName, IndexName>
): Promise<DocumentByName<DataModel, TableName>> {
const field = firstIndexField(index, fieldArg[0]);
const ret = await db
.query(table)
.withIndex(index, (q) => q.eq(field, value))
.unique();
return nullThrows(
ret,
`Can't find a document in ${table} with field ${field} equal to ${value}`
);
}
/**
* Get a list of documents matching the given value for a specified field.
*
* Useful for fetching many documents related to a given value via backrefs.
* Requires the table to have an index on the field named the same as the field.
* e.g. `defineTable({ fieldA: v.string() }).index("fieldA", ["fieldA"])`
*
* Getting 'string' is not assignable to parameter of type 'never'?
* Make sure your index is named after your field.
*
* @param db DatabaseReader, passed in from the function ctx
* @param table The table to fetch the target document from.
* @param index The index on that table to look up the specified value by.
* @param value The value to look up the document by, often an ID.
* @param field The field on that table that should match the specified value.
* Optional if the index is named after the field.
* @returns The documents matching the value, if any.
*/
export async function getManyFrom<
DataModel extends GenericDataModel,
TableName extends TablesWithLookups<DataModel>,
IndexName extends UserIndexes<DataModel, TableName>
>(
db: GenericDatabaseReader<DataModel>,
table: TableName,
index: IndexName,
value: TypeOfFirstIndexField<DataModel, TableName, IndexName>,
...fieldArg: FieldIfDoesntMatchIndex<DataModel, TableName, IndexName>
): Promise<DocumentByName<DataModel, TableName>[]> {
const field = firstIndexField(index, fieldArg[0]);
return db
.query(table)
.withIndex(index, (q) => q.eq(field, value))
.collect();
}
// File paths to fields that are IDs, excluding "_id".
type IdFilePaths<
DataModel extends GenericDataModel,
InTableName extends TableNamesInDataModel<DataModel>,
TableName extends TableNamesInDataModel<DataModel> | SystemTableNames
> = {
[FieldName in FieldPaths<
NamedTableInfo<DataModel, InTableName>
>]: FieldTypeFromFieldPath<
DocumentByName<DataModel, InTableName>,
FieldName
> extends GenericId<TableName>
? FieldName extends "_id"
? never
: FieldName
: never;
}[FieldPaths<NamedTableInfo<DataModel, InTableName>>];
// Whether a table has an ID field that isn't its sole lookup field.
// These can operate as join tables, going from one table to another.
// One field has an indexed field for lookup, and another has the ID to get.
type LookupAndIdFilePaths<
DataModel extends GenericDataModel,
TableName extends TablesWithLookups<DataModel>
> = {
[FieldPath in IdFilePaths<
DataModel,
TableName,
TableNamesInDataModel<DataModel> | SystemTableNames
>]: LookupFieldPaths<DataModel, TableName> extends FieldPath ? never : true;
}[IdFilePaths<
DataModel,
TableName,
TableNamesInDataModel<DataModel> | SystemTableNames
>];
// The table names that match LookupAndIdFields.
// These are the possible "join" or "edge" or "relationship" tables.
type JoinTables<DataModel extends GenericDataModel> = {
[TableName in TablesWithLookups<DataModel>]: LookupAndIdFilePaths<
DataModel,
TableName
> extends never
? never
: TableName;
}[TablesWithLookups<DataModel>];
// many-to-many via lookup table
/**
* Get related documents by using a join table.
*
* Any missing documents referenced by the join table will be null.
* It will find all join table entries matching a value, then look up all the
* documents pointed to by the join table entries. Useful for many-to-many
* relationships.
*
* Requires your join table to have an index on the fromField named the same as
* the fromField, and another field that is an Id type.
* e.g. `defineTable({ a: v.string(), b: v.id("users") }).index("a", ["a"])`
*
* Getting 'string' is not assignable to parameter of type 'never'?
* Make sure your index is named after your field.
*
* @param db DatabaseReader, passed in from the function ctx
* @param table The table to fetch the target document from.
* @param toField The ID field on the table pointing at target documents.
* @param index The index on the join table to look up the specified value by.
* @param value The value to look up the documents in join table by.
* @param field The field on the join table to match the specified value.
* Optional if the index is named after the field.
* @returns The documents targeted by matching documents in the table, if any.
*/
export async function getManyVia<
DataModel extends GenericDataModel,
JoinTableName extends JoinTables<DataModel>,
ToField extends IdFilePaths<
DataModel,
JoinTableName,
TableNamesInDataModel<DataModel> | SystemTableNames
>,
IndexName extends UserIndexes<DataModel, JoinTableName>,
TargetTableName extends FieldTypeFromFieldPath<
DocumentByName<DataModel, JoinTableName>,
ToField
> extends GenericId<infer TargetTableName>
? TargetTableName
: never
>(
db: GenericDatabaseReader<DataModel>,
table: JoinTableName,
toField: ToField,
index: IndexName,
value: TypeOfFirstIndexField<DataModel, JoinTableName, IndexName>,
...fieldArg: FieldIfDoesntMatchIndex<DataModel, JoinTableName, IndexName>
): Promise<(DocumentByName<DataModel, TargetTableName> | null)[]> {
return await asyncMap(
await getManyFrom(db, table, index, value, ...fieldArg),
async (link: DocumentByName<DataModel, JoinTableName>) => {
const id = link[toField] as GenericId<TargetTableName>;
try {
return await db.get(id);
} catch {
return await db.system.get(id as GenericId<SystemTableNames>);
}
}
);
}
/**
* Get related documents by using a join table.
*
* Throws an error if any documents referenced by the join table are missing.
* It will find all join table entries matching a value, then look up all the
* documents pointed to by the join table entries. Useful for many-to-many
* relationships.
*
* Requires your join table to have an index on the fromField named the same as
* the fromField, and another field that is an Id type.
* e.g. `defineTable({ a: v.string(), b: v.id("users") }).index("a", ["a"])`
*
* Getting 'string' is not assignable to parameter of type 'never'?
* Make sure your index is named after your field.
*
* @param db DatabaseReader, passed in from the function ctx
* @param table The table to fetch the target document from.
* @param toField The ID field on the table pointing at target documents.
* @param index The index on the join table to look up the specified value by.
* @param value The value to look up the documents in join table by.
* @param field The field on the join table to match the specified value.
* Optional if the index is named after the field.
* @returns The documents targeted by matching documents in the table, if any.
*/
export async function getManyViaOrThrow<
DataModel extends GenericDataModel,
JoinTableName extends JoinTables<DataModel>,
ToField extends IdFilePaths<
DataModel,
JoinTableName,
TableNamesInDataModel<DataModel> | SystemTableNames
>,
IndexName extends UserIndexes<DataModel, JoinTableName>,
TargetTableName extends FieldTypeFromFieldPath<
DocumentByName<DataModel, JoinTableName>,
ToField
> extends GenericId<infer TargetTableName>
? TargetTableName
: never
>(
db: GenericDatabaseReader<DataModel>,
table: JoinTableName,
toField: ToField,
index: IndexName,
value: TypeOfFirstIndexField<DataModel, JoinTableName, IndexName>,
...fieldArg: FieldIfDoesntMatchIndex<DataModel, JoinTableName, IndexName>
): Promise<DocumentByName<DataModel, TargetTableName>[]> {
return await asyncMap(
await getManyFrom(db, table, index, value, ...fieldArg),
async (link: DocumentByName<DataModel, JoinTableName>) => {
const id = link[toField];
try {
return nullThrows(
await db.get(id as GenericId<TargetTableName>),
`Can't find document ${id} referenced in ${table}'s field ${toField} for ${
fieldArg[0] ?? index
} equal to ${value}`
);
} catch {
return nullThrows(
await db.system.get(id as GenericId<SystemTableNames>),
`Can't find document ${id} referenced in ${table}'s field ${toField} for ${
fieldArg[0] ?? index
} equal to ${value}`
);
}
}
);
}