mirror of
https://github.com/withastro/astro.git
synced 2025-03-03 22:57:08 -05:00
Add support for SQL defaults
You can now use sql`CURRENT_TIMESTAMP`, `NOW`, and a couple of other helpers, to set defaults.
This commit is contained in:
parent
25c09d3ef4
commit
8f7da6cc79
10 changed files with 140 additions and 30 deletions
|
@ -14,6 +14,7 @@ import type {
|
|||
NumberField,
|
||||
TextField,
|
||||
} from '../types.js';
|
||||
import { SQL } from 'drizzle-orm';
|
||||
import { SQLiteAsyncDialect } from 'drizzle-orm/sqlite-core';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
import prompts from 'prompts';
|
||||
|
@ -547,5 +548,5 @@ type DBFieldWithDefault =
|
|||
| WithDefaultDefined<JsonField>;
|
||||
|
||||
function hasRuntimeDefault(field: DBField): field is DBFieldWithDefault {
|
||||
return field.type === 'date' && field.default === 'now';
|
||||
return !!(field.default && field.default instanceof SQL);
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ import {
|
|||
type TextField,
|
||||
} from '../core/types.js';
|
||||
import { bold } from 'kleur/colors';
|
||||
import { type SQL, sql } from 'drizzle-orm';
|
||||
import { SQL, sql } from 'drizzle-orm';
|
||||
import { SQLiteAsyncDialect } from 'drizzle-orm/sqlite-core';
|
||||
import type { AstroIntegrationLogger } from 'astro';
|
||||
import type { DBUserConfig } from '../core/types.js';
|
||||
|
@ -217,16 +217,29 @@ export function hasDefault(field: DBField): field is DBFieldWithDefault {
|
|||
return false;
|
||||
}
|
||||
|
||||
function toStringDefault<T>(def: T | SQL<any>): string {
|
||||
const type = typeof def;
|
||||
if(def instanceof SQL) {
|
||||
return sqlite.sqlToQuery(def).sql;
|
||||
} else 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: DBFieldWithDefault): string {
|
||||
switch (column.type) {
|
||||
case 'boolean':
|
||||
return column.default ? 'TRUE' : 'FALSE';
|
||||
return toStringDefault(column.default);
|
||||
case 'number':
|
||||
return `${column.default || 'AUTOINCREMENT'}`;
|
||||
return `${column.default ? toStringDefault(column.default) : 'AUTOINCREMENT'}`;
|
||||
case 'text':
|
||||
return sqlite.escapeString(column.default);
|
||||
return toStringDefault(column.default);
|
||||
case 'date':
|
||||
return column.default === 'now' ? 'CURRENT_TIMESTAMP' : sqlite.escapeString(column.default);
|
||||
return toStringDefault(column.default);
|
||||
case 'json': {
|
||||
let stringified = '';
|
||||
try {
|
||||
|
|
|
@ -2,7 +2,7 @@ import type { SQLiteInsertValue } from 'drizzle-orm/sqlite-core';
|
|||
import type { InferSelectModel } from 'drizzle-orm';
|
||||
import type { SqliteDB, Table } from '../runtime/index.js';
|
||||
import { z } from 'zod';
|
||||
import { getTableName } from 'drizzle-orm';
|
||||
import { getTableName, SQL } from 'drizzle-orm';
|
||||
|
||||
export type MaybePromise<T> = T | Promise<T>;
|
||||
export type MaybeArray<T> = T | T[];
|
||||
|
@ -19,20 +19,26 @@ const baseFieldSchema = z.object({
|
|||
|
||||
const booleanFieldSchema = baseFieldSchema.extend({
|
||||
type: z.literal('boolean'),
|
||||
default: z.boolean().optional(),
|
||||
default: z.union([
|
||||
z.boolean(),
|
||||
z.instanceof(SQL<any>),
|
||||
]).optional(),
|
||||
});
|
||||
|
||||
const numberFieldSchema: z.ZodType<
|
||||
{
|
||||
// ReferenceableField creates a circular type. Define ZodType to resolve.
|
||||
type: 'number';
|
||||
default?: number | undefined;
|
||||
default?: number | SQL<any> | undefined;
|
||||
references?: () => ReferenceableField | undefined;
|
||||
primaryKey?: boolean | undefined;
|
||||
} & z.infer<typeof baseFieldSchema>
|
||||
> = baseFieldSchema.extend({
|
||||
type: z.literal('number'),
|
||||
default: z.number().optional(),
|
||||
default: z.union([
|
||||
z.number(),
|
||||
z.instanceof(SQL<any>),
|
||||
]).optional(),
|
||||
references: z
|
||||
.function()
|
||||
.returns(z.lazy(() => referenceableFieldSchema))
|
||||
|
@ -45,14 +51,17 @@ const textFieldSchema: z.ZodType<
|
|||
// ReferenceableField creates a circular type. Define ZodType to resolve.
|
||||
type: 'text';
|
||||
multiline?: boolean | undefined;
|
||||
default?: string | undefined;
|
||||
default?: string | SQL<any> | undefined;
|
||||
references?: () => ReferenceableField | undefined;
|
||||
primaryKey?: boolean | undefined;
|
||||
} & z.infer<typeof baseFieldSchema>
|
||||
> = baseFieldSchema.extend({
|
||||
type: z.literal('text'),
|
||||
multiline: z.boolean().optional(),
|
||||
default: z.string().optional(),
|
||||
default: z.union([
|
||||
z.string(),
|
||||
z.instanceof(SQL<any>),
|
||||
]).optional(),
|
||||
references: z
|
||||
.function()
|
||||
.returns(z.lazy(() => referenceableFieldSchema))
|
||||
|
@ -64,7 +73,7 @@ const dateFieldSchema = baseFieldSchema.extend({
|
|||
type: z.literal('date'),
|
||||
default: z
|
||||
.union([
|
||||
z.literal('now'),
|
||||
z.instanceof(SQL<any>),
|
||||
// allow date-like defaults in user config,
|
||||
// transform to ISO string for D1 storage
|
||||
z.coerce.date().transform((d) => d.toISOString()),
|
||||
|
|
|
@ -2,3 +2,4 @@ export { defineCollection, defineWritableCollection, defineData, field } from '.
|
|||
export type { ResolvedCollectionConfig, DBDataContext } from './core/types.js';
|
||||
export { cli } from './core/cli/index.js';
|
||||
export { integration as default } from './core/integration/index.js';
|
||||
export { sql, NOW, TRUE, FALSE } from './runtime/index.js';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import type { SqliteRemoteDatabase } from 'drizzle-orm/sqlite-proxy';
|
||||
import { type DBCollection, type DBField } from '../core/types.js';
|
||||
import { type ColumnBuilderBaseConfig, type ColumnDataType, sql } from 'drizzle-orm';
|
||||
import { type ColumnBuilderBaseConfig, type ColumnDataType, sql, SQL } from 'drizzle-orm';
|
||||
import {
|
||||
customType,
|
||||
integer,
|
||||
|
@ -12,6 +12,7 @@ import {
|
|||
} from 'drizzle-orm/sqlite-core';
|
||||
import { z } from 'zod';
|
||||
|
||||
export { sql };
|
||||
export type SqliteDB = SqliteRemoteDatabase;
|
||||
export type { Table } from './types.js';
|
||||
export { createRemoteDatabaseClient, createLocalDatabaseClient } from './db-client.js';
|
||||
|
@ -20,6 +21,11 @@ export function hasPrimaryKey(field: DBField) {
|
|||
return 'primaryKey' in field && !!field.primaryKey;
|
||||
}
|
||||
|
||||
// Exports a few common expressions
|
||||
export const NOW = sql`CURRENT_TIMESTAMP`;
|
||||
export const TRUE = sql`TRUE`;
|
||||
export const FALSE = sql`FALSE`
|
||||
|
||||
const dateType = customType<{ data: Date; driverData: string }>({
|
||||
dataType() {
|
||||
return 'text';
|
||||
|
@ -116,17 +122,18 @@ function columnMapper(fieldName: string, field: DBField, isJsonSerializable: boo
|
|||
if (isJsonSerializable) {
|
||||
c = text(fieldName);
|
||||
if (field.default !== undefined) {
|
||||
c = c.default(field.default === 'now' ? sql`CURRENT_TIMESTAMP` : field.default);
|
||||
c = c.default(field.default);
|
||||
}
|
||||
} else {
|
||||
c = dateType(fieldName);
|
||||
if (field.default !== undefined) {
|
||||
const def = convertSerializedSQL(field.default);
|
||||
c = c.default(
|
||||
field.default === 'now'
|
||||
? sql`CURRENT_TIMESTAMP`
|
||||
: // default comes pre-transformed to an ISO string for D1 storage.
|
||||
def instanceof SQL
|
||||
? def
|
||||
// default comes pre-transformed to an ISO string for D1 storage.
|
||||
// parse back to a Date for Drizzle.
|
||||
z.coerce.date().parse(field.default)
|
||||
: z.coerce.date().parse(field.default)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -138,3 +145,15 @@ function columnMapper(fieldName: string, field: DBField, isJsonSerializable: boo
|
|||
if (field.unique) c = c.unique();
|
||||
return c;
|
||||
}
|
||||
|
||||
function isSerializedSQL(obj: unknown): boolean {
|
||||
return typeof obj === 'object' && !!(obj as any).queryChunks;
|
||||
}
|
||||
|
||||
function convertSerializedSQL<T = unknown>(obj: T): SQL<any> | T {
|
||||
if(isSerializedSQL(obj)) {
|
||||
return new SQL((obj as any).queryChunks)
|
||||
} else {
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,7 +29,7 @@ describe('astro:db', () => {
|
|||
const html = await res.text();
|
||||
const $ = cheerioLoad(html);
|
||||
|
||||
const ul = $('ul');
|
||||
const ul = $('.authors-list');
|
||||
expect(ul.children()).to.have.a.lengthOf(5);
|
||||
expect(ul.children().eq(0).text()).to.equal('Ben');
|
||||
});
|
||||
|
@ -53,5 +53,42 @@ describe('astro:db', () => {
|
|||
|
||||
expect($('#error').text()).to.equal('');
|
||||
});
|
||||
|
||||
describe('Expression defaults', () => {
|
||||
let app;
|
||||
before(async () => {
|
||||
app = await fixture.loadTestAdapterApp();
|
||||
});
|
||||
|
||||
it('Allows expression defaults for date fields', async () => {
|
||||
const request = new Request('http://example.com/');
|
||||
const res = await app.render(request);
|
||||
const html = await res.text();
|
||||
const $ = cheerioLoad(html);
|
||||
|
||||
const themeAdded = $($('.themes-list .theme-added')[0]).text();
|
||||
expect(new Date(themeAdded).getTime()).to.not.be.NaN;
|
||||
});
|
||||
|
||||
it('Allows expression defaults for text fields', async () => {
|
||||
const request = new Request('http://example.com/');
|
||||
const res = await app.render(request);
|
||||
const html = await res.text();
|
||||
const $ = cheerioLoad(html);
|
||||
|
||||
const themeOwner = $($('.themes-list .theme-owner')[0]).text();
|
||||
expect(themeOwner).to.equal('');
|
||||
});
|
||||
|
||||
it('Allows expression defaults for boolean fields', async () => {
|
||||
const request = new Request('http://example.com/');
|
||||
const res = await app.render(request);
|
||||
const html = await res.text();
|
||||
const $ = cheerioLoad(html);
|
||||
|
||||
const themeDark = $($('.themes-list .theme-dark')[0]).text();
|
||||
expect(themeDark).to.equal('dark mode');
|
||||
});
|
||||
})
|
||||
});
|
||||
});
|
||||
|
|
18
packages/db/test/fixtures/basics/astro.config.ts
vendored
18
packages/db/test/fixtures/basics/astro.config.ts
vendored
|
@ -1,5 +1,5 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
import db, { defineCollection, defineWritableCollection, field } from '@astrojs/db';
|
||||
import db, { defineCollection, defineWritableCollection, field, sql, NOW } from '@astrojs/db';
|
||||
|
||||
const Author = defineCollection({
|
||||
fields: {
|
||||
|
@ -10,6 +10,14 @@ const Author = defineCollection({
|
|||
const Themes = defineWritableCollection({
|
||||
fields: {
|
||||
name: field.text(),
|
||||
added: field.date({
|
||||
default: sql`CURRENT_TIMESTAMP`
|
||||
}),
|
||||
updated: field.date({
|
||||
default: NOW
|
||||
}),
|
||||
isDark: field.boolean({ default: sql`TRUE` }),
|
||||
owner: field.text({ optional: true, default: sql`NULL` }),
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -19,14 +27,18 @@ export default defineConfig({
|
|||
db: {
|
||||
studio: true,
|
||||
collections: { Author, Themes },
|
||||
data({ seed }) {
|
||||
seed(Author, [
|
||||
async data({ seed }) {
|
||||
await seed(Author, [
|
||||
{ name: 'Ben' },
|
||||
{ name: 'Nate' },
|
||||
{ name: 'Erika' },
|
||||
{ name: 'Bjorn' },
|
||||
{ name: 'Sarah' },
|
||||
]);
|
||||
await seed(Themes, [
|
||||
{ name: 'dracula' },
|
||||
{ name: 'monokai' },
|
||||
]);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,11 +1,28 @@
|
|||
---
|
||||
import { Author, db } from 'astro:db';
|
||||
import { Author, db, Themes } from 'astro:db';
|
||||
|
||||
const authors = await db.select().from(Author);
|
||||
const themes = await db.select().from(Themes);
|
||||
---
|
||||
|
||||
<ul>
|
||||
<h2>Authors</h2>
|
||||
<ul class="authors-list">
|
||||
{authors.map(author => (
|
||||
<li>{author.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2>Themes</h2>
|
||||
<ul class="themes-list">
|
||||
{
|
||||
themes.map(theme => (
|
||||
<li>
|
||||
<div class="theme-name">{theme.name}</div>
|
||||
<div class="theme-added">{theme.added}</div>
|
||||
<div class="theme-updated">{theme.updated}</div>
|
||||
<div class="theme-dark">{theme.isDark ? 'dark' : 'light'} mode</div>
|
||||
<div class="theme-owner">{theme.owner}</div>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
|
|
|
@ -6,6 +6,7 @@ import {
|
|||
} from '../../dist/core/cli/migration-queries.js';
|
||||
import { getCreateTableQuery } from '../../dist/core/queries.js';
|
||||
import { field, defineCollection, collectionSchema } from '../../dist/core/types.js';
|
||||
import { NOW, sql } from '../../dist/runtime/index.js';
|
||||
|
||||
const COLLECTION_NAME = 'Users';
|
||||
|
||||
|
@ -298,7 +299,7 @@ describe('field queries', () => {
|
|||
...initial,
|
||||
fields: {
|
||||
...initial.fields,
|
||||
age: field.date({ default: 'now' }),
|
||||
age: field.date({ default: NOW }),
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -320,7 +321,7 @@ describe('field queries', () => {
|
|||
...userInitial,
|
||||
fields: {
|
||||
...userInitial.fields,
|
||||
birthday: field.date({ default: 'now' }),
|
||||
birthday: field.date({ default: NOW }),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -25,7 +25,7 @@ describe('index queries', () => {
|
|||
},
|
||||
};
|
||||
|
||||
const queries = await getCollectionChangeQueries({
|
||||
const { queries } = await getCollectionChangeQueries({
|
||||
collectionName: 'user',
|
||||
oldCollection: userInitial,
|
||||
newCollection: userFinal,
|
||||
|
@ -53,7 +53,7 @@ describe('index queries', () => {
|
|||
indexes: {},
|
||||
};
|
||||
|
||||
const queries = await getCollectionChangeQueries({
|
||||
const { queries } = await getCollectionChangeQueries({
|
||||
collectionName: 'user',
|
||||
oldCollection: initial,
|
||||
newCollection: final,
|
||||
|
@ -81,7 +81,7 @@ describe('index queries', () => {
|
|||
},
|
||||
};
|
||||
|
||||
const queries = await getCollectionChangeQueries({
|
||||
const { queries } = await getCollectionChangeQueries({
|
||||
collectionName: 'user',
|
||||
oldCollection: initial,
|
||||
newCollection: final,
|
||||
|
|
Loading…
Add table
Reference in a new issue