aboutsummaryrefslogtreecommitdiff
path: root/packages/db/src/core/queries.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/db/src/core/queries.ts')
-rw-r--r--packages/db/src/core/queries.ts206
1 files changed, 206 insertions, 0 deletions
diff --git a/packages/db/src/core/queries.ts b/packages/db/src/core/queries.ts
new file mode 100644
index 000000000..8fba4f6f9
--- /dev/null
+++ b/packages/db/src/core/queries.ts
@@ -0,0 +1,206 @@
+import type { SQL } from 'drizzle-orm';
+import { SQLiteAsyncDialect } from 'drizzle-orm/sqlite-core';
+import { bold } from 'kleur/colors';
+import {
+ FOREIGN_KEY_DNE_ERROR,
+ FOREIGN_KEY_REFERENCES_EMPTY_ERROR,
+ FOREIGN_KEY_REFERENCES_LENGTH_ERROR,
+ REFERENCE_DNE_ERROR,
+} from '../runtime/errors.js';
+import { hasPrimaryKey } from '../runtime/index.js';
+import { isSerializedSQL } from '../runtime/types.js';
+import type {
+ BooleanColumn,
+ ColumnType,
+ DBColumn,
+ DBTable,
+ DateColumn,
+ JsonColumn,
+ NumberColumn,
+ TextColumn,
+} from './types.js';
+
+const sqlite = new SQLiteAsyncDialect();
+
+export const SEED_DEV_FILE_NAME = ['seed.ts', 'seed.js', 'seed.mjs', 'seed.mts'];
+
+export function getDropTableIfExistsQuery(tableName: string) {
+ return `DROP TABLE IF EXISTS ${sqlite.escapeName(tableName)}`;
+}
+
+export function getCreateTableQuery(tableName: string, table: DBTable) {
+ let query = `CREATE TABLE ${sqlite.escapeName(tableName)} (`;
+
+ const colQueries = [];
+ const colHasPrimaryKey = Object.entries(table.columns).find(([, column]) =>
+ hasPrimaryKey(column),
+ );
+ if (!colHasPrimaryKey) {
+ colQueries.push('_id INTEGER PRIMARY KEY');
+ }
+ for (const [columnName, column] of Object.entries(table.columns)) {
+ const colQuery = `${sqlite.escapeName(columnName)} ${schemaTypeToSqlType(
+ column.type,
+ )}${getModifiers(columnName, column)}`;
+ colQueries.push(colQuery);
+ }
+
+ colQueries.push(...getCreateForeignKeyQueries(tableName, table));
+
+ query += colQueries.join(', ') + ')';
+ return query;
+}
+
+export function getCreateIndexQueries(tableName: string, table: Pick<DBTable, 'indexes'>) {
+ let queries: string[] = [];
+ for (const [indexName, indexProps] of Object.entries(table.indexes ?? {})) {
+ const onColNames = asArray(indexProps.on);
+ const onCols = onColNames.map((colName) => sqlite.escapeName(colName));
+
+ const unique = indexProps.unique ? 'UNIQUE ' : '';
+ const indexQuery = `CREATE ${unique}INDEX ${sqlite.escapeName(
+ indexName,
+ )} ON ${sqlite.escapeName(tableName)} (${onCols.join(', ')})`;
+ queries.push(indexQuery);
+ }
+ return queries;
+}
+
+function getCreateForeignKeyQueries(tableName: string, table: DBTable) {
+ let queries: string[] = [];
+ for (const foreignKey of table.foreignKeys ?? []) {
+ const columns = asArray(foreignKey.columns);
+ const references = asArray(foreignKey.references);
+
+ if (columns.length !== references.length) {
+ throw new Error(FOREIGN_KEY_REFERENCES_LENGTH_ERROR(tableName));
+ }
+ const firstReference = references[0];
+ if (!firstReference) {
+ throw new Error(FOREIGN_KEY_REFERENCES_EMPTY_ERROR(tableName));
+ }
+ const referencedTable = firstReference.schema.collection;
+ if (!referencedTable) {
+ throw new Error(FOREIGN_KEY_DNE_ERROR(tableName));
+ }
+ const query = `FOREIGN KEY (${columns
+ .map((f) => sqlite.escapeName(f))
+ .join(', ')}) REFERENCES ${sqlite.escapeName(referencedTable)}(${references
+ .map((r) => sqlite.escapeName(r.schema.name!))
+ .join(', ')})`;
+ queries.push(query);
+ }
+ return queries;
+}
+
+function asArray<T>(value: T | T[]) {
+ return Array.isArray(value) ? value : [value];
+}
+
+export function schemaTypeToSqlType(type: ColumnType): 'text' | 'integer' {
+ switch (type) {
+ case 'date':
+ case 'text':
+ case 'json':
+ return 'text';
+ case 'number':
+ case 'boolean':
+ return 'integer';
+ }
+}
+
+export function getModifiers(columnName: string, column: DBColumn) {
+ let modifiers = '';
+ if (hasPrimaryKey(column)) {
+ return ' PRIMARY KEY';
+ }
+ if (!column.schema.optional) {
+ modifiers += ' NOT NULL';
+ }
+ if (column.schema.unique) {
+ modifiers += ' UNIQUE';
+ }
+ if (hasDefault(column)) {
+ modifiers += ` DEFAULT ${getDefaultValueSql(columnName, column)}`;
+ }
+ const references = getReferencesConfig(column);
+ if (references) {
+ const { collection: tableName, name } = references.schema;
+ if (!tableName || !name) {
+ throw new Error(REFERENCE_DNE_ERROR(columnName));
+ }
+
+ modifiers += ` REFERENCES ${sqlite.escapeName(tableName)} (${sqlite.escapeName(name)})`;
+ }
+ return modifiers;
+}
+
+export function getReferencesConfig(column: DBColumn) {
+ const canHaveReferences = column.type === 'number' || column.type === 'text';
+ if (!canHaveReferences) return undefined;
+ return column.schema.references;
+}
+
+// Using `DBColumn` will not narrow `default` based on the column `type`
+// Handle each column separately
+type WithDefaultDefined<T extends DBColumn> = T & {
+ schema: Required<Pick<T['schema'], 'default'>>;
+};
+type DBColumnWithDefault =
+ | WithDefaultDefined<TextColumn>
+ | WithDefaultDefined<DateColumn>
+ | WithDefaultDefined<NumberColumn>
+ | WithDefaultDefined<BooleanColumn>
+ | WithDefaultDefined<JsonColumn>;
+
+// Type narrowing the default fails on union types, so use a type guard
+export function hasDefault(column: DBColumn): column is DBColumnWithDefault {
+ if (column.schema.default !== undefined) {
+ return true;
+ }
+ if (hasPrimaryKey(column) && column.type === 'number') {
+ return true;
+ }
+ return false;
+}
+
+function toDefault<T>(def: T | SQL<any>): string {
+ const type = typeof def;
+ if (type === 'string') {
+ return sqlite.escapeString(def as string);
+ } else if (type === 'boolean') {
+ return def ? 'TRUE' : 'FALSE';
+ } else {
+ return def + '';
+ }
+}
+
+function getDefaultValueSql(columnName: string, column: DBColumnWithDefault): string {
+ if (isSerializedSQL(column.schema.default)) {
+ return column.schema.default.sql;
+ }
+
+ switch (column.type) {
+ case 'boolean':
+ case 'number':
+ case 'text':
+ case 'date':
+ return toDefault(column.schema.default);
+ case 'json': {
+ let stringified = '';
+ try {
+ stringified = JSON.stringify(column.schema.default);
+ } catch {
+ // biome-ignore lint/suspicious/noConsoleLog: allowed
+ console.log(
+ `Invalid default value for column ${bold(
+ columnName,
+ )}. Defaults must be valid JSON when using the \`json()\` type.`,
+ );
+ process.exit(0);
+ }
+
+ return sqlite.escapeString(stringified);
+ }
+ }
+}