Skip to content

Commit

Permalink
Refactor to QueryBuilder (#21)
Browse files Browse the repository at this point in the history
* Refactor to QueryBuilder

* chore: changeset

* Implement Update and Insert, and start tests

* Advanced order by options

* Switch test to use enums

* Delete() method

* Refactor: do not require a near useless prepare() method

* Insert() and Update() tests

* Rename variable to be tableName

* Refactor Model to use QueryBuilder

* Upsert() method

* Remove unnecessary types

* Check insert and update objects are not empty

* Surround table names with backticks to prevent unusual chars issues
  • Loading branch information
Skye-31 authored Sep 11, 2022
1 parent ac29e9b commit b2559ef
Show file tree
Hide file tree
Showing 7 changed files with 596 additions and 204 deletions.
7 changes: 7 additions & 0 deletions .changeset/witty-boxes-retire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"d1-orm": minor
---

[breaking] feat: Switch to use a QueryBuilder instead of duplicate code in the Model class

This will be much more expandable in future to support things like advanced where querying, using operators other than AND, joins, etc.
3 changes: 3 additions & 0 deletions src/datatypes.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
/**
* @enum {string} Aliases for DataTypes used in a {@link ModelColumn} definition.
*/
export enum DataTypes {
INTEGER = "integer",
INT = "integer",
Expand Down
7 changes: 4 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./database";
export * from "./model";
export * from "./datatypes";
export * from "./database.js";
export * from "./model.js";
export * from "./datatypes.js";
export * from "./queryBuilder.js";
270 changes: 70 additions & 200 deletions src/model.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
import { D1Orm } from "./database.js";
import { DataTypes } from "./datatypes.js";
import { QueryType, GenerateQuery } from "./queryBuilder.js";
import type { GenerateQueryOptions } from "./queryBuilder.js";

/**
* @typeParam T - The type of the model, which will be returned when using methods such as First() or All()
*/
export class Model<T> {
export class Model<T extends object> {
/**
* @param options - The options for the model. The table name & D1Orm instance are required.
* @param options.tableName - The name of the table to use.
* @param options.D1Orm - The D1Orm instance to use.
* @param columns - The columns for the model. The keys are the column names, and the values are the column options. See {@link ModelColumn}
* @typeParam T - The type of the model, which will be returned when using methods such as First() or All()
*/
constructor(options: ModelOptions, columns: ModelColumns) {
constructor(
options: {
D1Orm: D1Orm;
tableName: string;
},
columns: Record<string, ModelColumn>
) {
this.#D1Orm = options.D1Orm;
this.tableName = options.tableName;
this.columns = columns;
Expand Down Expand Up @@ -41,7 +51,7 @@ export class Model<T> {
this.#primaryKey;
}
public tableName: string;
public readonly columns: ModelColumns;
public readonly columns: Record<string, ModelColumn>;
readonly #D1Orm: D1Orm;

/**
Expand All @@ -54,7 +64,9 @@ export class Model<T> {
* - Throws an error if the strategy is "alter", as this is not yet implemented
*/
public async CreateTable(
options: CreateTableOptions = { strategy: "default" }
options: { strategy: "default" | "force" | "alter" } = {
strategy: "default",
}
): Promise<D1Result<unknown>> {
const { strategy } = options;
if (strategy === "alter") {
Expand Down Expand Up @@ -103,7 +115,11 @@ export class Model<T> {
* @param data The data to insert into the table, as an object with the column names as keys and the values as values.
*/
public async InsertOne(data: Partial<T>): Promise<D1Result<T>> {
return this.#createInsertStatement(data).first<D1Result<T>>();
const statement = GenerateQuery(QueryType.INSERT, this.tableName, data);
return this.#D1Orm
.prepare(statement.query)
.bind(...statement.bindings)
.run();
}

/**
Expand All @@ -112,178 +128,82 @@ export class Model<T> {
public async InsertMany(data: Partial<T>[]): Promise<D1Result<T>[]> {
const stmts: D1PreparedStatement[] = [];
for (const row of data) {
stmts.push(this.#createInsertStatement(row));
const stmt = GenerateQuery(QueryType.INSERT, this.tableName, row);
stmts.push(this.#D1Orm.prepare(stmt.query).bind(...stmt.bindings));
}
return this.#D1Orm.batch<T>(stmts);
}

/**
* @param options The options for the query
* @param options.where - The where clause for the query. This is an object with the column names as keys and the values as values.
* @param options The options for the query, see {@link GenerateQueryOptions}
* @returns Returns the first row that matches the where clause.
*/
public async First(options: {
where: WhereOptions<T>;
}): Promise<D1Result<T>> {
const { where } = options;
const objectKeys = Object.keys(where as Record<string, unknown>);
if (objectKeys.length === 0) {
return this.#D1Orm
.prepare(`SELECT * FROM ${this.tableName} LIMIT 1;`)
.first<D1Result<T>>();
}
const stmt = this.#statementAddBindings(
`SELECT * FROM ${this.tableName} WHERE ` +
objectKeys.map((key) => `${key} = ?`).join(" AND ") +
" LIMIT 1;",
where
);
return stmt.first<D1Result<T>>();
public async First(
options: Pick<GenerateQueryOptions<T>, "where">
): Promise<D1Result<T>> {
const statement = GenerateQuery(QueryType.SELECT, this.tableName, options);
return this.#D1Orm
.prepare(statement.query)
.bind(...statement.bindings)
.first();
}

/**
* @param options The options for the query
* @param options.where - The where clause for the query. This is an object with the column names as keys and the values as values.
* @param options.limit - The limit for the query. This is the maximum number of rows to return.
* @param options The options for the query, see {@link GenerateQueryOptions}
* @returns Returns all rows that match the where clause.
*/
public async All(options: {
where: WhereOptions<T>;
limit?: number;
}): Promise<D1Result<T[]>> {
const { where, limit } = options;
const objectKeys = Object.keys(where as Record<string, unknown>);
if (objectKeys.length === 0) {
return this.#D1Orm
.prepare(
`SELECT * FROM ${this.tableName}${limit ? ` LIMIT ${limit}` : ""};`
)
.all<T>();
}
const stmt = this.#statementAddBindings(
`SELECT * FROM ${this.tableName} WHERE` +
objectKeys.map((key) => `${key} = ?`).join(" AND ") +
(limit ? ` LIMIT ${limit}` : ""),
where
);
return stmt.all<T>();
public async All(
options: Omit<GenerateQueryOptions<T>, "data">
): Promise<D1Result<T[]>> {
const statement = GenerateQuery(QueryType.SELECT, this.tableName, options);
return this.#D1Orm
.prepare(statement.query)
.bind(...statement.bindings)
.all();
}

/**
* @param options The options for the query
* @param options.where - The where clause for the query. This is an object with the column names as keys and the values as values.
* @param options.limit - The limit for the query. This is the maximum number of rows to delete.
* @param options The options for the query, see {@link GenerateQueryOptions}
*/
public async Delete(options: {
where: WhereOptions<T>;
limit?: number;
}): Promise<D1Result<unknown>> {
const { where, limit } = options;
const objectKeys = Object.keys(where as Record<string, unknown>);
if (objectKeys.length === 0) {
return this.#D1Orm
.prepare(
`DELETE FROM ${this.tableName}${limit ? `LIMIT ${limit}` : ""};`
)
.run();
}
const stmt = this.#statementAddBindings(
`DELETE FROM ${this.tableName} WHERE ` +
objectKeys.map((key) => `${key} = ?`).join(" AND ") +
(limit ? ` LIMIT ${limit}` : ""),
where
);
return stmt.run();
public async Delete(
options: Pick<GenerateQueryOptions<T>, "where">
): Promise<D1Result<unknown>> {
const statement = GenerateQuery(QueryType.DELETE, this.tableName, options);
return this.#D1Orm
.prepare(statement.query)
.bind(...statement.bindings)
.run();
}

/**
* @param options The options for the query
* @param options.where - The where clause for the query. This is an object with the column names as keys and the values as values.
* @param options.limit - The limit for the query. This is the maximum number of rows to update.
* @param options.data - The data to update the rows with. This is an object with the column names as keys and the values as values.
* @throws
* - Throws an error if the data clause is empty.
* @param options The options for the query, see {@link GenerateQueryOptions}
* @throws Throws an error if the data clause is empty.
*/
public async Update(options: {
where: WhereOptions<T>;
data: Partial<T>;
limit?: number;
}): Promise<D1Result<unknown>> {
const { where, data } = options;
const dataKeys = Object.keys(data as Record<string, unknown>);
const whereKeys = Object.keys(where as Record<string, unknown>);
if (dataKeys.length === 0) {
throw new Error("Update called with no data");
}
if (whereKeys.length === 0) {
return this.#D1Orm
.prepare(
`UPDATE ${this.tableName} SET ${Object.keys(data)
.map((key) => `${key} = ?`)
.join(", ")}`
)
.bind(...Object.values(data))
.run();
}
const stmtArray = [...Object.values(data), ...Object.values(where)];
const params: Record<number, unknown> = {};
for (let i = 0; i < stmtArray.length; i++) {
params[i] = stmtArray[i];
}
const stmt = this.#statementAddBindings(
`UPDATE ${this.tableName} SET ${Object.keys(data)
.map((key) => `${key} = ?`)
.join(", ")} WHERE ` +
whereKeys.map((key) => `${key} = ?`).join(" AND "),
params
);
return stmt.run();
public async Update(
options: Pick<GenerateQueryOptions<T>, "where" | "data">
): Promise<D1Result<unknown>> {
const statement = GenerateQuery(QueryType.UPDATE, this.tableName, options);
return this.#D1Orm
.prepare(statement.query)
.bind(...statement.bindings)
.run();
}

/**
* Upserting is a way to insert a row into the table, or update it if it already exists.
* This is done by using SQLITE's ON CONFLICT clause. As a result, this method should control the primary key for the insert & where clauses, and should not be used with auto incrementing keys.
* @param options The options for the query
* @param options.where - The where clause for the query. This is an object with the column names as keys and the values as values.
* @param options.limit - The limit for the query. This is the maximum number of rows to update.
* @param options.updateData - The data to update the rows with if an `ON CONFLICT` clause occurs. This is an object with the column names as keys and the values as values.
* @param options.insertData - The data to insert. This is an object with the column names as keys and the values as values.
* @throws
* - Throws an error if the data clause is empty.
* @param options The options for the query, see {@link GenerateQueryOptions}
*/
public async Upsert(options: {
where: WhereOptions<T>;
updateData: Partial<T>;
insertData: Partial<T>;
}) {
const { where, updateData, insertData } = options;
const insertDataKeys = Object.keys(insertData as Record<string, unknown>);
const updateDataKeys = Object.keys(updateData as Record<string, unknown>);
const whereKeys = Object.keys(where as Record<string, unknown>);

if (insertDataKeys.length === 0 || updateDataKeys.length === 0) {
throw new Error("Upsert called with no data");
}

const bindings = [
...Object.values(insertData),
...Object.values(updateData),
...Object.values(where),
];
const stmt = `INSERT INTO ${this.tableName} (${insertDataKeys.join(
", "
)}) VALUES (${insertDataKeys.map(() => "?").join(", ")})
ON CONFLICT (${this.#primaryKey}) DO UPDATE SET ${updateDataKeys
.map((key) => `${key} = ?`)
.join(", ")}
${
whereKeys.length
? `WHERE ${whereKeys.map((x) => `${x} = ?`).join(" AND ")}`
: ""
};`;
public async Upsert(
options: Pick<
GenerateQueryOptions<T>,
"where" | "data" | "upsertOnlyUpdateData"
>
) {
const statement = GenerateQuery(QueryType.UPSERT, this.tableName, options);
return this.#D1Orm
.prepare(stmt)
.bind(...bindings)
.prepare(statement.query)
.bind(...statement.bindings)
.run();
}

Expand All @@ -296,36 +216,8 @@ export class Model<T> {
}
return keys[0];
}

#statementAddBindings(
query: string,
data: Record<string, unknown>
): D1PreparedStatement {
const statement = this.#D1Orm.prepare(query).bind(...Object.values(data));
return statement;
}

#createInsertStatement(data: Partial<T>): D1PreparedStatement {
const dataRecord = data as Record<string, unknown>;
const columnNames = Object.keys(dataRecord);
const columnSize = columnNames.length;
if (columnSize === 0) {
throw new Error("Insert called with no columns");
}
return this.#statementAddBindings(
`INSERT INTO ${this.tableName} (${columnNames.join(
", "
)}) VALUES (${"?, ".repeat(columnSize - 1)}?) RETURNING *;`,
dataRecord
);
}
}

/**
* An object where the keys are the column names, and the values are a {@link ModelColumn}
*/
export type ModelColumns = Record<string, ModelColumn>;

/**
* The definition of a column in a model.
* If the `defaultValue` is provided, it should be of the type defined by your `type`. Blobs should be provided as a [Uint32Array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint32Array).
Expand All @@ -338,25 +230,3 @@ export type ModelColumn = {
autoIncrement?: boolean;
defaultValue?: unknown;
};

export type ModelOptions = {
D1Orm: D1Orm;
tableName: string;
};

/**
* The options for the {@link Model.CreateTable} method.
*
* Note: Using `alter` is not yet supported. You should perform these migrations manually.
*/
export type CreateTableOptions = {
strategy: "default" | "force" | "alter";
};

/**
* The options for the {@link Model.First} method, amongst other {@link Model} methods.
*
* May be expanded in future to support more advanced querying, such as OR, NOT, IN operators, etc.
* @typeParam T - The type of the model. It will be inferred from the model class, and should not need to be provided by you.
*/
export type WhereOptions<T> = Partial<T>;
Loading

0 comments on commit b2559ef

Please sign in to comment.