0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2025-01-13 22:11:20 -05:00
astro/packages/db/test/unit/field-queries.test.js
Matthew Phillips 31a9f8469c
Introduce the db integration (prerelease) (#10201)
* Initial DB migration code

* chore: update package.json

* chore: update lockfile

* feat: add db as top-level config value

* Small package change

* Add a very basic test

* deps: remove unused

* chore: astro/db scripts, exports, deps

* chore: set tsconfig to monorepo defaults

* feat: MVP to use libsql in dev

* fix: test fixture paths

* fix: test file paths

* chore: remove CLI tests

* fix: add astro-scripts to db

* fix: package.json merge

* fix: load seed file separately for build

* feat: create db on filesystem for build

* fix: ul test. It passes now!

* Squashed commit of the following:

commit acdddd728c56f25e42975db7f367ab8a998e8c41
Author: Princesseuh <3019731+Princesseuh@users.noreply.github.com>
Date:   Wed Jan 10 14:06:16 2024 -0500

    fix: proper type augment for the config

commit b41ca9aacf291d1e5f0a27b6d6339ce4fc608ec3
Author: Nate Moore <nate@astro.build>
Date:   Tue Jan 9 14:33:42 2024 -0600

    wip: type augmentation

* feat: data() fn with basic type safety

* wip: update from seed file to data()

* fix: bad collectionSchema data type

* refactor: move dev to use filesystem to run seed at right time

* chore: remove seed file logic

* docs: add basic README

* CLI sync command

* Runtime checking of writable

* docs: add join example

* Implement defineWritableCollection

* feat: use studio connection for studio builds

* fix: add defineWritableCollection export

* refactor: use getTableName() util

* feat(db): add support for pass-through `astro db` command

* chore: add error map

* fix: add drizzle import

* refactor: studio -> db cli

* feat: add ticketing example

* fix: bad types in astro config

* fix: remove invalid `data()` on writable collection

* fix: vite warning on load-astro-config

* wip: add seeding for readable collections (nonstable ids!)

* merge migration work into branch

* cleanup migration commands

* migrate seed data to new db push command

* add migrations table to db

* fix remote db bugs

* fix: warn writable collections when studio false

* chore: delete README contents (now on Notion)

* chore: remove blank README

* chore: add dev dependency on db

* Removed unused deps

* 0.1.0

* Add config-augment.d.ts to published artifacts"

* Fixes merge issues with main

* fix: support promise response from data()

* feat: basic glob fixture

* Add a main field

* Give a help message when no db command is provided

* feat: `db push --token` for GitHub CI secrets

* fix getPackage for db package

* 0.1.2

* wip: build a table type

* chore: update lockfile

* chore: temporarily remove `Table` type

* feat: better Table object for type inference

* format

* add primaryKey support

* improve snapshot parsing support

* cleanup primary key support, db push

* add simple db shell

* cleanup old copy paste code

* feat: scrappy global data() prototype

* feat(test): recipes example

* fix: use Extract to narrow keyof to strings

* 0.1.3

* Create a runtime version of createRemoteDatabaseClient

* 0.1.4

* Grab the dbUrl from the environment

* 0.1.5

* Expose the database to the build output

* 0.1.6

* 0.1.7

* 0.1.15

* wip: data() -> set() concept

* fix: just infer insert keys for now

* refactor: rewrite to injected set() function

* deps: chokidar, drizzle

* feat: glob support with { db, table } signature

* chore: move basics to new data set

* refactor: set -> seed

* feat: expose Model.table

* refactor: clean up types

* feat: migrations now working!

* upgrade @libsql/client

* format

* expose relevant types

* 0.1.16

* feat: config design

* feat: add indexes from collectionToTable

* feat: add indexes to setupDbTables

* fix: remove unique constraint on recipeId

* Use an import statement to grab the database file

* 0.1.17

* Remove unused import

* Rename to ?fileurl

* 0.1.18

* feat: index migrations

* move migration logic to turso, add back sync support

* feat: add queries unit tests and fix related bugs

* refactor: move field queries to field-queries.test

* feat: index query tests

* refactor: reorganize the rats nest of files

* Make the DB_URL be root relative

* Upgrade to latest libsql

* 0.1.19

* 0.1.20

* Make work in webcontainer

* 0.1.22

* Remove content database from the static build

* 0.1.23

* chore: remove `optional: true` from pk

* chore: disable console linting for CLI

* fix: remove `id` column from Table type

* chore: remove `AstroId` type

* fix(ex): add `id` col to ticketing

* 0.2.0

* 0.2.1

* add keywords

* 0.2.2

* feat: API shape

* feat: FINALLY collection and name attached

* refactor: move to arrow function signature

* fix: foreignKeys references signature

* chore: unused imports

* feat: add foreignkeys to create table

* chore: lint

* chore: enable foreign keys in local mode only

* refactor: objShallowEqual -> deep diff

* fix: correct `hasDefault` inference

* fix: correct type Config reference

* fix: respect primaryKey from hasDefault

* fix: mark data keys as optional until we have type inference

* improve conflict and dataloss handling

- moved prompts to db push
- moved prompt logic out of lower-level functions
- improved logic overall
- improved user-facing prompt messages

* improve error messaging around studio config missing

* make it more clear when remove vs. local db is in use

* fix bug in prompt logic

* feat: better field.x() types

* feat: better seed() types

* chore: remove `as any` on seed values

* feat: good enough return type on seed :)

* feat: defineData()

* fix: add back promptResponse injection

* fix: use schema.parse to resolve dates

* fix: correctly respect primary key on INSERT INTO

* add short-lived db tokens

* add help output

* add better token error logging

* fix studio tests

* add shortcut link command from studio web ui

* Add support for SQL defaults

You can now use sql`CURRENT_TIMESTAMP`, `NOW`, and a couple of other
helpers, to set defaults.

* chore: todo

* feat: ignore `optional` and `default` when pk is present

* refactor: type `false` instead of type `never`

* feat: prevent `optional` on text pk

* fix db URL import for windows

* fix: add back textField multiline

* fix: remove explicit AUTOINCREMENT on int pk

* feat(db-cli): clean up CLI logging, support --json flag for `astro db verify`, extract shared logic to a utility

* prepare to run seed on all db push commands

* chore: expose setMeta for unit testing

* feat(test): reference add and remove tests

* feat: add references checks to migratiosn

* feat: remove useForeignKey checks

* feat: add pragma when pushing migrations

* feat(test): foreignKeys

* fix: transform collection config to be JSON serializable

* refactor: _setMeta -> preprocess for `table`

* refactor: reference tests

* chore: remove console log

* fix: handle serialized SQL object correctly

* refactor: store raw sql instead

* seed on every push

* Move field schema only a `schema` object

* Fix references test

* 0.3.0

* add default URLs to db package

* 0.3.1

* Fix input types

* fix: primaryKey type check

* 0.3.2

* fix: respect default in table types

* fix: avoid dropping tables on production seed

* fix: escape name on drop table

* feat: allow verify to mock migration file

* Handle unauthorized linking

* Fix verbiage of unauthorized link warning

* Add some color to the unauthorized message

* 0.3.3

* Improve the unauthorized error output

* 0.3.4

* fix: better error message

* Seed the Themes in build too

* Push skipped test

* chore: remove dead isJsonSerializable check

* fix: use `dateType` for dates (oops)

* refactor: clarify date coerce comment

* refactor: remove unused coerce

* chore: unskip date test

* feat: seed -> seedReturning

* refactor: throw when seeding writable in prod

* Add unsafeWritable option

* refactor: use FieldsConfig for Table generic

* chore: lint

* fix: use z.input for AstroConfigWithDB type

* fix: add defaults for boolean config options

* Support new CLI command structure

* Allow writable in the tests

* fix: handle defaults for safe type changes

* refactor: avoid selecting ['schema'] on input types

* 0.3.5

* Rename field->column, collection->table

* Rename collections->tables

* rename to defineReadableTable

* deps: upgrade ticketing-example

* fix: stray console.log

* deps: bump preact again

* chore: preact->react

* fix: parse params.event as number

* fix: correct event references

* Allow integrations to define schema

* fix: file-url plugin failure on repeated generateBundle() runs

* update url

* Cleanup

* Linting

* fix windows file permission issue

When runnng `astro dev`, the watcher would close trying to delete the `content.db` file due to a file permission error. This change makes the local DB client a disposable to allow cleanup after usage.

* Formatting

* "fix" Symbol.dispose usage

---------

Co-authored-by: Nate Moore <nate@astro.build>
Co-authored-by: bholmesdev <hey@bholmes.dev>
Co-authored-by: Fred K. Schott <fkschott@gmail.com>
Co-authored-by: itsMapleLeaf <19603573+itsMapleLeaf@users.noreply.github.com>
2024-02-22 14:50:44 -05:00

449 lines
15 KiB
JavaScript

import { expect } from 'chai';
import { describe, it } from 'mocha';
import {
getCollectionChangeQueries,
getMigrationQueries,
} from '../../dist/core/cli/migration-queries.js';
import { getCreateTableQuery } from '../../dist/core/queries.js';
import { collectionSchema, column, defineReadableTable } from '../../dist/core/types.js';
import { NOW } from '../../dist/runtime/index.js';
const COLLECTION_NAME = 'Users';
// `parse` to resolve schema transformations
// ex. convert column.date() to ISO strings
const userInitial = collectionSchema.parse(
defineReadableTable({
columns: {
name: column.text(),
age: column.number(),
email: column.text({ unique: true }),
mi: column.text({ optional: true }),
},
})
);
const defaultAmbiguityResponses = {
collectionRenames: {},
columnRenames: {},
};
function userChangeQueries(
oldCollection,
newCollection,
ambiguityResponses = defaultAmbiguityResponses
) {
return getCollectionChangeQueries({
collectionName: COLLECTION_NAME,
oldCollection,
newCollection,
ambiguityResponses,
});
}
function configChangeQueries(
oldCollections,
newCollections,
ambiguityResponses = defaultAmbiguityResponses
) {
return getMigrationQueries({
oldSnapshot: { schema: oldCollections, experimentalVersion: 1 },
newSnapshot: { schema: newCollections, experimentalVersion: 1 },
ambiguityResponses,
});
}
describe('column queries', () => {
describe('getMigrationQueries', () => {
it('should be empty when tables are the same', async () => {
const oldCollections = { [COLLECTION_NAME]: userInitial };
const newCollections = { [COLLECTION_NAME]: userInitial };
const { queries } = await configChangeQueries(oldCollections, newCollections);
expect(queries).to.deep.equal([]);
});
it('should create table for new tables', async () => {
const oldCollections = {};
const newCollections = { [COLLECTION_NAME]: userInitial };
const { queries } = await configChangeQueries(oldCollections, newCollections);
expect(queries).to.deep.equal([getCreateTableQuery(COLLECTION_NAME, userInitial)]);
});
it('should drop table for removed tables', async () => {
const oldCollections = { [COLLECTION_NAME]: userInitial };
const newCollections = {};
const { queries } = await configChangeQueries(oldCollections, newCollections);
expect(queries).to.deep.equal([`DROP TABLE "${COLLECTION_NAME}"`]);
});
it('should rename table for renamed tables', async () => {
const rename = 'Peeps';
const oldCollections = { [COLLECTION_NAME]: userInitial };
const newCollections = { [rename]: userInitial };
const { queries } = await configChangeQueries(oldCollections, newCollections, {
...defaultAmbiguityResponses,
collectionRenames: { [rename]: COLLECTION_NAME },
});
expect(queries).to.deep.equal([`ALTER TABLE "${COLLECTION_NAME}" RENAME TO "${rename}"`]);
});
});
describe('getCollectionChangeQueries', () => {
it('should be empty when tables are the same', async () => {
const { queries } = await userChangeQueries(userInitial, userInitial);
expect(queries).to.deep.equal([]);
});
it('should be empty when type updated to same underlying SQL type', async () => {
const blogInitial = collectionSchema.parse({
...userInitial,
columns: {
title: column.text(),
draft: column.boolean(),
},
});
const blogFinal = collectionSchema.parse({
...userInitial,
columns: {
...blogInitial.columns,
draft: column.number(),
},
});
const { queries } = await userChangeQueries(blogInitial, blogFinal);
expect(queries).to.deep.equal([]);
});
it('should respect user primary key without adding a hidden id', async () => {
const user = collectionSchema.parse({
...userInitial,
columns: {
...userInitial.columns,
id: column.number({ primaryKey: true }),
},
});
const userFinal = collectionSchema.parse({
...user,
columns: {
...user.columns,
name: column.text({ unique: true, optional: true }),
},
});
const { queries } = await userChangeQueries(user, userFinal);
expect(queries[0]).to.not.be.undefined;
const tempTableName = getTempTableName(queries[0]);
expect(queries).to.deep.equal([
`CREATE TABLE \"${tempTableName}\" (\"name\" text UNIQUE, \"age\" integer NOT NULL, \"email\" text NOT NULL UNIQUE, \"mi\" text, \"id\" integer PRIMARY KEY)`,
`INSERT INTO \"${tempTableName}\" (\"name\", \"age\", \"email\", \"mi\", \"id\") SELECT \"name\", \"age\", \"email\", \"mi\", \"id\" FROM \"Users\"`,
'DROP TABLE "Users"',
`ALTER TABLE "${tempTableName}" RENAME TO "Users"`,
]);
});
describe('ALTER RENAME COLUMN', () => {
it('when renaming a column', async () => {
const userFinal = {
...userInitial,
columns: {
...userInitial.columns,
},
};
userFinal.columns.middleInitial = userFinal.columns.mi;
delete userFinal.columns.mi;
const { queries } = await userChangeQueries(userInitial, userFinal, {
collectionRenames: {},
columnRenames: { [COLLECTION_NAME]: { middleInitial: 'mi' } },
});
expect(queries).to.deep.equal([
`ALTER TABLE "${COLLECTION_NAME}" RENAME COLUMN "mi" TO "middleInitial"`,
]);
});
});
describe('Lossy table recreate', () => {
it('when changing a column type', async () => {
const userFinal = {
...userInitial,
columns: {
...userInitial.columns,
age: column.text(),
},
};
const { queries } = await userChangeQueries(userInitial, userFinal);
expect(queries).to.deep.equal([
'DROP TABLE "Users"',
`CREATE TABLE "Users" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" text NOT NULL, "email" text NOT NULL UNIQUE, "mi" text)`,
]);
});
it('when adding a required column without a default', async () => {
const userFinal = {
...userInitial,
columns: {
...userInitial.columns,
phoneNumber: column.text(),
},
};
const { queries } = await userChangeQueries(userInitial, userFinal);
expect(queries).to.deep.equal([
'DROP TABLE "Users"',
`CREATE TABLE "Users" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text, "phoneNumber" text NOT NULL)`,
]);
});
});
describe('Lossless table recreate', () => {
it('when adding a primary key', async () => {
const userFinal = {
...userInitial,
columns: {
...userInitial.columns,
id: column.number({ primaryKey: true }),
},
};
const { queries } = await userChangeQueries(userInitial, userFinal);
expect(queries[0]).to.not.be.undefined;
const tempTableName = getTempTableName(queries[0]);
expect(queries).to.deep.equal([
`CREATE TABLE \"${tempTableName}\" (\"name\" text NOT NULL, \"age\" integer NOT NULL, \"email\" text NOT NULL UNIQUE, \"mi\" text, \"id\" integer PRIMARY KEY)`,
`INSERT INTO \"${tempTableName}\" (\"name\", \"age\", \"email\", \"mi\") SELECT \"name\", \"age\", \"email\", \"mi\" FROM \"Users\"`,
'DROP TABLE "Users"',
`ALTER TABLE "${tempTableName}" RENAME TO "Users"`,
]);
});
it('when dropping a primary key', async () => {
const user = {
...userInitial,
columns: {
...userInitial.columns,
id: column.number({ primaryKey: true }),
},
};
const { queries } = await userChangeQueries(user, userInitial);
expect(queries[0]).to.not.be.undefined;
const tempTableName = getTempTableName(queries[0]);
expect(queries).to.deep.equal([
`CREATE TABLE \"${tempTableName}\" (_id INTEGER PRIMARY KEY, \"name\" text NOT NULL, \"age\" integer NOT NULL, \"email\" text NOT NULL UNIQUE, \"mi\" text)`,
`INSERT INTO \"${tempTableName}\" (\"name\", \"age\", \"email\", \"mi\") SELECT \"name\", \"age\", \"email\", \"mi\" FROM \"Users\"`,
'DROP TABLE "Users"',
`ALTER TABLE "${tempTableName}" RENAME TO "Users"`,
]);
});
it('when adding an optional unique column', async () => {
const userFinal = {
...userInitial,
columns: {
...userInitial.columns,
phoneNumber: column.text({ unique: true, optional: true }),
},
};
const { queries } = await userChangeQueries(userInitial, userFinal);
expect(queries).to.have.lengthOf(4);
const tempTableName = getTempTableName(queries[0]);
expect(tempTableName).to.be.a('string');
expect(queries).to.deep.equal([
`CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text, "phoneNumber" text UNIQUE)`,
`INSERT INTO "${tempTableName}" ("_id", "name", "age", "email", "mi") SELECT "_id", "name", "age", "email", "mi" FROM "Users"`,
'DROP TABLE "Users"',
`ALTER TABLE "${tempTableName}" RENAME TO "Users"`,
]);
});
it('when dropping unique column', async () => {
const userFinal = {
...userInitial,
columns: {
...userInitial.columns,
},
};
delete userFinal.columns.email;
const { queries } = await userChangeQueries(userInitial, userFinal);
expect(queries).to.have.lengthOf(4);
const tempTableName = getTempTableName(queries[0]);
expect(tempTableName).to.be.a('string');
expect(queries).to.deep.equal([
`CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "mi" text)`,
`INSERT INTO "${tempTableName}" ("_id", "name", "age", "mi") SELECT "_id", "name", "age", "mi" FROM "Users"`,
'DROP TABLE "Users"',
`ALTER TABLE "${tempTableName}" RENAME TO "Users"`,
]);
});
it('when updating to a runtime default', async () => {
const initial = collectionSchema.parse({
...userInitial,
columns: {
...userInitial.columns,
age: column.date(),
},
});
const userFinal = collectionSchema.parse({
...initial,
columns: {
...initial.columns,
age: column.date({ default: NOW }),
},
});
const { queries } = await userChangeQueries(initial, userFinal);
expect(queries).to.have.lengthOf(4);
const tempTableName = getTempTableName(queries[0]);
expect(tempTableName).to.be.a('string');
expect(queries).to.deep.equal([
`CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" text NOT NULL DEFAULT CURRENT_TIMESTAMP, "email" text NOT NULL UNIQUE, "mi" text)`,
`INSERT INTO "${tempTableName}" ("_id", "name", "age", "email", "mi") SELECT "_id", "name", "age", "email", "mi" FROM "Users"`,
'DROP TABLE "Users"',
`ALTER TABLE "${tempTableName}" RENAME TO "Users"`,
]);
});
it('when adding a column with a runtime default', async () => {
const userFinal = collectionSchema.parse({
...userInitial,
columns: {
...userInitial.columns,
birthday: column.date({ default: NOW }),
},
});
const { queries } = await userChangeQueries(userInitial, userFinal);
expect(queries).to.have.lengthOf(4);
const tempTableName = getTempTableName(queries[0]);
expect(tempTableName).to.be.a('string');
expect(queries).to.deep.equal([
`CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text, "birthday" text NOT NULL DEFAULT CURRENT_TIMESTAMP)`,
`INSERT INTO "${tempTableName}" ("_id", "name", "age", "email", "mi") SELECT "_id", "name", "age", "email", "mi" FROM "Users"`,
'DROP TABLE "Users"',
`ALTER TABLE "${tempTableName}" RENAME TO "Users"`,
]);
});
/**
* REASON: to follow the "expand" and "contract" migration model,
* you'll need to update the schema from NOT NULL to NULL.
* It's up to the user to ensure all data follows the new schema!
*
* @see https://planetscale.com/blog/safely-making-database-schema-changes#backwards-compatible-changes
*/
it('when changing a column to required', async () => {
const userFinal = {
...userInitial,
columns: {
...userInitial.columns,
mi: column.text(),
},
};
const { queries } = await userChangeQueries(userInitial, userFinal);
expect(queries).to.have.lengthOf(4);
const tempTableName = getTempTableName(queries[0]);
expect(tempTableName).to.be.a('string');
expect(queries).to.deep.equal([
`CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text NOT NULL)`,
`INSERT INTO "${tempTableName}" ("_id", "name", "age", "email", "mi") SELECT "_id", "name", "age", "email", "mi" FROM "Users"`,
'DROP TABLE "Users"',
`ALTER TABLE "${tempTableName}" RENAME TO "Users"`,
]);
});
it('when changing a column to unique', async () => {
const userFinal = {
...userInitial,
columns: {
...userInitial.columns,
age: column.number({ unique: true }),
},
};
const { queries } = await userChangeQueries(userInitial, userFinal);
expect(queries).to.have.lengthOf(4);
const tempTableName = getTempTableName(queries[0]);
expect(tempTableName).to.be.a('string');
expect(queries).to.deep.equal([
`CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL UNIQUE, "email" text NOT NULL UNIQUE, "mi" text)`,
`INSERT INTO "${tempTableName}" ("_id", "name", "age", "email", "mi") SELECT "_id", "name", "age", "email", "mi" FROM "Users"`,
'DROP TABLE "Users"',
`ALTER TABLE "${tempTableName}" RENAME TO "Users"`,
]);
});
});
describe('ALTER ADD COLUMN', () => {
it('when adding an optional column', async () => {
const userFinal = {
...userInitial,
columns: {
...userInitial.columns,
birthday: column.date({ optional: true }),
},
};
const { queries } = await userChangeQueries(userInitial, userFinal);
expect(queries).to.deep.equal(['ALTER TABLE "Users" ADD COLUMN "birthday" text']);
});
it('when adding a required column with default', async () => {
const defaultDate = new Date('2023-01-01');
const userFinal = collectionSchema.parse({
...userInitial,
columns: {
...userInitial.columns,
birthday: column.date({ default: new Date('2023-01-01') }),
},
});
const { queries } = await userChangeQueries(userInitial, userFinal);
expect(queries).to.deep.equal([
`ALTER TABLE "Users" ADD COLUMN "birthday" text NOT NULL DEFAULT '${defaultDate.toISOString()}'`,
]);
});
});
describe('ALTER DROP COLUMN', () => {
it('when removing optional or required columns', async () => {
const userFinal = {
...userInitial,
columns: {
name: userInitial.columns.name,
email: userInitial.columns.email,
},
};
const { queries } = await userChangeQueries(userInitial, userFinal);
expect(queries).to.deep.equal([
'ALTER TABLE "Users" DROP COLUMN "age"',
'ALTER TABLE "Users" DROP COLUMN "mi"',
]);
});
});
});
});
/** @param {string} query */
function getTempTableName(query) {
// eslint-disable-next-line regexp/no-unused-capturing-group
return query.match(/Users_([a-z\d]+)/)?.[0];
}