0
Fork 0
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:
Matthew Phillips 2024-02-08 13:32:56 -05:00
parent 25c09d3ef4
commit 8f7da6cc79
10 changed files with 140 additions and 30 deletions

View file

@ -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);
}

View file

@ -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 {

View file

@ -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()),

View file

@ -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';

View file

@ -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;
}
}

View file

@ -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');
});
})
});
});

View file

@ -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' },
]);
},
},
});

View file

@ -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>

View file

@ -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 }),
},
});

View file

@ -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,