0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2024-12-30 22:03:56 -05:00

Rename field->column

This commit is contained in:
Matthew Phillips 2024-02-20 15:29:17 -05:00
parent 841e24ebb9
commit 9693d19801
18 changed files with 479 additions and 479 deletions

View file

@ -1,19 +1,19 @@
import * as color from 'kleur/colors';
import deepDiff from 'deep-diff';
import {
fieldSchema,
type BooleanField,
columnSchema,
type BooleanColumn,
type DBCollection,
type DBCollections,
type DBField,
type DBFields,
type DBColumn,
type DBColumns,
type DBSnapshot,
type DateField,
type FieldType,
type DateColumn,
type ColumnType,
type Indexes,
type JsonField,
type NumberField,
type TextField,
type JsonColumn,
type NumberColumn,
type TextColumn,
} from '../types.js';
import { SQLiteAsyncDialect } from 'drizzle-orm/sqlite-core';
import { customAlphabet } from 'nanoid';
@ -35,7 +35,7 @@ const genTempTableName = customAlphabet('abcdefghijklmnopqrstuvwxyz', 10);
/** Dependency injected for unit testing */
type AmbiguityResponses = {
collectionRenames: Record<string, string>;
fieldRenames: {
columnRenames: {
[collectionName: string]: Record<string, string>;
};
};
@ -102,9 +102,9 @@ export async function getCollectionChangeQueries({
}): Promise<{ queries: string[]; confirmations: string[] }> {
const queries: string[] = [];
const confirmations: string[] = [];
const updated = getUpdatedFields(oldCollection.fields, newCollection.fields);
let added = getAdded(oldCollection.fields, newCollection.fields);
let dropped = getDropped(oldCollection.fields, newCollection.fields);
const updated = getUpdatedColumns(oldCollection.columns, newCollection.columns);
let added = getAdded(oldCollection.columns, newCollection.columns);
let dropped = getDropped(oldCollection.columns, newCollection.columns);
/** Any foreign key changes require a full table recreate */
const hasForeignKeyChanges = Boolean(
deepDiff(oldCollection.foreignKeys, newCollection.foreignKeys)
@ -121,10 +121,10 @@ export async function getCollectionChangeQueries({
};
}
if (!hasForeignKeyChanges && !isEmpty(added) && !isEmpty(dropped)) {
const resolved = await resolveFieldRenames(collectionName, added, dropped, ambiguityResponses);
const resolved = await resolveColumnRenames(collectionName, added, dropped, ambiguityResponses);
added = resolved.added;
dropped = resolved.dropped;
queries.push(...getFieldRenameQueries(collectionName, resolved.renamed));
queries.push(...getColumnRenameQueries(collectionName, resolved.renamed));
}
if (
!hasForeignKeyChanges &&
@ -145,31 +145,31 @@ export async function getCollectionChangeQueries({
const dataLossCheck = canRecreateTableWithoutDataLoss(added, updated);
if (dataLossCheck.dataLoss) {
const { reason, fieldName } = dataLossCheck;
const { reason, columnName } = dataLossCheck;
const reasonMsgs: Record<DataLossReason, string> = {
'added-required': `New field ${color.bold(
collectionName + '.' + fieldName
'added-required': `New column ${color.bold(
collectionName + '.' + columnName
)} is required with no default value.\nThis requires deleting existing data in the ${color.bold(
collectionName
)} collection.`,
'added-unique': `New field ${color.bold(
collectionName + '.' + fieldName
'added-unique': `New column ${color.bold(
collectionName + '.' + columnName
)} is marked as unique.\nThis requires deleting existing data in the ${color.bold(
collectionName
)} collection.`,
'updated-type': `Updated field ${color.bold(
collectionName + '.' + fieldName
)} cannot convert data to new field data type.\nThis requires deleting existing data in the ${color.bold(
'updated-type': `Updated column ${color.bold(
collectionName + '.' + columnName
)} cannot convert data to new column data type.\nThis requires deleting existing data in the ${color.bold(
collectionName
)} collection.`,
};
confirmations.push(reasonMsgs[reason]);
}
const primaryKeyExists = Object.entries(newCollection.fields).find(([, field]) =>
hasPrimaryKey(field)
const primaryKeyExists = Object.entries(newCollection.columns).find(([, column]) =>
hasPrimaryKey(column)
);
const droppedPrimaryKey = Object.entries(dropped).find(([, field]) => hasPrimaryKey(field));
const droppedPrimaryKey = Object.entries(dropped).find(([, column]) => hasPrimaryKey(column));
const recreateTableQueries = getRecreateTableQueries({
collectionName,
@ -209,31 +209,31 @@ function getChangeIndexQueries({
type Renamed = Array<{ from: string; to: string }>;
async function resolveFieldRenames(
async function resolveColumnRenames(
collectionName: string,
mightAdd: DBFields,
mightDrop: DBFields,
mightAdd: DBColumns,
mightDrop: DBColumns,
ambiguityResponses?: AmbiguityResponses
): Promise<{ added: DBFields; dropped: DBFields; renamed: Renamed }> {
const added: DBFields = {};
const dropped: DBFields = {};
): Promise<{ added: DBColumns; dropped: DBColumns; renamed: Renamed }> {
const added: DBColumns = {};
const dropped: DBColumns = {};
const renamed: Renamed = [];
for (const [fieldName, field] of Object.entries(mightAdd)) {
let oldFieldName = ambiguityResponses
? ambiguityResponses.fieldRenames[collectionName]?.[fieldName] ?? '__NEW__'
for (const [columnName, column] of Object.entries(mightAdd)) {
let oldColumnName = ambiguityResponses
? ambiguityResponses.columnRenames[collectionName]?.[columnName] ?? '__NEW__'
: undefined;
if (!oldFieldName) {
if (!oldColumnName) {
const res = await prompts(
{
type: 'select',
name: 'fieldName',
name: 'columnName',
message:
'New field ' +
color.blue(color.bold(`${collectionName}.${fieldName}`)) +
' detected. Was this renamed from an existing field?',
'New column ' +
color.blue(color.bold(`${collectionName}.${columnName}`)) +
' detected. Was this renamed from an existing column?',
choices: [
{ title: 'New field (not renamed from existing)', value: '__NEW__' },
{ title: 'New column (not renamed from existing)', value: '__NEW__' },
...Object.keys(mightDrop)
.filter((key) => !(key in renamed))
.map((key) => ({ title: key, value: key })),
@ -245,18 +245,18 @@ async function resolveFieldRenames(
},
}
);
oldFieldName = res.fieldName as string;
oldColumnName = res.columnName as string;
}
if (oldFieldName === '__NEW__') {
added[fieldName] = field;
if (oldColumnName === '__NEW__') {
added[columnName] = column;
} else {
renamed.push({ from: oldFieldName, to: fieldName });
renamed.push({ from: oldColumnName, to: columnName });
}
}
for (const [droppedFieldName, droppedField] of Object.entries(mightDrop)) {
if (!renamed.find((r) => r.from === droppedFieldName)) {
dropped[droppedFieldName] = droppedField;
for (const [droppedColumnName, droppedColumn] of Object.entries(mightDrop)) {
if (!renamed.find((r) => r.from === droppedColumnName)) {
dropped[droppedColumnName] = droppedColumn;
}
}
@ -339,7 +339,7 @@ function getDroppedCollections(
return dropped;
}
function getFieldRenameQueries(unescapedCollectionName: string, renamed: Renamed): string[] {
function getColumnRenameQueries(unescapedCollectionName: string, renamed: Renamed): string[] {
const queries: string[] = [];
const collectionName = sqlite.escapeName(unescapedCollectionName);
@ -359,25 +359,25 @@ function getFieldRenameQueries(unescapedCollectionName: string, renamed: Renamed
*/
function getAlterTableQueries(
unescapedCollectionName: string,
added: DBFields,
dropped: DBFields
added: DBColumns,
dropped: DBColumns
): string[] {
const queries: string[] = [];
const collectionName = sqlite.escapeName(unescapedCollectionName);
for (const [unescFieldName, field] of Object.entries(added)) {
const fieldName = sqlite.escapeName(unescFieldName);
const type = schemaTypeToSqlType(field.type);
const q = `ALTER TABLE ${collectionName} ADD COLUMN ${fieldName} ${type}${getModifiers(
fieldName,
field
for (const [unescColumnName, column] of Object.entries(added)) {
const columnName = sqlite.escapeName(unescColumnName);
const type = schemaTypeToSqlType(column.type);
const q = `ALTER TABLE ${collectionName} ADD COLUMN ${columnName} ${type}${getModifiers(
columnName,
column
)}`;
queries.push(q);
}
for (const unescFieldName of Object.keys(dropped)) {
const fieldName = sqlite.escapeName(unescFieldName);
const q = `ALTER TABLE ${collectionName} DROP COLUMN ${fieldName}`;
for (const unescColumnName of Object.keys(dropped)) {
const columnName = sqlite.escapeName(unescColumnName);
const q = `ALTER TABLE ${collectionName} DROP COLUMN ${columnName}`;
queries.push(q);
}
@ -393,7 +393,7 @@ function getRecreateTableQueries({
}: {
collectionName: string;
newCollection: DBCollection;
added: Record<string, DBField>;
added: Record<string, DBColumn>;
hasDataLoss: boolean;
migrateHiddenPrimaryKey: boolean;
}): string[] {
@ -407,7 +407,7 @@ function getRecreateTableQueries({
getCreateTableQuery(unescCollectionName, newCollection),
];
}
const newColumns = [...Object.keys(newCollection.fields)];
const newColumns = [...Object.keys(newCollection.columns)];
if (migrateHiddenPrimaryKey) {
newColumns.unshift('_id');
}
@ -434,44 +434,44 @@ function isEmpty(obj: Record<string, unknown>) {
*
* @see https://www.sqlite.org/lang_altertable.html#alter_table_add_column
*/
function canAlterTableAddColumn(field: DBField) {
if (field.schema.unique) return false;
if (hasRuntimeDefault(field)) return false;
if (!field.schema.optional && !hasDefault(field)) return false;
if (hasPrimaryKey(field)) return false;
if (getReferencesConfig(field)) return false;
function canAlterTableAddColumn(column: DBColumn) {
if (column.schema.unique) return false;
if (hasRuntimeDefault(column)) return false;
if (!column.schema.optional && !hasDefault(column)) return false;
if (hasPrimaryKey(column)) return false;
if (getReferencesConfig(column)) return false;
return true;
}
function canAlterTableDropColumn(field: DBField) {
if (field.schema.unique) return false;
if (hasPrimaryKey(field)) return false;
function canAlterTableDropColumn(column: DBColumn) {
if (column.schema.unique) return false;
if (hasPrimaryKey(column)) return false;
return true;
}
type DataLossReason = 'added-required' | 'added-unique' | 'updated-type';
type DataLossResponse =
| { dataLoss: false }
| { dataLoss: true; fieldName: string; reason: DataLossReason };
| { dataLoss: true; columnName: string; reason: DataLossReason };
function canRecreateTableWithoutDataLoss(
added: DBFields,
updated: UpdatedFields
added: DBColumns,
updated: UpdatedColumns
): DataLossResponse {
for (const [fieldName, a] of Object.entries(added)) {
for (const [columnName, a] of Object.entries(added)) {
if (hasPrimaryKey(a) && a.type !== 'number' && !hasDefault(a)) {
return { dataLoss: true, fieldName, reason: 'added-required' };
return { dataLoss: true, columnName, reason: 'added-required' };
}
if (!a.schema.optional && !hasDefault(a)) {
return { dataLoss: true, fieldName, reason: 'added-required' };
return { dataLoss: true, columnName, reason: 'added-required' };
}
if (!a.schema.optional && a.schema.unique) {
return { dataLoss: true, fieldName, reason: 'added-unique' };
return { dataLoss: true, columnName, reason: 'added-unique' };
}
}
for (const [fieldName, u] of Object.entries(updated)) {
for (const [columnName, u] of Object.entries(updated)) {
if (u.old.type !== u.new.type && !canChangeTypeWithoutQuery(u.old, u.new)) {
return { dataLoss: true, fieldName, reason: 'updated-type' };
return { dataLoss: true, columnName, reason: 'updated-type' };
}
}
return { dataLoss: false };
@ -503,58 +503,58 @@ function getUpdated<T>(oldObj: Record<string, T>, newObj: Record<string, T>) {
return updated;
}
type UpdatedFields = Record<string, { old: DBField; new: DBField }>;
type UpdatedColumns = Record<string, { old: DBColumn; new: DBColumn }>;
function getUpdatedFields(oldFields: DBFields, newFields: DBFields): UpdatedFields {
const updated: UpdatedFields = {};
for (const [key, newField] of Object.entries(newFields)) {
let oldField = oldFields[key];
if (!oldField) continue;
function getUpdatedColumns(oldColumns: DBColumns, newColumns: DBColumns): UpdatedColumns {
const updated: UpdatedColumns = {};
for (const [key, newColumn] of Object.entries(newColumns)) {
let oldColumn = oldColumns[key];
if (!oldColumn) continue;
if (oldField.type !== newField.type && canChangeTypeWithoutQuery(oldField, newField)) {
if (oldColumn.type !== newColumn.type && canChangeTypeWithoutQuery(oldColumn, newColumn)) {
// If we can safely change the type without a query,
// try parsing the old schema as the new schema.
// This lets us diff the fields as if they were the same type.
const asNewField = fieldSchema.safeParse({
type: newField.type,
schema: oldField.schema,
// This lets us diff the columns as if they were the same type.
const asNewColumn = columnSchema.safeParse({
type: newColumn.type,
schema: oldColumn.schema,
});
if (asNewField.success) {
oldField = asNewField.data;
if (asNewColumn.success) {
oldColumn = asNewColumn.data;
}
// If parsing fails, move on to the standard diff.
}
const diff = deepDiff(oldField, newField);
const diff = deepDiff(oldColumn, newColumn);
if (diff) {
updated[key] = { old: oldField, new: newField };
updated[key] = { old: oldColumn, new: newColumn };
}
}
return updated;
}
const typeChangesWithoutQuery: Array<{ from: FieldType; to: FieldType }> = [
const typeChangesWithoutQuery: Array<{ from: ColumnType; to: ColumnType }> = [
{ from: 'boolean', to: 'number' },
{ from: 'date', to: 'text' },
{ from: 'json', to: 'text' },
];
function canChangeTypeWithoutQuery(oldField: DBField, newField: DBField) {
function canChangeTypeWithoutQuery(oldColumn: DBColumn, newColumn: DBColumn) {
return typeChangesWithoutQuery.some(
({ from, to }) => oldField.type === from && newField.type === to
({ from, to }) => oldColumn.type === from && newColumn.type === to
);
}
// Using `DBField` will not narrow `default` based on the column `type`
// Handle each field separately
type WithDefaultDefined<T extends DBField> = T & Required<Pick<T['schema'], 'default'>>;
type DBFieldWithDefault =
| WithDefaultDefined<TextField>
| WithDefaultDefined<DateField>
| WithDefaultDefined<NumberField>
| WithDefaultDefined<BooleanField>
| WithDefaultDefined<JsonField>;
// Using `DBColumn` will not narrow `default` based on the column `type`
// Handle each column separately
type WithDefaultDefined<T extends DBColumn> = T & Required<Pick<T['schema'], 'default'>>;
type DBColumnWithDefault =
| WithDefaultDefined<TextColumn>
| WithDefaultDefined<DateColumn>
| WithDefaultDefined<NumberColumn>
| WithDefaultDefined<BooleanColumn>
| WithDefaultDefined<JsonColumn>;
function hasRuntimeDefault(field: DBField): field is DBFieldWithDefault {
return !!(field.schema.default && isSerializedSQL(field.schema.default));
function hasRuntimeDefault(column: DBColumn): column is DBColumnWithDefault {
return !!(column.schema.default && isSerializedSQL(column.schema.default));
}

View file

@ -91,7 +91,7 @@ export async function loadMigration(
export async function loadInitialSnapshot(): Promise<DBSnapshot> {
const snapshot = JSON.parse(await readFile('./migrations/0000_snapshot.json', 'utf-8'));
// `experimentalVersion: 1` -- added the version field
// `experimentalVersion: 1` -- added the version column
if (snapshot.experimentalVersion === 1) {
return snapshot;
}

View file

@ -30,13 +30,13 @@ function generateTableType(name: string, collection: DBCollection): string {
${JSON.stringify(name)},
${JSON.stringify(
Object.fromEntries(
Object.entries(collection.fields).map(([fieldName, field]) => [
fieldName,
Object.entries(collection.columns).map(([columnName, column]) => [
columnName,
{
// Only select fields Drizzle needs for inference
type: field.type,
optional: field.schema.optional,
default: field.schema.default,
// Only select columns Drizzle needs for inference
type: column.type,
optional: column.schema.optional,
default: column.schema.default,
},
])
)

View file

@ -1,14 +1,14 @@
import type { SqliteRemoteDatabase } from 'drizzle-orm/sqlite-proxy';
import {
type BooleanField,
type BooleanColumn,
type DBCollection,
type DBCollections,
type DBField,
type DateField,
type FieldType,
type JsonField,
type NumberField,
type TextField,
type DBColumn,
type DateColumn,
type ColumnType,
type JsonColumn,
type NumberColumn,
type TextColumn,
} from '../core/types.js';
import { bold, red } from 'kleur/colors';
import { type SQL, sql, getTableName } from 'drizzle-orm';
@ -89,13 +89,13 @@ export function getCreateTableQuery(collectionName: string, collection: DBCollec
let query = `CREATE TABLE ${sqlite.escapeName(collectionName)} (`;
const colQueries = [];
const colHasPrimaryKey = Object.entries(collection.fields).find(([, field]) =>
hasPrimaryKey(field)
const colHasPrimaryKey = Object.entries(collection.columns).find(([, column]) =>
hasPrimaryKey(column)
);
if (!colHasPrimaryKey) {
colQueries.push('_id INTEGER PRIMARY KEY');
}
for (const [columnName, column] of Object.entries(collection.fields)) {
for (const [columnName, column] of Object.entries(collection.columns)) {
const colQuery = `${sqlite.escapeName(columnName)} ${schemaTypeToSqlType(
column.type
)}${getModifiers(columnName, column)}`;
@ -129,12 +129,12 @@ export function getCreateIndexQueries(
export function getCreateForeignKeyQueries(collectionName: string, collection: DBCollection) {
let queries: string[] = [];
for (const foreignKey of collection.foreignKeys ?? []) {
const fields = asArray(foreignKey.fields);
const columns = asArray(foreignKey.columns);
const references = asArray(foreignKey.references);
if (fields.length !== references.length) {
if (columns.length !== references.length) {
throw new Error(
`Foreign key on ${collectionName} is misconfigured. \`fields\` and \`references\` must be the same length.`
`Foreign key on ${collectionName} is misconfigured. \`columns\` and \`references\` must be the same length.`
);
}
const referencedCollection = references[0]?.schema.collection;
@ -143,7 +143,7 @@ export function getCreateForeignKeyQueries(collectionName: string, collection: D
`Foreign key on ${collectionName} is misconfigured. \`references\` cannot be empty.`
);
}
const query = `FOREIGN KEY (${fields
const query = `FOREIGN KEY (${columns
.map((f) => sqlite.escapeName(f))
.join(', ')}) REFERENCES ${sqlite.escapeName(referencedCollection)}(${references
.map((r) => sqlite.escapeName(r.schema.name!))
@ -157,7 +157,7 @@ function asArray<T>(value: T | T[]) {
return Array.isArray(value) ? value : [value];
}
export function schemaTypeToSqlType(type: FieldType): 'text' | 'integer' {
export function schemaTypeToSqlType(type: ColumnType): 'text' | 'integer' {
switch (type) {
case 'date':
case 'text':
@ -169,26 +169,26 @@ export function schemaTypeToSqlType(type: FieldType): 'text' | 'integer' {
}
}
export function getModifiers(fieldName: string, field: DBField) {
export function getModifiers(columnName: string, column: DBColumn) {
let modifiers = '';
if (hasPrimaryKey(field)) {
if (hasPrimaryKey(column)) {
return ' PRIMARY KEY';
}
if (!field.schema.optional) {
if (!column.schema.optional) {
modifiers += ' NOT NULL';
}
if (field.schema.unique) {
if (column.schema.unique) {
modifiers += ' UNIQUE';
}
if (hasDefault(field)) {
modifiers += ` DEFAULT ${getDefaultValueSql(fieldName, field)}`;
if (hasDefault(column)) {
modifiers += ` DEFAULT ${getDefaultValueSql(columnName, column)}`;
}
const references = getReferencesConfig(field);
const references = getReferencesConfig(column);
if (references) {
const { collection, name } = references.schema;
if (!collection || !name) {
throw new Error(
`Field ${collection}.${name} references a collection that does not exist. Did you apply the referenced collection to the \`collections\` object in your Astro config?`
`Column ${collection}.${name} references a collection that does not exist. Did you apply the referenced collection to the \`collections\` object in your Astro config?`
);
}
@ -197,30 +197,30 @@ export function getModifiers(fieldName: string, field: DBField) {
return modifiers;
}
export function getReferencesConfig(field: DBField) {
const canHaveReferences = field.type === 'number' || field.type === 'text';
export function getReferencesConfig(column: DBColumn) {
const canHaveReferences = column.type === 'number' || column.type === 'text';
if (!canHaveReferences) return undefined;
return field.schema.references;
return column.schema.references;
}
// Using `DBField` will not narrow `default` based on the column `type`
// Handle each field separately
type WithDefaultDefined<T extends DBField> = T & {
// 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 DBFieldWithDefault =
| WithDefaultDefined<TextField>
| WithDefaultDefined<DateField>
| WithDefaultDefined<NumberField>
| WithDefaultDefined<BooleanField>
| WithDefaultDefined<JsonField>;
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(field: DBField): field is DBFieldWithDefault {
if (field.schema.default !== undefined) {
export function hasDefault(column: DBColumn): column is DBColumnWithDefault {
if (column.schema.default !== undefined) {
return true;
}
if (hasPrimaryKey(field) && field.type === 'number') {
if (hasPrimaryKey(column) && column.type === 'number') {
return true;
}
return false;
@ -237,7 +237,7 @@ function toDefault<T>(def: T | SQL<any>): string {
}
}
function getDefaultValueSql(columnName: string, column: DBFieldWithDefault): string {
function getDefaultValueSql(columnName: string, column: DBColumnWithDefault): string {
if (isSerializedSQL(column.schema.default)) {
return column.schema.default.sql;
}

View file

@ -19,7 +19,7 @@ const sqlSchema = z.instanceof(SQL<any>).transform(
})
);
const baseFieldSchema = z.object({
const baseColumnSchema = z.object({
label: z.string().optional(),
optional: z.boolean().optional().default(false),
unique: z.boolean().optional().default(false),
@ -29,18 +29,18 @@ const baseFieldSchema = z.object({
collection: z.string().optional(),
});
const booleanFieldSchema = z.object({
const booleanColumnSchema = z.object({
type: z.literal('boolean'),
schema: baseFieldSchema.extend({
schema: baseColumnSchema.extend({
default: z.union([z.boolean(), sqlSchema]).optional(),
}),
});
const numberFieldBaseSchema = baseFieldSchema.omit({ optional: true }).and(
const numberColumnBaseSchema = baseColumnSchema.omit({ optional: true }).and(
z.union([
z.object({
primaryKey: z.literal(false).optional().default(false),
optional: baseFieldSchema.shape.optional,
optional: baseColumnSchema.shape.optional,
default: z.union([z.number(), sqlSchema]).optional(),
}),
z.object({
@ -54,31 +54,31 @@ const numberFieldBaseSchema = baseFieldSchema.omit({ optional: true }).and(
])
);
const numberFieldOptsSchema: z.ZodType<
z.infer<typeof numberFieldBaseSchema> & {
// ReferenceableField creates a circular type. Define ZodType to resolve.
references?: NumberField;
const numberColumnOptsSchema: z.ZodType<
z.infer<typeof numberColumnBaseSchema> & {
// ReferenceableColumn creates a circular type. Define ZodType to resolve.
references?: NumberColumn;
},
ZodTypeDef,
z.input<typeof numberFieldBaseSchema> & {
references?: () => z.input<typeof numberFieldSchema>;
z.input<typeof numberColumnBaseSchema> & {
references?: () => z.input<typeof numberColumnSchema>;
}
> = numberFieldBaseSchema.and(
> = numberColumnBaseSchema.and(
z.object({
references: z
.function()
.returns(z.lazy(() => numberFieldSchema))
.returns(z.lazy(() => numberColumnSchema))
.optional()
.transform((fn) => fn?.()),
})
);
const numberFieldSchema = z.object({
const numberColumnSchema = z.object({
type: z.literal('number'),
schema: numberFieldOptsSchema,
schema: numberColumnOptsSchema,
});
const textFieldBaseSchema = baseFieldSchema
const textColumnBaseSchema = baseColumnSchema
.omit({ optional: true })
.extend({
default: z.union([z.string(), sqlSchema]).optional(),
@ -88,7 +88,7 @@ const textFieldBaseSchema = baseFieldSchema
z.union([
z.object({
primaryKey: z.literal(false).optional().default(false),
optional: baseFieldSchema.shape.optional,
optional: baseColumnSchema.shape.optional,
}),
z.object({
// text primary key allows NULL values.
@ -101,33 +101,33 @@ const textFieldBaseSchema = baseFieldSchema
])
);
const textFieldOptsSchema: z.ZodType<
z.infer<typeof textFieldBaseSchema> & {
// ReferenceableField creates a circular type. Define ZodType to resolve.
references?: TextField;
const textColumnOptsSchema: z.ZodType<
z.infer<typeof textColumnBaseSchema> & {
// ReferenceableColumn creates a circular type. Define ZodType to resolve.
references?: TextColumn;
},
ZodTypeDef,
z.input<typeof textFieldBaseSchema> & {
references?: () => z.input<typeof textFieldSchema>;
z.input<typeof textColumnBaseSchema> & {
references?: () => z.input<typeof textColumnSchema>;
}
> = textFieldBaseSchema.and(
> = textColumnBaseSchema.and(
z.object({
references: z
.function()
.returns(z.lazy(() => textFieldSchema))
.returns(z.lazy(() => textColumnSchema))
.optional()
.transform((fn) => fn?.()),
})
);
const textFieldSchema = z.object({
const textColumnSchema = z.object({
type: z.literal('text'),
schema: textFieldOptsSchema,
schema: textColumnOptsSchema,
});
const dateFieldSchema = z.object({
const dateColumnSchema = z.object({
type: z.literal('date'),
schema: baseFieldSchema.extend({
schema: baseColumnSchema.extend({
default: z
.union([
sqlSchema,
@ -138,23 +138,23 @@ const dateFieldSchema = z.object({
}),
});
const jsonFieldSchema = z.object({
const jsonColumnSchema = z.object({
type: z.literal('json'),
schema: baseFieldSchema.extend({
schema: baseColumnSchema.extend({
default: z.unknown().optional(),
}),
});
export const fieldSchema = z.union([
booleanFieldSchema,
numberFieldSchema,
textFieldSchema,
dateFieldSchema,
jsonFieldSchema,
export const columnSchema = z.union([
booleanColumnSchema,
numberColumnSchema,
textColumnSchema,
dateColumnSchema,
jsonColumnSchema,
]);
export const referenceableFieldSchema = z.union([textFieldSchema, numberFieldSchema]);
export const referenceableColumnSchema = z.union([textColumnSchema, numberColumnSchema]);
const fieldsSchema = z.record(fieldSchema);
const columnsSchema = z.record(columnSchema);
export const indexSchema = z.object({
on: z.string().or(z.array(z.string())),
@ -162,27 +162,27 @@ export const indexSchema = z.object({
});
type ForeignKeysInput = {
fields: MaybeArray<string>;
references: () => MaybeArray<Omit<z.input<typeof referenceableFieldSchema>, 'references'>>;
columns: MaybeArray<string>;
references: () => MaybeArray<Omit<z.input<typeof referenceableColumnSchema>, 'references'>>;
};
type ForeignKeysOutput = Omit<ForeignKeysInput, 'references'> & {
// reference fn called in `transform`. Ensures output is JSON serializable.
references: MaybeArray<Omit<z.output<typeof referenceableFieldSchema>, 'references'>>;
references: MaybeArray<Omit<z.output<typeof referenceableColumnSchema>, 'references'>>;
};
const foreignKeysSchema: z.ZodType<ForeignKeysOutput, ZodTypeDef, ForeignKeysInput> = z.object({
fields: z.string().or(z.array(z.string())),
columns: z.string().or(z.array(z.string())),
references: z
.function()
.returns(z.lazy(() => referenceableFieldSchema.or(z.array(referenceableFieldSchema))))
.returns(z.lazy(() => referenceableColumnSchema.or(z.array(referenceableColumnSchema))))
.transform((fn) => fn()),
});
export type Indexes = Record<string, z.infer<typeof indexSchema>>;
const baseCollectionSchema = z.object({
fields: fieldsSchema,
columns: columnsSchema,
indexes: z.record(indexSchema).optional(),
foreignKeys: z.array(foreignKeysSchema).optional(),
});
@ -206,43 +206,43 @@ export const collectionsSchema = z.preprocess((rawCollections) => {
collectionName,
collectionSchema.parse(collection, { errorMap })
);
// Append collection and field names to fields.
// Append collection and column names to columns.
// Used to track collection info for references.
const { fields } = z.object({ fields: z.record(z.any()) }).parse(collection, { errorMap });
for (const [fieldName, field] of Object.entries(fields)) {
field.schema.name = fieldName;
field.schema.collection = collectionName;
const { columns } = z.object({ columns: z.record(z.any()) }).parse(collection, { errorMap });
for (const [columnName, column] of Object.entries(columns)) {
column.schema.name = columnName;
column.schema.collection = collectionName;
}
}
return rawCollections;
}, z.record(collectionSchema));
export type BooleanField = z.infer<typeof booleanFieldSchema>;
export type BooleanFieldInput = z.input<typeof booleanFieldSchema>;
export type NumberField = z.infer<typeof numberFieldSchema>;
export type NumberFieldInput = z.input<typeof numberFieldSchema>;
export type TextField = z.infer<typeof textFieldSchema>;
export type TextFieldInput = z.input<typeof textFieldSchema>;
export type DateField = z.infer<typeof dateFieldSchema>;
export type DateFieldInput = z.input<typeof dateFieldSchema>;
export type JsonField = z.infer<typeof jsonFieldSchema>;
export type JsonFieldInput = z.input<typeof jsonFieldSchema>;
export type BooleanColumn = z.infer<typeof booleanColumnSchema>;
export type BooleanColumnInput = z.input<typeof booleanColumnSchema>;
export type NumberColumn = z.infer<typeof numberColumnSchema>;
export type NumberColumnInput = z.input<typeof numberColumnSchema>;
export type TextColumn = z.infer<typeof textColumnSchema>;
export type TextColumnInput = z.input<typeof textColumnSchema>;
export type DateColumn = z.infer<typeof dateColumnSchema>;
export type DateColumnInput = z.input<typeof dateColumnSchema>;
export type JsonColumn = z.infer<typeof jsonColumnSchema>;
export type JsonColumnInput = z.input<typeof jsonColumnSchema>;
export type FieldType =
| BooleanField['type']
| NumberField['type']
| TextField['type']
| DateField['type']
| JsonField['type'];
export type ColumnType =
| BooleanColumn['type']
| NumberColumn['type']
| TextColumn['type']
| DateColumn['type']
| JsonColumn['type'];
export type DBField = z.infer<typeof fieldSchema>;
export type DBFieldInput =
| DateFieldInput
| BooleanFieldInput
| NumberFieldInput
| TextFieldInput
| JsonFieldInput;
export type DBFields = z.infer<typeof fieldsSchema>;
export type DBColumn = z.infer<typeof columnSchema>;
export type DBColumnInput =
| DateColumnInput
| BooleanColumnInput
| NumberColumnInput
| TextColumnInput
| JsonColumnInput;
export type DBColumns = z.infer<typeof columnsSchema>;
export type DBCollection = z.infer<
typeof readableCollectionSchema | typeof writableCollectionSchema
>;
@ -260,20 +260,20 @@ export type WritableDBCollection = z.infer<typeof writableCollectionSchema>;
export type DBDataContext = {
db: SqliteDB;
seed: <TFields extends FieldsConfig>(
collection: ResolvedCollectionConfig<TFields>,
data: MaybeArray<SQLiteInsertValue<Table<string, TFields>>>
seed: <TColumns extends ColumnsConfig>(
collection: ResolvedCollectionConfig<TColumns>,
data: MaybeArray<SQLiteInsertValue<Table<string, TColumns>>>
) => Promise<void>;
seedReturning: <
TFields extends FieldsConfig,
TData extends MaybeArray<SQLiteInsertValue<Table<string, TFields>>>,
TColumns extends ColumnsConfig,
TData extends MaybeArray<SQLiteInsertValue<Table<string, TColumns>>>,
>(
collection: ResolvedCollectionConfig<TFields>,
collection: ResolvedCollectionConfig<TColumns>,
data: TData
) => Promise<
TData extends Array<SQLiteInsertValue<Table<string, TFields>>>
? InferSelectModel<Table<string, TFields>>[]
: InferSelectModel<Table<string, TFields>>
TData extends Array<SQLiteInsertValue<Table<string, TColumns>>>
? InferSelectModel<Table<string, TColumns>>[]
: InferSelectModel<Table<string, TColumns>>
>;
mode: 'dev' | 'build';
};
@ -300,37 +300,37 @@ export const astroConfigWithDbSchema = z.object({
db: dbConfigSchema.optional(),
});
export type FieldsConfig = z.input<typeof collectionSchema>['fields'];
export type ColumnsConfig = z.input<typeof collectionSchema>['columns'];
interface CollectionConfig<TFields extends FieldsConfig = FieldsConfig>
interface CollectionConfig<TColumns extends ColumnsConfig = ColumnsConfig>
// use `extends` to ensure types line up with zod,
// only adding generics for type completions.
extends Pick<z.input<typeof collectionSchema>, 'fields' | 'indexes' | 'foreignKeys'> {
fields: TFields;
extends Pick<z.input<typeof collectionSchema>, 'columns' | 'indexes' | 'foreignKeys'> {
columns: TColumns;
foreignKeys?: Array<{
fields: MaybeArray<Extract<keyof TFields, string>>;
// TODO: runtime error if parent collection doesn't match for all fields. Can't put a generic here...
references: () => MaybeArray<z.input<typeof referenceableFieldSchema>>;
columns: MaybeArray<Extract<keyof TColumns, string>>;
// TODO: runtime error if parent collection doesn't match for all columns. Can't put a generic here...
references: () => MaybeArray<z.input<typeof referenceableColumnSchema>>;
}>;
indexes?: Record<string, IndexConfig<TFields>>;
indexes?: Record<string, IndexConfig<TColumns>>;
}
interface IndexConfig<TFields extends FieldsConfig> extends z.input<typeof indexSchema> {
on: MaybeArray<Extract<keyof TFields, string>>;
interface IndexConfig<TColumns extends ColumnsConfig> extends z.input<typeof indexSchema> {
on: MaybeArray<Extract<keyof TColumns, string>>;
}
export type ResolvedCollectionConfig<
TFields extends FieldsConfig = FieldsConfig,
TColumns extends ColumnsConfig = ColumnsConfig,
Writable extends boolean = boolean,
> = CollectionConfig<TFields> & {
> = CollectionConfig<TColumns> & {
writable: Writable;
table: Table<string, TFields>;
table: Table<string, TColumns>;
};
function baseDefineCollection<TFields extends FieldsConfig, TWritable extends boolean>(
userConfig: CollectionConfig<TFields>,
function baseDefineCollection<TColumns extends ColumnsConfig, TWritable extends boolean>(
userConfig: CollectionConfig<TColumns>,
writable: TWritable
): ResolvedCollectionConfig<TFields, TWritable> {
): ResolvedCollectionConfig<TColumns, TWritable> {
return {
...userConfig,
writable,
@ -339,26 +339,26 @@ function baseDefineCollection<TFields extends FieldsConfig, TWritable extends bo
};
}
export function defineCollection<TFields extends FieldsConfig>(
userConfig: CollectionConfig<TFields>
): ResolvedCollectionConfig<TFields, false> {
export function defineCollection<TColumns extends ColumnsConfig>(
userConfig: CollectionConfig<TColumns>
): ResolvedCollectionConfig<TColumns, false> {
return baseDefineCollection(userConfig, false);
}
export function defineWritableCollection<TFields extends FieldsConfig>(
userConfig: CollectionConfig<TFields>
): ResolvedCollectionConfig<TFields, true> {
export function defineWritableCollection<TColumns extends ColumnsConfig>(
userConfig: CollectionConfig<TColumns>
): ResolvedCollectionConfig<TColumns, true> {
return baseDefineCollection(userConfig, true);
}
export type AstroConfigWithDB = z.input<typeof astroConfigWithDbSchema>;
// We cannot use `Omit<NumberField | TextField, 'type'>`,
// We cannot use `Omit<NumberColumn | TextColumn, 'type'>`,
// since Omit collapses our union type on primary key.
type NumberFieldOpts = z.input<typeof numberFieldOptsSchema>;
type TextFieldOpts = z.input<typeof textFieldOptsSchema>;
type NumberColumnOpts = z.input<typeof numberColumnOptsSchema>;
type TextColumnOpts = z.input<typeof textColumnOptsSchema>;
function createField<S extends string, T extends Record<string, unknown>>(type: S, schema: T) {
function createColumn<S extends string, T extends Record<string, unknown>>(type: S, schema: T) {
return {
type,
/**
@ -368,20 +368,20 @@ function createField<S extends string, T extends Record<string, unknown>>(type:
};
}
export const field = {
number: <T extends NumberFieldOpts>(opts: T = {} as T) => {
return createField('number', opts) satisfies { type: 'number' };
export const column = {
number: <T extends NumberColumnOpts>(opts: T = {} as T) => {
return createColumn('number', opts) satisfies { type: 'number' };
},
boolean: <T extends BooleanFieldInput['schema']>(opts: T = {} as T) => {
return createField('boolean', opts) satisfies { type: 'boolean' };
boolean: <T extends BooleanColumnInput['schema']>(opts: T = {} as T) => {
return createColumn('boolean', opts) satisfies { type: 'boolean' };
},
text: <T extends TextFieldOpts>(opts: T = {} as T) => {
return createField('text', opts) satisfies { type: 'text' };
text: <T extends TextColumnOpts>(opts: T = {} as T) => {
return createColumn('text', opts) satisfies { type: 'text' };
},
date<T extends DateFieldInput['schema']>(opts: T = {} as T) {
return createField('date', opts) satisfies { type: 'date' };
date<T extends DateColumnInput['schema']>(opts: T = {} as T) {
return createColumn('date', opts) satisfies { type: 'date' };
},
json<T extends JsonFieldInput['schema']>(opts: T = {} as T) {
return createField('json', opts) satisfies { type: 'json' };
json<T extends JsonColumnInput['schema']>(opts: T = {} as T) {
return createColumn('json', opts) satisfies { type: 'json' };
},
};

View file

@ -1,4 +1,4 @@
export { defineCollection, defineWritableCollection, defineData, field } from './core/types.js';
export { defineCollection, defineWritableCollection, defineData, column } from './core/types.js';
export type { ResolvedCollectionConfig, DBDataContext } from './core/types.js';
export { cli } from './core/cli/index.js';
export { integration as default } from './core/integration/index.js';

View file

@ -1,5 +1,5 @@
import type { SqliteRemoteDatabase } from 'drizzle-orm/sqlite-proxy';
import { type DBCollection, type DBField } from '../core/types.js';
import { type DBCollection, type DBColumn } from '../core/types.js';
import { type ColumnBuilderBaseConfig, type ColumnDataType, sql, SQL } from 'drizzle-orm';
import {
customType,
@ -18,8 +18,8 @@ export type SqliteDB = SqliteRemoteDatabase;
export type { Table } from './types.js';
export { createRemoteDatabaseClient, createLocalDatabaseClient } from './db-client.js';
export function hasPrimaryKey(field: DBField) {
return 'primaryKey' in field.schema && !!field.schema.primaryKey;
export function hasPrimaryKey(column: DBColumn) {
return 'primaryKey' in column.schema && !!column.schema.primaryKey;
}
// Exports a few common expressions
@ -58,11 +58,11 @@ type D1ColumnBuilder = SQLiteColumnBuilderBase<
export function collectionToTable(name: string, collection: DBCollection) {
const columns: Record<string, D1ColumnBuilder> = {};
if (!Object.entries(collection.fields).some(([, field]) => hasPrimaryKey(field))) {
if (!Object.entries(collection.columns).some(([, column]) => hasPrimaryKey(column))) {
columns['_id'] = integer('_id').primaryKey();
}
for (const [fieldName, field] of Object.entries(collection.fields)) {
columns[fieldName] = columnMapper(fieldName, field);
for (const [columnName, column] of Object.entries(collection.columns)) {
columns[columnName] = columnMapper(columnName, column);
}
const table = sqliteTable(name, columns, (ormTable) => {
const indexes: Record<string, IndexBuilder> = {};
@ -82,7 +82,7 @@ function atLeastOne<T>(arr: T[]): arr is [T, ...T[]] {
return arr.length > 0;
}
function columnMapper(fieldName: string, field: DBField) {
function columnMapper(columnName: string, column: DBColumn) {
let c: ReturnType<
| typeof text
| typeof integer
@ -91,45 +91,45 @@ function columnMapper(fieldName: string, field: DBField) {
| typeof integer<string, 'boolean'>
>;
switch (field.type) {
switch (column.type) {
case 'text': {
c = text(fieldName);
c = text(columnName);
// Duplicate default logic across cases to preserve type inference.
// No clean generic for every column builder.
if (field.schema.default !== undefined)
c = c.default(handleSerializedSQL(field.schema.default));
if (field.schema.primaryKey === true) c = c.primaryKey();
if (column.schema.default !== undefined)
c = c.default(handleSerializedSQL(column.schema.default));
if (column.schema.primaryKey === true) c = c.primaryKey();
break;
}
case 'number': {
c = integer(fieldName);
if (field.schema.default !== undefined)
c = c.default(handleSerializedSQL(field.schema.default));
if (field.schema.primaryKey === true) c = c.primaryKey();
c = integer(columnName);
if (column.schema.default !== undefined)
c = c.default(handleSerializedSQL(column.schema.default));
if (column.schema.primaryKey === true) c = c.primaryKey();
break;
}
case 'boolean': {
c = integer(fieldName, { mode: 'boolean' });
if (field.schema.default !== undefined)
c = c.default(handleSerializedSQL(field.schema.default));
c = integer(columnName, { mode: 'boolean' });
if (column.schema.default !== undefined)
c = c.default(handleSerializedSQL(column.schema.default));
break;
}
case 'json':
c = jsonType(fieldName);
if (field.schema.default !== undefined) c = c.default(field.schema.default);
c = jsonType(columnName);
if (column.schema.default !== undefined) c = c.default(column.schema.default);
break;
case 'date': {
c = dateType(fieldName);
if (field.schema.default !== undefined) {
const def = handleSerializedSQL(field.schema.default);
c = dateType(columnName);
if (column.schema.default !== undefined) {
const def = handleSerializedSQL(column.schema.default);
c = c.default(typeof def === 'string' ? new Date(def) : def);
}
break;
}
}
if (!field.schema.optional) c = c.notNull();
if (field.schema.unique) c = c.unique();
if (!column.schema.optional) c = c.notNull();
if (column.schema.unique) c = c.unique();
return c;
}

View file

@ -1,6 +1,6 @@
import type { ColumnDataType, ColumnBaseConfig } from 'drizzle-orm';
import type { SQLiteColumn, SQLiteTableWithColumns } from 'drizzle-orm/sqlite-core';
import type { DBField, FieldsConfig } from '../core/types.js';
import type { DBColumn, ColumnsConfig } from '../core/types.js';
type GeneratedConfig<T extends ColumnDataType = ColumnDataType> = Pick<
ColumnBaseConfig<T, string>,
@ -62,7 +62,7 @@ export type AstroJson<T extends GeneratedConfig<'custom'>> = SQLiteColumn<
}
>;
export type Column<T extends DBField['type'], S extends GeneratedConfig> = T extends 'boolean'
export type Column<T extends DBColumn['type'], S extends GeneratedConfig> = T extends 'boolean'
? AstroBoolean<S>
: T extends 'number'
? AstroNumber<S>
@ -76,23 +76,23 @@ export type Column<T extends DBField['type'], S extends GeneratedConfig> = T ext
export type Table<
TTableName extends string,
TFields extends FieldsConfig,
TColumns extends ColumnsConfig,
> = SQLiteTableWithColumns<{
name: TTableName;
schema: undefined;
dialect: 'sqlite';
columns: {
[K in Extract<keyof TFields, string>]: Column<
TFields[K]['type'],
[K in Extract<keyof TColumns, string>]: Column<
TColumns[K]['type'],
{
tableName: TTableName;
name: K;
hasDefault: TFields[K]['schema'] extends { default: NonNullable<unknown> }
hasDefault: TColumns[K]['schema'] extends { default: NonNullable<unknown> }
? true
: TFields[K]['schema'] extends { primaryKey: true }
: TColumns[K]['schema'] extends { primaryKey: true }
? true
: false;
notNull: TFields[K]['schema']['optional'] extends true ? false : true;
notNull: TColumns[K]['schema']['optional'] extends true ? false : true;
}
>;
};

View file

@ -60,7 +60,7 @@ describe('astro:db', () => {
app = await fixture.loadTestAdapterApp();
});
it('Allows expression defaults for date fields', async () => {
it('Allows expression defaults for date columns', async () => {
const request = new Request('http://example.com/');
const res = await app.render(request);
const html = await res.text();
@ -80,7 +80,7 @@ describe('astro:db', () => {
expect(new Date(themeAdded).getTime()).to.not.be.NaN;
});
it('Allows expression defaults for text fields', async () => {
it('Allows expression defaults for text columns', async () => {
const request = new Request('http://example.com/');
const res = await app.render(request);
const html = await res.text();
@ -90,7 +90,7 @@ describe('astro:db', () => {
expect(themeOwner).to.equal('');
});
it('Allows expression defaults for boolean fields', async () => {
it('Allows expression defaults for boolean columns', async () => {
const request = new Request('http://example.com/');
const res = await app.render(request);
const html = await res.text();

View file

@ -1,23 +1,23 @@
import { defineConfig } from 'astro/config';
import db, { defineCollection, defineWritableCollection, field, sql, NOW } from '@astrojs/db';
import db, { defineCollection, defineWritableCollection, column, sql, NOW } from '@astrojs/db';
const Author = defineCollection({
fields: {
name: field.text(),
columns: {
name: column.text(),
},
});
const Themes = defineWritableCollection({
fields: {
name: field.text(),
added: field.date({
columns: {
name: column.text(),
added: column.date({
default: sql`CURRENT_TIMESTAMP`
}),
updated: field.date({
updated: column.date({
default: NOW
}),
isDark: field.boolean({ default: sql`TRUE` }),
owner: field.text({ optional: true, default: sql`NULL` }),
isDark: column.boolean({ default: sql`TRUE` }),
owner: column.text({ optional: true, default: sql`NULL` }),
},
});

View file

@ -1,12 +1,12 @@
import { defineConfig } from 'astro/config';
import db, { defineCollection, field } from '@astrojs/db';
import db, { defineCollection, column } from '@astrojs/db';
import { asJson, createGlob } from './utils';
const Quote = defineCollection({
fields: {
author: field.text(),
body: field.text(),
file: field.text({ unique: true }),
columns: {
author: column.text(),
body: column.text(),
file: column.text({ unique: true }),
},
});

View file

@ -14,9 +14,9 @@ export function createGlob({ db, mode }: Pick<DBDataContext, 'db' | 'mode'>) {
) {
// TODO: expose `table`
const { table } = opts.into as any;
const fileField = table.file;
if (!fileField) {
throw new Error('`file` field is required for glob collections.');
const fileColumn = table.file;
if (!fileColumn) {
throw new Error('`file` column is required for glob collections.');
}
if (mode === 'dev') {
chokidar
@ -33,12 +33,12 @@ export function createGlob({ db, mode }: Pick<DBDataContext, 'db' | 'mode'>) {
.insert(table)
.values({ ...parsed, file })
.onConflictDoUpdate({
target: fileField,
target: fileColumn,
set: parsed,
});
})
.on('unlink', async (file) => {
await db.delete(table).where(eq(fileField, file));
await db.delete(table).where(eq(fileColumn, file));
});
} else {
const files = await fastGlob(pattern);

View file

@ -1,25 +1,25 @@
import { defineConfig } from 'astro/config';
import astroDb, { defineCollection, field } from '@astrojs/db';
import astroDb, { defineCollection, column } from '@astrojs/db';
const Recipe = defineCollection({
fields: {
id: field.number({ primaryKey: true }),
title: field.text(),
description: field.text(),
columns: {
id: column.number({ primaryKey: true }),
title: column.text(),
description: column.text(),
},
});
const Ingredient = defineCollection({
fields: {
id: field.number({ primaryKey: true }),
name: field.text(),
quantity: field.number(),
recipeId: field.number(),
columns: {
id: column.number({ primaryKey: true }),
name: column.text(),
quantity: column.number(),
recipeId: column.number(),
},
indexes: {
recipeIdx: { on: 'recipeId' },
},
foreignKeys: [{ fields: 'recipeId', references: () => [Recipe.fields.id] }],
foreignKeys: [{ columns: 'recipeId', references: () => [Recipe.columns.id] }],
});
export default defineConfig({

View file

@ -1,25 +1,25 @@
import { defineConfig } from 'astro/config';
import preact from '@astrojs/preact';
import simpleStackForm from 'simple-stack-form';
import db, { defineCollection, defineWritableCollection, field } from '@astrojs/db';
import db, { defineCollection, defineWritableCollection, column } from '@astrojs/db';
import node from '@astrojs/node';
const Event = defineCollection({
fields: {
id: field.number({ primaryKey: true }),
name: field.text(),
description: field.text(),
ticketPrice: field.number(),
date: field.date(),
location: field.text(),
columns: {
id: column.number({ primaryKey: true }),
name: column.text(),
description: column.text(),
ticketPrice: column.number(),
date: column.date(),
location: column.text(),
},
});
const Ticket = defineWritableCollection({
fields: {
eventId: field.text(),
email: field.text(),
quantity: field.number(),
newsletter: field.boolean({
columns: {
eventId: column.text(),
email: column.text(),
quantity: column.number(),
newsletter: column.boolean({
default: false,
}),
},

View file

@ -4,25 +4,25 @@ import { type ComponentProps, createContext } from 'preact';
import { useContext, useState } from 'preact/hooks';
import { navigate } from 'astro:transitions/client';
import {
type FieldErrors,
type ColumnErrors,
type FormState,
type FormValidator,
formNameInputProps,
getInitialFormState,
toSetValidationErrors,
toTrackAstroSubmitStatus,
toValidateField,
toValidateColumn,
validateForm,
} from 'simple:form';
export function useCreateFormContext(validator: FormValidator, fieldErrors?: FieldErrors) {
const initial = getInitialFormState({ validator, fieldErrors });
export function useCreateFormContext(validator: FormValidator, columnErrors?: ColumnErrors) {
const initial = getInitialFormState({ validator, columnErrors });
const [formState, setFormState] = useState<FormState>(initial);
return {
value: formState,
set: setFormState,
setValidationErrors: toSetValidationErrors(setFormState),
validateField: toValidateField(setFormState),
validateColumn: toValidateColumn(setFormState),
trackAstroSubmitStatus: toTrackAstroSubmitStatus(setFormState),
};
}
@ -45,15 +45,15 @@ export function Form({
children,
validator,
context,
fieldErrors,
columnErrors,
name,
...formProps
}: {
validator: FormValidator;
context?: FormContextType;
fieldErrors?: FieldErrors;
columnErrors?: ColumnErrors;
} & Omit<ComponentProps<'form'>, 'method' | 'onSubmit'>) {
const formContext = context ?? useCreateFormContext(validator, fieldErrors);
const formContext = context ?? useCreateFormContext(validator, columnErrors);
return (
<FormContext.Provider value={formContext}>
@ -80,7 +80,7 @@ export function Form({
return formContext.trackAstroSubmitStatus();
}
formContext.setValidationErrors(parsed.fieldErrors);
formContext.setValidationErrors(parsed.columnErrors);
}}
>
{name ? <input {...formNameInputProps} value={name} /> : null}
@ -92,28 +92,28 @@ export function Form({
export function Input({ onInput, ...inputProps }: ComponentProps<'input'> & { name: string }) {
const formContext = useFormContext();
const fieldState = formContext.value.fields[inputProps.name];
if (!fieldState) {
const columnState = formContext.value.columns[inputProps.name];
if (!columnState) {
throw new Error(
`Input "${inputProps.name}" not found in form. Did you use the <Form> component?`
);
}
const { hasErroredOnce, validationErrors, validator } = fieldState;
const { hasErroredOnce, validationErrors, validator } = columnState;
return (
<>
<input
onBlur={async (e) => {
const value = e.currentTarget.value;
if (value === '') return;
formContext.validateField(inputProps.name, value, validator);
formContext.validateColumn(inputProps.name, value, validator);
}}
onInput={async (e) => {
onInput?.(e);
if (!hasErroredOnce) return;
const value = e.currentTarget.value;
formContext.validateField(inputProps.name, value, validator);
formContext.validateColumn(inputProps.name, value, validator);
}}
{...inputProps}
/>

View file

@ -5,27 +5,27 @@ import {
getMigrationQueries,
} from '../../dist/core/cli/migration-queries.js';
import { getCreateTableQuery } from '../../dist/core/queries.js';
import { field, defineCollection, collectionSchema } from '../../dist/core/types.js';
import { column, defineCollection, collectionSchema } from '../../dist/core/types.js';
import { NOW, sql } from '../../dist/runtime/index.js';
const COLLECTION_NAME = 'Users';
// `parse` to resolve schema transformations
// ex. convert field.date() to ISO strings
// ex. convert column.date() to ISO strings
const userInitial = collectionSchema.parse(
defineCollection({
fields: {
name: field.text(),
age: field.number(),
email: field.text({ unique: true }),
mi: field.text({ optional: true }),
columns: {
name: column.text(),
age: column.number(),
email: column.text({ unique: true }),
mi: column.text({ optional: true }),
},
})
);
const defaultAmbiguityResponses = {
collectionRenames: {},
fieldRenames: {},
columnRenames: {},
};
function userChangeQueries(
@ -53,7 +53,7 @@ function configChangeQueries(
});
}
describe('field queries', () => {
describe('column queries', () => {
describe('getMigrationQueries', () => {
it('should be empty when collections are the same', async () => {
const oldCollections = { [COLLECTION_NAME]: userInitial };
@ -97,16 +97,16 @@ describe('field queries', () => {
it('should be empty when type updated to same underlying SQL type', async () => {
const blogInitial = collectionSchema.parse({
...userInitial,
fields: {
title: field.text(),
draft: field.boolean(),
columns: {
title: column.text(),
draft: column.boolean(),
},
});
const blogFinal = collectionSchema.parse({
...userInitial,
fields: {
...blogInitial.fields,
draft: field.number(),
columns: {
...blogInitial.columns,
draft: column.number(),
},
});
const { queries } = await userChangeQueries(blogInitial, blogFinal);
@ -116,17 +116,17 @@ describe('field queries', () => {
it('should respect user primary key without adding a hidden id', async () => {
const user = collectionSchema.parse({
...userInitial,
fields: {
...userInitial.fields,
id: field.number({ primaryKey: true }),
columns: {
...userInitial.columns,
id: column.number({ primaryKey: true }),
},
});
const userFinal = collectionSchema.parse({
...user,
fields: {
...user.fields,
name: field.text({ unique: true, optional: true }),
columns: {
...user.columns,
name: column.text({ unique: true, optional: true }),
},
});
@ -143,19 +143,19 @@ describe('field queries', () => {
});
describe('ALTER RENAME COLUMN', () => {
it('when renaming a field', async () => {
it('when renaming a column', async () => {
const userFinal = {
...userInitial,
fields: {
...userInitial.fields,
columns: {
...userInitial.columns,
},
};
userFinal.fields.middleInitial = userFinal.fields.mi;
delete userFinal.fields.mi;
userFinal.columns.middleInitial = userFinal.columns.mi;
delete userFinal.columns.mi;
const { queries } = await userChangeQueries(userInitial, userFinal, {
collectionRenames: {},
fieldRenames: { [COLLECTION_NAME]: { middleInitial: 'mi' } },
columnRenames: { [COLLECTION_NAME]: { middleInitial: 'mi' } },
});
expect(queries).to.deep.equal([
`ALTER TABLE "${COLLECTION_NAME}" RENAME COLUMN "mi" TO "middleInitial"`,
@ -164,12 +164,12 @@ describe('field queries', () => {
});
describe('Lossy table recreate', () => {
it('when changing a field type', async () => {
it('when changing a column type', async () => {
const userFinal = {
...userInitial,
fields: {
...userInitial.fields,
age: field.text(),
columns: {
...userInitial.columns,
age: column.text(),
},
};
@ -181,12 +181,12 @@ describe('field queries', () => {
]);
});
it('when adding a required field without a default', async () => {
it('when adding a required column without a default', async () => {
const userFinal = {
...userInitial,
fields: {
...userInitial.fields,
phoneNumber: field.text(),
columns: {
...userInitial.columns,
phoneNumber: column.text(),
},
};
@ -203,9 +203,9 @@ describe('field queries', () => {
it('when adding a primary key', async () => {
const userFinal = {
...userInitial,
fields: {
...userInitial.fields,
id: field.number({ primaryKey: true }),
columns: {
...userInitial.columns,
id: column.number({ primaryKey: true }),
},
};
@ -224,9 +224,9 @@ describe('field queries', () => {
it('when dropping a primary key', async () => {
const user = {
...userInitial,
fields: {
...userInitial.fields,
id: field.number({ primaryKey: true }),
columns: {
...userInitial.columns,
id: column.number({ primaryKey: true }),
},
};
@ -242,12 +242,12 @@ describe('field queries', () => {
]);
});
it('when adding an optional unique field', async () => {
it('when adding an optional unique column', async () => {
const userFinal = {
...userInitial,
fields: {
...userInitial.fields,
phoneNumber: field.text({ unique: true, optional: true }),
columns: {
...userInitial.columns,
phoneNumber: column.text({ unique: true, optional: true }),
},
};
@ -267,11 +267,11 @@ describe('field queries', () => {
it('when dropping unique column', async () => {
const userFinal = {
...userInitial,
fields: {
...userInitial.fields,
columns: {
...userInitial.columns,
},
};
delete userFinal.fields.email;
delete userFinal.columns.email;
const { queries } = await userChangeQueries(userInitial, userFinal);
expect(queries).to.have.lengthOf(4);
@ -289,17 +289,17 @@ describe('field queries', () => {
it('when updating to a runtime default', async () => {
const initial = collectionSchema.parse({
...userInitial,
fields: {
...userInitial.fields,
age: field.date(),
columns: {
...userInitial.columns,
age: column.date(),
},
});
const userFinal = collectionSchema.parse({
...initial,
fields: {
...initial.fields,
age: field.date({ default: NOW }),
columns: {
...initial.columns,
age: column.date({ default: NOW }),
},
});
@ -316,12 +316,12 @@ describe('field queries', () => {
]);
});
it('when adding a field with a runtime default', async () => {
it('when adding a column with a runtime default', async () => {
const userFinal = collectionSchema.parse({
...userInitial,
fields: {
...userInitial.fields,
birthday: field.date({ default: NOW }),
columns: {
...userInitial.columns,
birthday: column.date({ default: NOW }),
},
});
@ -345,12 +345,12 @@ describe('field queries', () => {
*
* @see https://planetscale.com/blog/safely-making-database-schema-changes#backwards-compatible-changes
*/
it('when changing a field to required', async () => {
it('when changing a column to required', async () => {
const userFinal = {
...userInitial,
fields: {
...userInitial.fields,
mi: field.text(),
columns: {
...userInitial.columns,
mi: column.text(),
},
};
@ -368,12 +368,12 @@ describe('field queries', () => {
]);
});
it('when changing a field to unique', async () => {
it('when changing a column to unique', async () => {
const userFinal = {
...userInitial,
fields: {
...userInitial.fields,
age: field.number({ unique: true }),
columns: {
...userInitial.columns,
age: column.number({ unique: true }),
},
};
@ -392,12 +392,12 @@ describe('field queries', () => {
});
describe('ALTER ADD COLUMN', () => {
it('when adding an optional field', async () => {
it('when adding an optional column', async () => {
const userFinal = {
...userInitial,
fields: {
...userInitial.fields,
birthday: field.date({ optional: true }),
columns: {
...userInitial.columns,
birthday: column.date({ optional: true }),
},
};
@ -405,13 +405,13 @@ describe('field queries', () => {
expect(queries).to.deep.equal(['ALTER TABLE "Users" ADD COLUMN "birthday" text']);
});
it('when adding a required field with default', async () => {
it('when adding a required column with default', async () => {
const defaultDate = new Date('2023-01-01');
const userFinal = collectionSchema.parse({
...userInitial,
fields: {
...userInitial.fields,
birthday: field.date({ default: new Date('2023-01-01') }),
columns: {
...userInitial.columns,
birthday: column.date({ default: new Date('2023-01-01') }),
},
});
@ -423,12 +423,12 @@ describe('field queries', () => {
});
describe('ALTER DROP COLUMN', () => {
it('when removing optional or required fields', async () => {
it('when removing optional or required columns', async () => {
const userFinal = {
...userInitial,
fields: {
name: userInitial.fields.name,
email: userInitial.fields.email,
columns: {
name: userInitial.columns.name,
email: userInitial.columns.email,
},
};

View file

@ -1,14 +1,14 @@
import { expect } from 'chai';
import { describe, it } from 'mocha';
import { getCollectionChangeQueries } from '../../dist/core/cli/migration-queries.js';
import { field, collectionSchema } from '../../dist/core/types.js';
import { column, collectionSchema } from '../../dist/core/types.js';
const userInitial = collectionSchema.parse({
fields: {
name: field.text(),
age: field.number(),
email: field.text({ unique: true }),
mi: field.text({ optional: true }),
columns: {
name: column.text(),
age: column.number(),
email: column.text({ unique: true }),
mi: column.text({ optional: true }),
},
indexes: {},
writable: false,

View file

@ -1,30 +1,30 @@
import { expect } from 'chai';
import { describe, it } from 'mocha';
import { getCollectionChangeQueries } from '../../dist/core/cli/migration-queries.js';
import { field, defineCollection, collectionsSchema } from '../../dist/core/types.js';
import { column, defineCollection, collectionsSchema } from '../../dist/core/types.js';
const BaseUser = defineCollection({
fields: {
id: field.number({ primaryKey: true }),
name: field.text(),
age: field.number(),
email: field.text({ unique: true }),
mi: field.text({ optional: true }),
columns: {
id: column.number({ primaryKey: true }),
name: column.text(),
age: column.number(),
email: column.text({ unique: true }),
mi: column.text({ optional: true }),
},
});
const BaseSentBox = defineCollection({
fields: {
to: field.number(),
toName: field.text(),
subject: field.text(),
body: field.text(),
columns: {
to: column.number(),
toName: column.text(),
subject: column.text(),
body: column.text(),
},
});
const defaultAmbiguityResponses = {
collectionRenames: {},
fieldRenames: {},
columnRenames: {},
};
/**
@ -59,9 +59,9 @@ describe('reference queries', () => {
const { SentBox: Initial } = resolveReferences();
const { SentBox: Final } = resolveReferences({
SentBox: defineCollection({
fields: {
...BaseSentBox.fields,
to: field.number({ references: () => BaseUser.fields.id }),
columns: {
...BaseSentBox.columns,
to: column.number({ references: () => BaseUser.columns.id }),
},
}),
});
@ -83,9 +83,9 @@ describe('reference queries', () => {
it('removes references with lossless table recreate', async () => {
const { SentBox: Initial } = resolveReferences({
SentBox: defineCollection({
fields: {
...BaseSentBox.fields,
to: field.number({ references: () => BaseUser.fields.id }),
columns: {
...BaseSentBox.columns,
to: column.number({ references: () => BaseUser.columns.id }),
},
}),
});
@ -109,9 +109,9 @@ describe('reference queries', () => {
const { SentBox: Initial } = resolveReferences();
const { SentBox: Final } = resolveReferences({
SentBox: defineCollection({
fields: {
...BaseSentBox.fields,
from: field.number({ references: () => BaseUser.fields.id, optional: true }),
columns: {
...BaseSentBox.columns,
from: column.number({ references: () => BaseUser.columns.id, optional: true }),
},
}),
});
@ -133,7 +133,7 @@ describe('reference queries', () => {
const { SentBox: InitialWithDifferentFK } = resolveReferences({
SentBox: defineCollection({
...BaseSentBox,
foreignKeys: [{ fields: ['to'], references: () => [BaseUser.fields.id] }],
foreignKeys: [{ columns: ['to'], references: () => [BaseUser.columns.id] }],
}),
});
const { SentBox: Final } = resolveReferences({
@ -141,8 +141,8 @@ describe('reference queries', () => {
...BaseSentBox,
foreignKeys: [
{
fields: ['to', 'toName'],
references: () => [BaseUser.fields.id, BaseUser.fields.name],
columns: ['to', 'toName'],
references: () => [BaseUser.columns.id, BaseUser.columns.name],
},
],
}),