Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: composite primary keys #36

Merged
merged 5 commits into from Sep 27, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nine-stingrays-love.md
@@ -0,0 +1,5 @@
---
"d1-orm": minor
---

Feat: Composite primary keys
103 changes: 59 additions & 44 deletions src/model.ts
Expand Up @@ -39,21 +39,61 @@ export class Model<T extends object> {
throw new Error("Model columns cannot be empty");
}

let hasAutoIncrement = false;
for (const [columnName, column] of columnEntries) {
if (column.autoIncrement && column.type !== DataTypes.INTEGER) {
throw new Error(
`Column "${columnName}" is autoincrement but is not an integer`
);
if (column.autoIncrement) {
if (column.type !== DataTypes.INTEGER) {
throw new Error(
`Column "${columnName}" is autoincrement but is not an integer`
);
}
hasAutoIncrement = true;
}
}

// This is done so the getter checks if the model has a primary key, and throws an error if not
this.#primaryKey;
if (hasAutoIncrement && this.#primaryKeys.length > 1) {
throw new Error(
"Model cannot have more than 1 primary key if autoIncrement is true"
);
}
if (!this.#primaryKeys.length) {
throw new Error("Model must have a primary key");
}
}
public tableName: string;
public readonly columns: Record<string, ModelColumn>;
readonly #D1Orm: D1Orm;

get #primaryKeys(): string[] {
return Object.keys(this.columns).filter((x) => this.columns[x].primaryKey);
}

/**
* @returns A CreateTable definition for the model, which can be used in a CREATE TABLE statement.
*/
get createTableDefinition(): string {
const columnEntries = Object.entries(this.columns);
const columnDefinition = columnEntries.map(([columnName, column]) => {
let definition = `${columnName} ${column.type}`;
if (column.autoIncrement) {
definition += " AUTOINCREMENT";
}
if (column.notNull) {
definition += " NOT NULL";
}
if (column.unique) {
definition += " UNIQUE";
}
if (column.defaultValue !== undefined) {
definition += ` DEFAULT "${column.defaultValue}"`;
}
return definition;
});
columnDefinition.push(`PRIMARY KEY (${this.#primaryKeys.join(", ")})`);
return `CREATE TABLE \`${this.tableName}\` (${columnDefinition.join(
", "
)});`;
}

/**
* @param options The options for creating the table. Currently only contains strategy, which is the strategy to use when creating the table.
* - "default" - The default strategy, which will attempt create the table.
Expand All @@ -72,31 +112,9 @@ export class Model<T extends object> {
if (strategy === "alter") {
throw new Error("Alter strategy is not implemented");
}
const columnEntries = Object.entries(this.columns);
const columnDefinitions = columnEntries
.map(([columnName, column]) => {
let definition = `${columnName} ${column.type}`;
if (column.primaryKey) {
definition += " PRIMARY KEY";
}
if (column.autoIncrement) {
definition += " AUTOINCREMENT";
}
if (column.notNull) {
definition += " NOT NULL";
}
if (column.unique) {
definition += " UNIQUE";
}
if (column.defaultValue) {
definition += ` DEFAULT "${column.defaultValue}"`;
}
return definition;
})
.join(", ");
let statement = `CREATE TABLE ${this.tableName} (${columnDefinitions});`;
let statement = this.createTableDefinition;
if (strategy === "force") {
statement = `DROP TABLE IF EXISTS ${this.tableName}\n${statement}`;
statement = `DROP TABLE IF EXISTS \`${this.tableName}\`\n${statement}`;
}
return this.#D1Orm.exec(statement);
}
Expand All @@ -115,7 +133,7 @@ export class Model<T extends object> {
* @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>> {
const statement = GenerateQuery(QueryType.INSERT, this.tableName, data);
const statement = GenerateQuery(QueryType.INSERT, this.tableName, { data });
return this.#D1Orm
.prepare(statement.query)
.bind(...statement.bindings)
Expand All @@ -128,7 +146,9 @@ export class Model<T extends object> {
public async InsertMany(data: Partial<T>[]): Promise<D1Result<T>[]> {
const stmts: D1PreparedStatement[] = [];
for (const row of data) {
const stmt = GenerateQuery(QueryType.INSERT, this.tableName, row);
const stmt = GenerateQuery(QueryType.INSERT, this.tableName, {
data: row,
});
stmts.push(this.#D1Orm.prepare(stmt.query).bind(...stmt.bindings));
}
return this.#D1Orm.batch<T>(stmts);
Expand Down Expand Up @@ -204,22 +224,17 @@ export class Model<T extends object> {
"where" | "data" | "upsertOnlyUpdateData"
>
) {
const statement = GenerateQuery(QueryType.UPSERT, this.tableName, options);
const statement = GenerateQuery(
QueryType.UPSERT,
this.tableName,
options,
this.#primaryKeys
);
return this.#D1Orm
.prepare(statement.query)
.bind(...statement.bindings)
.run();
}

get #primaryKey(): string {
const keys = Object.keys(this.columns).filter(
(key) => this.columns[key].primaryKey
);
if (keys.length !== 1) {
throw new Error(`Model must have 1 primary key, got: ${keys.length}`);
}
return keys[0];
}
}

/**
Expand Down
8 changes: 6 additions & 2 deletions src/queryBuilder.ts
Expand Up @@ -60,7 +60,7 @@ export function GenerateQuery<T extends object>(
type: QueryType,
tableName: string,
options: GenerateQueryOptions<T> = {},
primaryKey = "id"
primaryKeys: string | string[] = "id"
): { bindings: unknown[]; query: string } {
if (typeof tableName !== "string" || !tableName.length) {
throw new Error("Invalid table name");
Expand Down Expand Up @@ -168,7 +168,11 @@ export function GenerateQuery<T extends object>(
.repeat(insertDataKeys.length)
.split("")
.join(", ")})`;
query += ` ON CONFLICT (${primaryKey}) DO UPDATE SET`;

const primaryKeyStr = Array.isArray(primaryKeys)
? primaryKeys.join(", ")
: primaryKeys;
query += ` ON CONFLICT (${primaryKeyStr}) DO UPDATE SET`;
query += ` ${updateDataKeys.map((key) => `${key} = ?`).join(", ")}`;
query += ` WHERE ${whereKeys.map((key) => `${key} = ?`).join(" AND ")}`;
break;
Expand Down
102 changes: 93 additions & 9 deletions test/model.test.js
Expand Up @@ -3,13 +3,14 @@ import { D1Orm } from "../lib/database.js";
import { DataTypes } from "../lib/datatypes.js";
import { Model } from "../lib/model.js";

const fakeD1Database = {
prepare: () => {},
dump: () => {},
batch: () => {},
exec: () => {},
};

describe("Model Validation", () => {
const fakeD1Database = {
prepare: () => {},
dump: () => {},
batch: () => {},
exec: () => {},
};
const orm = new D1Orm(fakeD1Database);
describe("it should throw if the model has invalid options", () => {
it("should throw if an invalid D1Orm is provided", () => {
Expand Down Expand Up @@ -56,9 +57,9 @@ describe("Model Validation", () => {
id: { type: DataTypes.INTEGER },
}
)
).to.throw(Error, "Model must have 1 primary key, got: 0");
).to.throw(Error, "Model must have a primary key");
});
it("should throw an error if there is more than 1 primary key", () => {
it("should not throw an error if there is more than 1 primary key", () => {
expect(
() =>
new Model(
Expand All @@ -68,7 +69,90 @@ describe("Model Validation", () => {
id2: { type: DataTypes.INTEGER, primaryKey: true },
}
)
).to.throw(Error, "Model must have 1 primary key, got: 2");
).to.not.throw();
});
it("should throw an error if 2 primary keys and autoincrement is true", () => {
expect(
() =>
new Model(
{ D1Orm: orm, tableName: "test" },
{
id: { type: DataTypes.INTEGER, primaryKey: true },
id2: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
}
)
).to.throw(
Error,
"Model cannot have more than 1 primary key if autoIncrement is true"
);
});
});
});

describe("Model > Create Tables", () => {
const orm = new D1Orm(fakeD1Database);
it("should return a create table statement", () => {
const model = new Model(
{ D1Orm: orm, tableName: "test" },
{
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
name: { type: DataTypes.STRING },
}
);
expect(model.createTableDefinition).to.equal(
"CREATE TABLE `test` (id integer AUTOINCREMENT, name text, PRIMARY KEY (id));"
);
});
it("should return a create table statement with multiple primary keys", () => {
const model = new Model(
{ D1Orm: orm, tableName: "test" },
{
id: { type: DataTypes.INTEGER, primaryKey: true },
name: { type: DataTypes.STRING, primaryKey: true },
}
);
expect(model.createTableDefinition).to.equal(
"CREATE TABLE `test` (id integer, name text, PRIMARY KEY (id, name));"
);
});
it("should support a not null constraint", () => {
const model = new Model(
{ D1Orm: orm, tableName: "test" },
{
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
name: { type: DataTypes.STRING, notNull: true },
}
);
expect(model.createTableDefinition).to.equal(
"CREATE TABLE `test` (id integer AUTOINCREMENT, name text NOT NULL, PRIMARY KEY (id));"
);
});
it("should support a unique constraint", () => {
const model = new Model(
{ D1Orm: orm, tableName: "test" },
{
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
name: { type: DataTypes.STRING, unique: true },
}
);
expect(model.createTableDefinition).to.equal(
"CREATE TABLE `test` (id integer AUTOINCREMENT, name text UNIQUE, PRIMARY KEY (id));"
);
});
it("should support a default value", () => {
const model = new Model(
{ D1Orm: orm, tableName: "test" },
{
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
name: { type: DataTypes.STRING, defaultValue: "test" },
}
);
expect(model.createTableDefinition).to.equal(
'CREATE TABLE `test` (id integer AUTOINCREMENT, name text DEFAULT "test", PRIMARY KEY (id));'
);
});
});
21 changes: 21 additions & 0 deletions test/querybuilder.test.js
Expand Up @@ -310,6 +310,27 @@ describe("Query Builder", () => {
expect(statement.bindings[3]).to.equal("test");
expect(statement.bindings[4]).to.equal(1);
});
it("should accept multiple primary keys", () => {
const statement = GenerateQuery(
QueryType.UPSERT,
"test",
{
data: { id: 1, name: "test" },
upsertOnlyUpdateData: { id: 1, name: "test" },
where: { id: 1 },
},
["name", "id"]
);
expect(statement.query).to.equal(
"INSERT INTO `test` (id, name) VALUES (?, ?) ON CONFLICT (name, id) DO UPDATE SET id = ?, name = ? WHERE id = ?"
);
expect(statement.bindings.length).to.equal(5);
expect(statement.bindings[0]).to.equal(1);
expect(statement.bindings[1]).to.equal("test");
expect(statement.bindings[2]).to.equal(1);
expect(statement.bindings[3]).to.equal("test");
expect(statement.bindings[4]).to.equal(1);
});
});
});
describe("Ordering", () => {
Expand Down