diff --git a/.changeset/curly-chefs-invite.md b/.changeset/curly-chefs-invite.md new file mode 100644 index 00000000000..1686716c287 --- /dev/null +++ b/.changeset/curly-chefs-invite.md @@ -0,0 +1,21 @@ +--- +'@keystone-next/website': minor +'@keystone-next/fields': minor +'@keystone-next/fields-document': minor +'@keystone-next/keystone': minor +'@keystone-next/types': minor +'@keystone-next/adapter-prisma-legacy': minor +'@keystone-next/fields-legacy': minor +'@keystone-next/fields-auto-increment-legacy': minor +'@keystone-next/fields-cloudinary-image-legacy': minor +'@keystone-next/fields-color-legacy': minor +'@keystone-next/fields-markdown-legacy': minor +'@keystone-next/fields-oembed-legacy': minor +'@keystone-next/fields-unsplash-legacy': minor +'@keystone-next/fields-wysiwyg-tinymce-legacy': minor +'@keystone-next/keystone-legacy': minor +'@keystone-next/test-utils-legacy': minor +'@keystone-next/api-tests-legacy': minor +--- + +Added experimental support for Prisma + SQLite as a database adapter. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f580ecb3b9a..56b3fae6545 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -112,13 +112,11 @@ jobs: POSTGRES_DB: test_db ports: - 5432:5432 - env: - DATABASE_URL: 'postgres://keystone5:k3yst0n3@localhost:5432/test_db' strategy: fail-fast: false matrix: index: [0, 1, 2, 3, 4, 5, 6, 7, 8] - adapter: ['mongoose', 'knex', 'prisma_postgresql'] + adapter: ['mongoose', 'knex', 'prisma_postgresql', 'prisma_sqlite'] steps: - name: Checkout Repo uses: actions/checkout@v2 @@ -163,6 +161,7 @@ jobs: UNSPLASH_KEY: ${{ secrets.UNSPLASH_KEY}} UNSPLASH_SECRET: ${{ secrets.UNSPLASH_SECRET}} GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + DATABASE_URL: ${{ matrix.adapter == 'prisma_sqlite' && 'file:./dev.db' || 'postgres://keystone5:k3yst0n3@localhost:5432/test_db' }} non-api-tests: name: Package Unit Tests diff --git a/docs-next/pages/apis/config.mdx b/docs-next/pages/apis/config.mdx index ec4ef7fa732..823071918f1 100644 --- a/docs-next/pages/apis/config.mdx +++ b/docs-next/pages/apis/config.mdx @@ -21,6 +21,7 @@ export default config({ session: () => { /* ... */ }, graphql: { /* ... */ }, extendGraphqlSchema: { /* ... */ }, + experimental: { /* ... */ }, }); ``` @@ -56,8 +57,9 @@ import type { DatabaseConfig } from '@keystone-next/types'; The `db` config option configures the database used to store data in your Keystone system. It has a TypeScript type of `DatabaseConfig`. Keystone supports three different database types; **Prisma**, **PostgreSQL**, and **MongoDB**. -These database types are powered by their corresponding Keystone database adapter; `prisma_postgresql`, `knex`, and `mongoose`. -The `prisma_postgresql` adapter includes support for the **migrate** commands of the Keystone [command line](../guides/cli). +Prisma in turn support both **PostgreSQL** and **SQLite** databases. +These database types are powered by their corresponding Keystone database adapter; `prisma_postgresql`, `prisma_sqlite`, `knex`, and `mongoose`. +The `prisma_postgresql` and `prisma_sqlite` adapters includes support for the **migrate** commands of the Keystone [command line](../guides/cli). All database adapters require the `url` argument, which defines the connection URL for your database. They also all have an optional `onConnect` async function, which takes a [`KeystoneContext`](./context) object, and lets perform any actions you might need at startup, such as data seeding. @@ -89,6 +91,43 @@ export default config({ }); ``` +### prisma_sqlite + +Support for SQLite with Prisma is still in preview. +To use this option you must also set `{ experimental: { prismaSqlite: true } }`. + +Advanced configuration: + +- `enableLogging` (default: `false`): Enable logging from the Prisma client. +- `getPrismaPath` (default: `() => '.keystone/prisma'` ): Set the location of the generated Prisma schema and client. + +The function for `getPrismaPath` is provided with the generated Prisma schema as a `string` in the `{ prismaSchema }` argument. + +```typescript +export default config({ + db: { + adapter: 'prisma_sqlite', + url: 'file:./keystone.db', + onConnect: async context => { /* ... */ }, + // Optional advanced configuration + enableLogging: true, + getPrismaPath: ({ prismaSchema }) => '.prisma', + }, + /* ... */ +}); +``` + +#### Limitations + +The `prisma_sqlite` is not intended to be used in production systems, and has certain limitations: + +- `document`: The `document` field type is not supported. +- `decimal`: The `decimal` field type is not supported. +- `timestamp`: The `timestamp` field type only supports times within the range `1970 - 2038`. +- `text`: The `text` field type does not support the advanced filtering operations `contains`, `starts_with`, `ends_with`, or case insensitive filtering. +- `autoincrement`: The `autoincrement` field type can only be used as an `id` field. +- `select`: Using the `dataType: 'enum'` will use a GraphQL `String` type, rather than an `Enum` type. + ### knex Advanced configuration: @@ -260,4 +299,27 @@ export default config({ See the [schema extension guide](../guides/schema-extension) for more details on how to use `graphQLSchemaExtension()` to extend your GraphQL API. +## experimental + +The following flags allow you to enable features which are still in preview. +These features are not guaranteed to work, and should be used with caution. + +```typescript +import { config } from '@keystone-next/keystone/schema'; + +export default config({ + experimental: { + enableNextJsGraphqlApiEndpoint: true, + prismaSqlite: true, + } + /* ... */ +}); +``` + +Options: + +- `enableNextJsGraphqlApiEndpoint`: (coming soon) +- `prismaSqlite`: Enables the use of SQLite with Prisma. + This flag is required when setting `{ db: { adapter: 'prisma_sqlite' } }`. + export default ({ children }) => {children}; diff --git a/packages-next/fields-document/src/Implementation.js b/packages-next/fields-document/src/Implementation.js index 5d9568d696d..72673aa7459 100644 --- a/packages-next/fields-document/src/Implementation.js +++ b/packages-next/fields-document/src/Implementation.js @@ -121,7 +121,11 @@ export class KnexDocumentInterface extends CommonDocumentInterface(KnexFieldAdap export class PrismaDocumentInterface extends CommonDocumentInterface(PrismaFieldAdapter) { constructor() { super(...arguments); - + if (this.listAdapter.parentAdapter.provider === 'sqlite') { + throw new Error( + `PrismaAdapter provider "sqlite" does not support field type "${this.field.constructor.name}"` + ); + } // Error rather than ignoring invalid config // We totally can index these values, it's just not trivial. See issue #1297 if (this.config.isIndexed) { diff --git a/packages-next/fields-document/src/test-fixtures.js b/packages-next/fields-document/src/test-fixtures.js index 7ce37e182a8..9ca3a2222c1 100644 --- a/packages-next/fields-document/src/test-fixtures.js +++ b/packages-next/fields-document/src/test-fixtures.js @@ -11,6 +11,7 @@ export const exampleValue2 = () => [ export const supportsUnique = false; export const fieldName = 'content'; export const subfieldName = 'document'; +export const unSupportedAdapterList = ['prisma_sqlite']; export const fieldConfig = () => ({ ___validateAndNormalize: x => x }); diff --git a/packages-next/fields/src/types/text/views/index.tsx b/packages-next/fields/src/types/text/views/index.tsx index e5fdf11e3a7..5eda0b82084 100644 --- a/packages-next/fields/src/types/text/views/index.tsx +++ b/packages-next/fields/src/types/text/views/index.tsx @@ -85,6 +85,7 @@ export const controller = ( Label({ label, value }) { return `${label.toLowerCase()}: "${value}"`; }, + // FIXME: Not all of these options will work with prisma_sqlite types: { contains_i: { label: 'Contains', diff --git a/packages-next/keystone/src/lib/applyIdFieldDefaults.ts b/packages-next/keystone/src/lib/applyIdFieldDefaults.ts index 90cbceb0a51..10ca97fd2b4 100644 --- a/packages-next/keystone/src/lib/applyIdFieldDefaults.ts +++ b/packages-next/keystone/src/lib/applyIdFieldDefaults.ts @@ -15,9 +15,12 @@ export function applyIdFieldDefaults(config: KeystoneConfig): KeystoneConfig['li } let idField = config.lists[key].idField ?? - { mongoose: mongoId({}), knex: autoIncrement({}), prisma_postgresql: autoIncrement({}) }[ - config.db.adapter - ]; + { + mongoose: mongoId({}), + knex: autoIncrement({}), + prisma_postgresql: autoIncrement({}), + prisma_sqlite: autoIncrement({}), + }[config.db.adapter]; idField = { ...idField, config: { diff --git a/packages-next/keystone/src/lib/createKeystone.ts b/packages-next/keystone/src/lib/createKeystone.ts index d10c084e240..c22460dc0b8 100644 --- a/packages-next/keystone/src/lib/createKeystone.ts +++ b/packages-next/keystone/src/lib/createKeystone.ts @@ -34,6 +34,19 @@ export function createKeystone( migrationMode, prismaClient, ...db, + provider: 'postgresql', + }); + } else if (db.adapter === 'prisma_sqlite') { + if (!config.experimental?.prismaSqlite) { + throw new Error( + 'SQLite support is still experimental. You must set { experimental: { prismaSqlite: true } } in your config to use this feature.' + ); + } + adapter = new PrismaAdapter({ + getPrismaPath: () => path.join(dotKeystonePath, 'prisma'), + prismaClient, + ...db, + provider: 'sqlite', }); } // @ts-ignore The @types/keystonejs__keystone package has the wrong type for KeystoneOptions diff --git a/packages-next/types/src/config/index.ts b/packages-next/types/src/config/index.ts index f1afbf91504..05e9a62fdd5 100644 --- a/packages-next/types/src/config/index.ts +++ b/packages-next/types/src/config/index.ts @@ -34,6 +34,8 @@ export type KeystoneConfig = { experimental?: { /** Enables nextjs graphql api route mode */ enableNextJsGraphqlApiEndpoint?: boolean; + /** Enable Prisma+SQLite support */ + prismaSqlite?: boolean; }; }; @@ -65,6 +67,11 @@ export type DatabaseConfig = DatabaseCommon & getPrismaPath?: (arg: { prismaSchema: any }) => string; getDbSchemaName?: (arg: { prismaSchema: any }) => string; } + | { + adapter: 'prisma_sqlite'; + enableLogging?: boolean; + getPrismaPath?: (arg: { prismaSchema: any }) => string; + } | { adapter: 'knex'; dropDatabase?: boolean; schemaName?: string } | { adapter: 'mongoose'; mongooseOptions?: { mongoUri?: string } & ConnectOptions } ); diff --git a/packages/adapter-prisma/src/adapter-prisma.js b/packages/adapter-prisma/src/adapter-prisma.js index 539afeb9aca..70a33936400 100644 --- a/packages/adapter-prisma/src/adapter-prisma.js +++ b/packages/adapter-prisma/src/adapter-prisma.js @@ -47,6 +47,8 @@ class PrismaAdapter extends BaseKeystoneAdapter { // TODO: Should we default to 'public' or null? if (this.provider === 'postgresql') { return this.dbSchemaName ? `${this.url}?schema=${this.dbSchemaName}` : this.url; + } else if (this.provider === 'sqlite') { + return this.url; } } @@ -272,6 +274,13 @@ class PrismaAdapter extends BaseKeystoneAdapter { this._runPrismaCmd(`migrate reset --force --preview-feature`); await runPrototypeMigrations(this._url(), this.prismaSchema, path.resolve(this.schemaPath)); } + } else if (this.provider === 'sqlite') { + const tables = await this.prisma.$queryRaw( + "SELECT name FROM sqlite_master WHERE type='table';" + ); + for (const { name } of tables) { + await this.prisma.$queryRaw(`DELETE FROM "${name}";`); + } } else { this._runPrismaCmd(`migrate reset --force --preview-feature`); } @@ -437,11 +446,12 @@ class PrismaListAdapter extends BaseListAdapter { if (search !== undefined && search !== '' && searchField) { if (searchField.fieldName === 'Text') { // FIXME: Think about regex + const mode = this.parentAdapter.provider === 'sqlite' ? undefined : 'insensitive'; if (!ret.where) { - ret.where = { [searchFieldName]: { contains: search, mode: 'insensitive' } }; + ret.where = { [searchFieldName]: { contains: search, mode } }; } else { ret.where = { - AND: [ret.where, { [searchFieldName]: { contains: search, mode: 'insensitive' } }], + AND: [ret.where, { [searchFieldName]: { contains: search, mode } }], }; } // const f = escapeRegExp; diff --git a/packages/fields-auto-increment/src/Implementation.js b/packages/fields-auto-increment/src/Implementation.js index d3fd8c1a82e..d218ed0b710 100644 --- a/packages/fields-auto-increment/src/Implementation.js +++ b/packages/fields-auto-increment/src/Implementation.js @@ -115,7 +115,11 @@ export class KnexAutoIncrementInterface extends KnexFieldAdapter { export class PrismaAutoIncrementInterface extends PrismaFieldAdapter { constructor() { super(...arguments); - + if (this.listAdapter.parentAdapter.provider === 'sqlite' && !this.field.isPrimaryKey) { + throw new Error( + `PrismaAdapter provider "sqlite" does not support field type "${this.field.constructor.name}"` + ); + } // Default isUnique to true if not specified this.isUnique = typeof this.config.isUnique === 'undefined' ? true : !!this.config.isUnique; this.isIndexed = !!this.config.isIndexed && !this.config.isUnique; diff --git a/packages/fields-auto-increment/src/test-fixtures.js b/packages/fields-auto-increment/src/test-fixtures.js index f0f697e8d46..2c85f40aa43 100644 --- a/packages/fields-auto-increment/src/test-fixtures.js +++ b/packages/fields-auto-increment/src/test-fixtures.js @@ -13,7 +13,7 @@ export const skipCreateTest = false; export const skipUpdateTest = true; // `AutoIncrement` field type is not supported by `mongoose`. So, we need to filter it out while performing `API` tests. -export const unSupportedAdapterList = ['mongoose']; +export const unSupportedAdapterList = ['mongoose', 'prisma_sqlite']; // Be default, `AutoIncrement` are read-only. But for `isRequired` test purpose, we need to bypass these restrictions. export const fieldConfig = matrixValue => ({ diff --git a/packages/fields-cloudinary-image/src/test-fixtures.skip.js b/packages/fields-cloudinary-image/src/test-fixtures.skip.js index c417ad4c399..73b4b1c373c 100644 --- a/packages/fields-cloudinary-image/src/test-fixtures.skip.js +++ b/packages/fields-cloudinary-image/src/test-fixtures.skip.js @@ -78,5 +78,5 @@ export const storedValues = () => [ export const supportedFilters = adapterName => [ 'null_equality', - adapterName !== 'prisma_postgresql' && 'in_empty_null', + !['prisma_postgresql', 'prisma_sqlite'].includes(adapterName) && 'in_empty_null', ]; diff --git a/packages/fields-color/src/test-fixtures.js b/packages/fields-color/src/test-fixtures.js index 8e1f49f20e5..e900699fc01 100644 --- a/packages/fields-color/src/test-fixtures.js +++ b/packages/fields-color/src/test-fixtures.js @@ -32,12 +32,12 @@ export const storedValues = () => [ { name: 'g', testField: null }, ]; -export const supportedFilters = () => [ +export const supportedFilters = adapterName => [ 'null_equality', 'equality', - 'equality_case_insensitive', + adapterName !== 'prisma_sqlite' && 'equality_case_insensitive', 'in_empty_null', 'in_value', - 'string', - 'string_case_insensitive', + adapterName !== 'prisma_sqlite' && 'string', + adapterName !== 'prisma_sqlite' && 'string_case_insensitive', ]; diff --git a/packages/fields-markdown/src/test-fixtures.js b/packages/fields-markdown/src/test-fixtures.js index 998293b91d5..9c4242782d7 100644 --- a/packages/fields-markdown/src/test-fixtures.js +++ b/packages/fields-markdown/src/test-fixtures.js @@ -32,12 +32,12 @@ export const storedValues = () => [ { name: 'g', testField: null }, ]; -export const supportedFilters = () => [ +export const supportedFilters = adapterName => [ 'null_equality', 'equality', - 'equality_case_insensitive', + adapterName !== 'prisma_sqlite' && 'equality_case_insensitive', 'in_empty_null', 'in_value', - 'string', - 'string_case_insensitive', + adapterName !== 'prisma_sqlite' && 'string', + adapterName !== 'prisma_sqlite' && 'string_case_insensitive', ]; diff --git a/packages/fields-oembed/src/Implementation.js b/packages/fields-oembed/src/Implementation.js index 3a8653dd6e2..d0df9d2c70c 100644 --- a/packages/fields-oembed/src/Implementation.js +++ b/packages/fields-oembed/src/Implementation.js @@ -306,7 +306,11 @@ export class KnexOEmbedInterface extends CommonOEmbedInterface(KnexFieldAdapter) export class PrismaOEmbedInterface extends CommonOEmbedInterface(PrismaFieldAdapter) { constructor() { super(...arguments); - + if (this.listAdapter.parentAdapter.provider === 'sqlite') { + throw new Error( + `PrismaAdapter provider "sqlite" does not support field type "${this.field.constructor.name}"` + ); + } // Error rather than ignoring invalid config // We totally can index these values, it's just not trivial. See issue #1297 if (this.config.isIndexed) { diff --git a/packages/fields-oembed/src/test-fixtures.js b/packages/fields-oembed/src/test-fixtures.js index f1a4685ee6e..722966340ba 100644 --- a/packages/fields-oembed/src/test-fixtures.js +++ b/packages/fields-oembed/src/test-fixtures.js @@ -11,6 +11,7 @@ export const exampleValue2 = () => 'https://codesandbox.io'; export const supportsUnique = false; export const fieldName = 'portfolio'; export const subfieldName = 'originalUrl'; +export const unSupportedAdapterList = ['prisma_sqlite']; const iframelyAdapter = new IframelyOEmbedAdapter({ apiKey: process.env.IFRAMELY_API_KEY || 'iframely_api_key', @@ -47,5 +48,5 @@ export const storedValues = () => [ export const supportedFilters = adapterName => [ 'null_equality', - adapterName !== 'prisma_postgresql' && 'in_empty_null', + !['prisma_postgresql'].includes(adapterName) && 'in_empty_null', ]; diff --git a/packages/fields-unsplash/src/test-fixtures.skip.js b/packages/fields-unsplash/src/test-fixtures.skip.js index 090bbd84cb9..a7c9674f872 100644 --- a/packages/fields-unsplash/src/test-fixtures.skip.js +++ b/packages/fields-unsplash/src/test-fixtures.skip.js @@ -49,5 +49,5 @@ export const storedValues = () => [ export const supportedFilters = adapterName => [ 'null_equality', - adapterName !== 'prisma_postgresql' && 'in_empty_null', + !['prisma_postgresql', 'prisma_sqlite'].includes(adapterName) && 'in_empty_null', ]; diff --git a/packages/fields-wysiwyg-tinymce/src/test-fixtures.js b/packages/fields-wysiwyg-tinymce/src/test-fixtures.js index 5fcb4c72e18..210eac8fdd8 100644 --- a/packages/fields-wysiwyg-tinymce/src/test-fixtures.js +++ b/packages/fields-wysiwyg-tinymce/src/test-fixtures.js @@ -39,12 +39,12 @@ export const storedValues = () => [ { name: 'g', content: null }, ]; -export const supportedFilters = () => [ +export const supportedFilters = adapterName => [ 'null_equality', 'equality', - 'equality_case_insensitive', + adapterName !== 'prisma_sqlite' && 'equality_case_insensitive', 'in_empty_null', - 'in_equal', - 'string', - 'string_case_insensitive', + 'in_value', + adapterName !== 'prisma_sqlite' && 'string', + adapterName !== 'prisma_sqlite' && 'string_case_insensitive', ]; diff --git a/packages/fields/src/types/DateTime/Implementation.js b/packages/fields/src/types/DateTime/Implementation.js index 955e0a78f87..cbaf65a932c 100644 --- a/packages/fields/src/types/DateTime/Implementation.js +++ b/packages/fields/src/types/DateTime/Implementation.js @@ -229,7 +229,11 @@ export class KnexDateTimeInterface extends CommonDateTimeInterface(KnexFieldAdap export class PrismaDateTimeInterface extends CommonDateTimeInterface(PrismaFieldAdapter) { constructor() { super(...arguments); - + if (this.listAdapter.parentAdapter.provider === 'sqlite') { + throw new Error( + `PrismaAdapter provider "sqlite" does not support field type "${this.field.constructor.name}"` + ); + } this.utcPath = `${this.path}_utc`; this.offsetPath = `${this.path}_offset`; this.realKeys = [this.utcPath, this.offsetPath]; diff --git a/packages/fields/src/types/DateTime/test-fixtures.js b/packages/fields/src/types/DateTime/test-fixtures.js index e8bdede34ea..1fa4541e368 100644 --- a/packages/fields/src/types/DateTime/test-fixtures.js +++ b/packages/fields/src/types/DateTime/test-fixtures.js @@ -8,6 +8,7 @@ export const exampleValue = () => '1990-12-31T12:34:56.789+01:23'; export const exampleValue2 = () => '2000-01-20T00:08:00.000+10:00'; export const supportsUnique = true; export const fieldName = 'lastOnline'; +export const unSupportedAdapterList = ['prisma_sqlite']; export const getTestFields = () => ({ name: { type: Text }, lastOnline: { type } }); diff --git a/packages/fields/src/types/DateTimeUtc/test-fixtures.js b/packages/fields/src/types/DateTimeUtc/test-fixtures.js index cb862d23b70..54f7d57d150 100644 --- a/packages/fields/src/types/DateTimeUtc/test-fixtures.js +++ b/packages/fields/src/types/DateTimeUtc/test-fixtures.js @@ -13,8 +13,8 @@ export const getTestFields = () => ({ name: { type: Text }, lastOnline: { type } export const initItems = () => { return [ - { name: 'person1', lastOnline: '1949-04-12T00:08:00.000Z' }, - { name: 'person2', lastOnline: '1950-10-01T23:59:59.999Z' }, + { name: 'person1', lastOnline: '1979-04-12T00:08:00.000Z' }, + { name: 'person2', lastOnline: '1980-10-01T23:59:59.999Z' }, { name: 'person3', lastOnline: '1990-12-31T12:34:56.789Z' }, { name: 'person4', lastOnline: '2000-01-20T00:08:00.000Z' }, { name: 'person5', lastOnline: '2020-06-10T10:20:30.456Z' }, @@ -24,8 +24,8 @@ export const initItems = () => { }; export const storedValues = () => [ - { name: 'person1', lastOnline: '1949-04-12T00:08:00.000Z' }, - { name: 'person2', lastOnline: '1950-10-01T23:59:59.999Z' }, + { name: 'person1', lastOnline: '1979-04-12T00:08:00.000Z' }, + { name: 'person2', lastOnline: '1980-10-01T23:59:59.999Z' }, { name: 'person3', lastOnline: '1990-12-31T12:34:56.789Z' }, { name: 'person4', lastOnline: '2000-01-20T00:08:00.000Z' }, { name: 'person5', lastOnline: '2020-06-10T10:20:30.456Z' }, @@ -63,15 +63,25 @@ export const filterTests = withKeystone => { ? [ { name: 'person7', lastOnline: null }, { name: 'person6', lastOnline: null }, - { name: 'person1', lastOnline: '1949-04-12T00:08:00.000Z' }, - { name: 'person2', lastOnline: '1950-10-01T23:59:59.999Z' }, + { name: 'person1', lastOnline: '1979-04-12T00:08:00.000Z' }, + { name: 'person2', lastOnline: '1980-10-01T23:59:59.999Z' }, + { name: 'person3', lastOnline: '1990-12-31T12:34:56.789Z' }, + { name: 'person4', lastOnline: '2000-01-20T00:08:00.000Z' }, + { name: 'person5', lastOnline: '2020-06-10T10:20:30.456Z' }, + ] + : adapterName === 'prisma_sqlite' + ? [ + { name: 'person6', lastOnline: null }, + { name: 'person7', lastOnline: null }, + { name: 'person1', lastOnline: '1979-04-12T00:08:00.000Z' }, + { name: 'person2', lastOnline: '1980-10-01T23:59:59.999Z' }, { name: 'person3', lastOnline: '1990-12-31T12:34:56.789Z' }, { name: 'person4', lastOnline: '2000-01-20T00:08:00.000Z' }, { name: 'person5', lastOnline: '2020-06-10T10:20:30.456Z' }, ] : [ - { name: 'person1', lastOnline: '1949-04-12T00:08:00.000Z' }, - { name: 'person2', lastOnline: '1950-10-01T23:59:59.999Z' }, + { name: 'person1', lastOnline: '1979-04-12T00:08:00.000Z' }, + { name: 'person2', lastOnline: '1980-10-01T23:59:59.999Z' }, { name: 'person3', lastOnline: '1990-12-31T12:34:56.789Z' }, { name: 'person4', lastOnline: '2000-01-20T00:08:00.000Z' }, { name: 'person5', lastOnline: '2020-06-10T10:20:30.456Z' }, @@ -89,13 +99,13 @@ export const filterTests = withKeystone => { match( keystone, undefined, - adapterName === 'mongoose' + adapterName === 'mongoose' || adapterName === 'prisma_sqlite' ? [ { name: 'person5', lastOnline: '2020-06-10T10:20:30.456Z' }, { name: 'person4', lastOnline: '2000-01-20T00:08:00.000Z' }, { name: 'person3', lastOnline: '1990-12-31T12:34:56.789Z' }, - { name: 'person2', lastOnline: '1950-10-01T23:59:59.999Z' }, - { name: 'person1', lastOnline: '1949-04-12T00:08:00.000Z' }, + { name: 'person2', lastOnline: '1980-10-01T23:59:59.999Z' }, + { name: 'person1', lastOnline: '1979-04-12T00:08:00.000Z' }, { name: 'person6', lastOnline: null }, { name: 'person7', lastOnline: null }, ] @@ -105,8 +115,8 @@ export const filterTests = withKeystone => { { name: 'person5', lastOnline: '2020-06-10T10:20:30.456Z' }, { name: 'person4', lastOnline: '2000-01-20T00:08:00.000Z' }, { name: 'person3', lastOnline: '1990-12-31T12:34:56.789Z' }, - { name: 'person2', lastOnline: '1950-10-01T23:59:59.999Z' }, - { name: 'person1', lastOnline: '1949-04-12T00:08:00.000Z' }, + { name: 'person2', lastOnline: '1980-10-01T23:59:59.999Z' }, + { name: 'person1', lastOnline: '1979-04-12T00:08:00.000Z' }, ], 'lastOnline_DESC' ) diff --git a/packages/fields/src/types/Decimal/Implementation.js b/packages/fields/src/types/Decimal/Implementation.js index 05deb6143ff..1f8eeac9173 100644 --- a/packages/fields/src/types/Decimal/Implementation.js +++ b/packages/fields/src/types/Decimal/Implementation.js @@ -142,6 +142,11 @@ export class KnexDecimalInterface extends KnexFieldAdapter { export class PrismaDecimalInterface extends PrismaFieldAdapter { constructor() { super(...arguments); + if (this.listAdapter.parentAdapter.provider === 'sqlite') { + throw new Error( + `PrismaAdapter provider "sqlite" does not support field type "${this.field.constructor.name}"` + ); + } const { precision, scale } = this.config; this.isUnique = !!this.config.isUnique; this.isIndexed = !!this.config.isIndexed && !this.config.isUnique; diff --git a/packages/fields/src/types/Decimal/test-fixtures.js b/packages/fields/src/types/Decimal/test-fixtures.js index 80ec45ddf9d..5d08c49127e 100644 --- a/packages/fields/src/types/Decimal/test-fixtures.js +++ b/packages/fields/src/types/Decimal/test-fixtures.js @@ -7,7 +7,7 @@ export const exampleValue = () => '6.28'; export const exampleValue2 = () => '6.45'; export const supportsUnique = true; export const fieldName = 'price'; -export const unSupportedAdapterList = []; +export const unSupportedAdapterList = ['prisma_sqlite']; export const getTestFields = () => ({ name: { type: Text }, diff --git a/packages/fields/src/types/File/Implementation.js b/packages/fields/src/types/File/Implementation.js index cf7fa3e189d..09a393cdd1f 100644 --- a/packages/fields/src/types/File/Implementation.js +++ b/packages/fields/src/types/File/Implementation.js @@ -179,6 +179,11 @@ export class KnexFileInterface extends CommonFileInterface(KnexFieldAdapter) { export class PrismaFileInterface extends CommonFileInterface(PrismaFieldAdapter) { constructor() { super(...arguments); + if (this.listAdapter.parentAdapter.provider === 'sqlite') { + throw new Error( + `PrismaAdapter provider "sqlite" does not support field type "${this.field.constructor.name}"` + ); + } // Error rather than ignoring invalid config // We totally can index these values, it's just not trivial. See issue #1297 diff --git a/packages/fields/src/types/File/test-fixtures.js b/packages/fields/src/types/File/test-fixtures.js index f9175c6f1d1..b990e2575f2 100644 --- a/packages/fields/src/types/File/test-fixtures.js +++ b/packages/fields/src/types/File/test-fixtures.js @@ -11,6 +11,7 @@ export const type = File; export const supportsUnique = false; export const fieldName = 'image'; export const subfieldName = 'originalFilename'; +export const unSupportedAdapterList = ['prisma_sqlite']; // Grab all the image files from the directory const directory = './files'; @@ -89,5 +90,5 @@ export const afterAll = () => { export const supportedFilters = adapterName => [ 'null_equality', - adapterName !== 'prisma_postgresql' && 'in_empty_null', + !['prisma_postgresql'].includes(adapterName) && 'in_empty_null', ]; diff --git a/packages/fields/src/types/Select/Implementation.js b/packages/fields/src/types/Select/Implementation.js index 393ec3fbee1..c40dbf05587 100644 --- a/packages/fields/src/types/Select/Implementation.js +++ b/packages/fields/src/types/Select/Implementation.js @@ -182,7 +182,7 @@ export class PrismaSelectInterface extends CommonSelectInterface(PrismaFieldAdap this.isUnique = !!this.config.isUnique; this.isIndexed = !!this.config.isIndexed && !this.config.isUnique; this._prismaType = - this.config.dataType === 'enum' + this.config.dataType === 'enum' && this.listAdapter.parentAdapter.provider !== 'sqlite' ? `${this.field.listKey}${inflection.classify(this.path)}Enum` : this.config.dataType === 'integer' ? 'Int' diff --git a/packages/fields/src/types/Slug/test-fixtures.js b/packages/fields/src/types/Slug/test-fixtures.js index 5cbfb2eee2f..54a94d27abc 100644 --- a/packages/fields/src/types/Slug/test-fixtures.js +++ b/packages/fields/src/types/Slug/test-fixtures.js @@ -43,10 +43,10 @@ export const storedValues = () => [ { name: 'g', testField: 'null' }, ]; -export const supportedFilters = () => [ +export const supportedFilters = adapterName => [ 'equality', - 'equality_case_insensitive', + adapterName !== 'prisma_sqlite' && 'equality_case_insensitive', 'in_value', - 'string', - 'string_case_insensitive', + adapterName !== 'prisma_sqlite' && 'string', + adapterName !== 'prisma_sqlite' && 'string_case_insensitive', ]; diff --git a/packages/fields/src/types/Text/Implementation.js b/packages/fields/src/types/Text/Implementation.js index e3206839f6e..f089d592a8b 100644 --- a/packages/fields/src/types/Text/Implementation.js +++ b/packages/fields/src/types/Text/Implementation.js @@ -21,11 +21,16 @@ export class Text extends Implementation { return { [`${this.path}`]: item => item[this.path] }; } gqlQueryInputFields() { + const { listAdapter } = this.adapter; return [ ...this.equalityInputFields('String'), - ...this.stringInputFields('String'), - ...this.equalityInputFieldsInsensitive('String'), - ...this.stringInputFieldsInsensitive('String'), + ...(listAdapter.name === 'prisma' && listAdapter.provider === 'sqlite' + ? [] + : [ + ...this.stringInputFields('String'), + ...this.equalityInputFieldsInsensitive('String'), + ...this.stringInputFieldsInsensitive('String'), + ]), ...this.inInputFields('String'), ]; } @@ -47,11 +52,16 @@ export class Text extends Implementation { const CommonTextInterface = superclass => class extends superclass { getQueryConditions(dbPath) { + const { listAdapter } = this; return { ...this.equalityConditions(dbPath), - ...this.stringConditions(dbPath), - ...this.equalityConditionsInsensitive(dbPath), - ...this.stringConditionsInsensitive(dbPath), + ...(listAdapter.name === 'prisma' && listAdapter.provider === 'sqlite' + ? {} + : { + ...this.stringConditions(dbPath), + ...this.equalityConditionsInsensitive(dbPath), + ...this.stringConditionsInsensitive(dbPath), + }), // These have no case-insensitive counter parts ...this.inConditions(dbPath), }; diff --git a/packages/fields/src/types/Text/test-fixtures.js b/packages/fields/src/types/Text/test-fixtures.js index 611079a0bcc..3344eebb6d6 100644 --- a/packages/fields/src/types/Text/test-fixtures.js +++ b/packages/fields/src/types/Text/test-fixtures.js @@ -31,12 +31,12 @@ export const storedValues = () => [ { name: 'g', testField: null }, ]; -export const supportedFilters = () => [ +export const supportedFilters = adapterName => [ 'null_equality', 'equality', - 'equality_case_insensitive', + adapterName !== 'prisma_sqlite' && 'equality_case_insensitive', 'in_empty_null', 'in_value', - 'string', - 'string_case_insensitive', + adapterName !== 'prisma_sqlite' && 'string', + adapterName !== 'prisma_sqlite' && 'string_case_insensitive', ]; diff --git a/packages/fields/src/types/Url/test-fixtures.js b/packages/fields/src/types/Url/test-fixtures.js index c9043cafe24..e20ece2587c 100644 --- a/packages/fields/src/types/Url/test-fixtures.js +++ b/packages/fields/src/types/Url/test-fixtures.js @@ -32,12 +32,12 @@ export const storedValues = () => [ { name: 'g', testField: null }, ]; -export const supportedFilters = () => [ +export const supportedFilters = adapterName => [ 'null_equality', 'equality', - 'equality_case_insensitive', + adapterName !== 'prisma_sqlite' && 'equality_case_insensitive', 'in_empty_null', 'in_value', - 'string', - 'string_case_insensitive', + adapterName !== 'prisma_sqlite' && 'string', + adapterName !== 'prisma_sqlite' && 'string_case_insensitive', ]; diff --git a/packages/keystone/lib/ListTypes/list.js b/packages/keystone/lib/ListTypes/list.js index b2fbc261efc..d04c186a8b8 100644 --- a/packages/keystone/lib/ListTypes/list.js +++ b/packages/keystone/lib/ListTypes/list.js @@ -829,11 +829,22 @@ module.exports = class List { // FIXME: We should do all of these in parallel and return *all* the field access violations await this.checkFieldAccess(operation, itemsToUpdate, context, extraData); - return Promise.all( - itemsToUpdate.map(({ existingItem, id, data }) => - this._updateSingle(id, data, existingItem, context, mutationState) - ) - ); + if (this.adapterName === 'prisma' && this.adapter.parentAdapter.provider === 'sqlite') { + // We perform these operations sequentially as a workaround for a connection + // timeout bug that happens in prisma+sqlite: https://github.com/prisma/prisma/issues/2955 + const ret = []; + for (const item of itemsToUpdate) { + const { existingItem, id, data } = item; + ret.push(await this._updateSingle(id, data, existingItem, context, mutationState)); + } + return ret; + } else { + return Promise.all( + itemsToUpdate.map(({ existingItem, id, data }) => + this._updateSingle(id, data, existingItem, context, mutationState) + ) + ); + } } async _updateSingle(id, originalInput, existingItem, context, mutationState) { @@ -916,9 +927,19 @@ module.exports = class List { const existingItems = await this.getAccessControlledItems(ids, access); - return Promise.all( - existingItems.map(existingItem => this._deleteSingle(existingItem, context, mutationState)) - ); + if (this.adapterName === 'prisma' && this.adapter.parentAdapter.provider === 'sqlite') { + // We perform these operations sequentially as a workaround for a connection + // timeout bug that happens in prisma+sqlite: https://github.com/prisma/prisma/issues/2955 + const ret = []; + for (const existingItem of existingItems) { + ret.push(await this._deleteSingle(existingItem, context, mutationState)); + } + return ret; + } else { + return Promise.all( + existingItems.map(existingItem => this._deleteSingle(existingItem, context, mutationState)) + ); + } } async _deleteSingle(existingItem, context, mutationState) { diff --git a/packages/keystone/tests/List.test.js b/packages/keystone/tests/List.test.js index c6c532e261c..21d45dcc633 100644 --- a/packages/keystone/tests/List.test.js +++ b/packages/keystone/tests/List.test.js @@ -119,7 +119,9 @@ class MockFieldImplementation { async validateDelete() {} async afterDelete() {} } -class MockFieldAdapter {} +class MockFieldAdapter { + listAdapter = { name: 'mock' }; +} const MockIdType = { implementation: MockFieldImplementation, diff --git a/packages/test-utils/src/index.ts b/packages/test-utils/src/index.ts index e0901097e8c..778af9fef40 100644 --- a/packages/test-utils/src/index.ts +++ b/packages/test-utils/src/index.ts @@ -20,7 +20,7 @@ import { initConfig, createSystem, createExpressServer } from '@keystone-next/ke import type { KeystoneConfig, BaseKeystone, KeystoneContext } from '@keystone-next/types'; import memoizeOne from 'memoize-one'; -export type AdapterName = 'mongoose' | 'knex' | 'prisma_postgresql'; +export type AdapterName = 'mongoose' | 'knex' | 'prisma_postgresql' | 'prisma_sqlite'; const hashPrismaSchema = memoizeOne(prismaSchema => crypto.createHash('md5').update(prismaSchema).digest('hex') @@ -49,6 +49,17 @@ const argGenerator = { // Turn this on if you need verbose debug info enableLogging: false, }), + prisma_sqlite: () => ({ + migrationMode: 'prototype', + dropDatabase: true, + url: process.env.DATABASE_URL || '', + provider: 'sqlite', + // Put the generated client at a unique path + getPrismaPath: ({ prismaSchema }: { prismaSchema: string }) => + path.join('.api-test-prisma-clients', hashPrismaSchema(prismaSchema)), + // Turn this on if you need verbose debug info + enableLogging: false, + }), }; // Users should use testConfig({ ... }) in place of config({ ... }) when setting up @@ -74,6 +85,10 @@ async function setupFromConfig({ } else if (adapterName === 'prisma_postgresql') { const adapterArgs = await argGenerator[adapterName](); db = { adapter: adapterName, ...adapterArgs }; + } else if (adapterName === 'prisma_sqlite') { + const adapterArgs = await argGenerator[adapterName](); + db = { adapter: adapterName, ...adapterArgs }; + config.experimental = { prismaSqlite: true }; } const _config = initConfig({ ...config, db: db!, ui: { isDisabled: true } }); @@ -107,6 +122,7 @@ async function setupServer({ mongoose: MongooseAdapter, knex: KnexAdapter, prisma_postgresql: PrismaAdapter, + prisma_sqlite: PrismaAdapter, }[adapterName]; const keystone = new Keystone({ @@ -282,6 +298,12 @@ function multiAdapterRunners(only = process.env.TEST_ADAPTER) { before: _before('prisma_postgresql'), after: _after(() => {}), }, + { + runner: _keystoneRunner('prisma_sqlite', () => {}), + adapterName: 'prisma_sqlite' as const, + before: _before('prisma_sqlite'), + after: _after(() => {}), + }, ].filter(a => typeof only === 'undefined' || a.adapterName === only); } diff --git a/tests/api-tests/access-control/utils.ts b/tests/api-tests/access-control/utils.ts index f700ffa39e3..be78338f436 100644 --- a/tests/api-tests/access-control/utils.ts +++ b/tests/api-tests/access-control/utils.ts @@ -10,11 +10,13 @@ const FAKE_ID = { mongoose: '5b3eabd9e9f2e3e4866742ea', knex: 137, prisma_postgresql: 137, + prisma_sqlite: 137, } as const; const FAKE_ID_2 = { mongoose: '5b3eabd9e9f2e3e4866742eb', knex: 138, prisma_postgresql: 138, + prisma_sqlite: 137, } as const; const COOKIE_SECRET = 'qwertyuiopasdfghjlkzxcvbmnm1234567890'; diff --git a/tests/api-tests/fields/types/DateTime.test.js b/tests/api-tests/fields/types/DateTime.test.js index 2f54588768f..7fa9d863a05 100644 --- a/tests/api-tests/fields/types/DateTime.test.js +++ b/tests/api-tests/fields/types/DateTime.test.js @@ -15,18 +15,22 @@ function setupKeystone(adapterName) { }, }); } -multiAdapterRunners().map(({ runner, adapterName }) => - describe(`Adapter: ${adapterName}`, () => { - describe('DateTime type', () => { - test( - 'is present in the schema', - runner(setupKeystone, async ({ keystone }) => { - // Introspection query - const { - data: { __schema }, - errors, - } = await keystone.executeGraphQL({ - query: ` +multiAdapterRunners().map(({ runner, adapterName }) => { + if (adapterName === 'prisma_sqlite') { + // Appease jest, which doesn't like it when you have an empty test file. + test('noop', () => {}); + } else { + describe(`Adapter: ${adapterName}`, () => { + describe('DateTime type', () => { + test( + 'is present in the schema', + runner(setupKeystone, async ({ keystone }) => { + // Introspection query + const { + data: { __schema }, + errors, + } = await keystone.executeGraphQL({ + query: ` query { __schema { types { @@ -42,168 +46,181 @@ multiAdapterRunners().map(({ runner, adapterName }) => } } `, - }); - expect(errors).toBe(undefined); - expect(__schema).toHaveProperty('types'); - expect(__schema.types).toMatchObject( - expect.arrayContaining([ - expect.objectContaining({ - name: 'DateTime', - kind: 'SCALAR', - }), - ]) - ); - - expect(__schema.types).toMatchObject( - expect.arrayContaining([ - expect.objectContaining({ - name: 'Post', - fields: expect.arrayContaining([ - expect.objectContaining({ - name: 'postedAt', - type: { - name: 'DateTime', - }, - }), - ]), - }), - ]) - ); - }) - ); - - test( - 'response is serialized as a String', - runner(setupKeystone, async ({ keystone }) => { - const postedAt = '2018-08-31T06:49:07.000Z'; - - const createPost = await createItem({ keystone, listKey: 'Post', item: { postedAt } }); - - // Create an item that does the linking - const { data, errors } = await keystone.executeGraphQL({ - query: ` + }); + expect(errors).toBe(undefined); + expect(__schema).toHaveProperty('types'); + expect(__schema.types).toMatchObject( + expect.arrayContaining([ + expect.objectContaining({ + name: 'DateTime', + kind: 'SCALAR', + }), + ]) + ); + + expect(__schema.types).toMatchObject( + expect.arrayContaining([ + expect.objectContaining({ + name: 'Post', + fields: expect.arrayContaining([ + expect.objectContaining({ + name: 'postedAt', + type: { + name: 'DateTime', + }, + }), + ]), + }), + ]) + ); + }) + ); + + test( + 'response is serialized as a String', + runner(setupKeystone, async ({ keystone }) => { + const postedAt = '2018-08-31T06:49:07.000Z'; + + const createPost = await createItem({ + keystone, + listKey: 'Post', + item: { postedAt }, + }); + + // Create an item that does the linking + const { data, errors } = await keystone.executeGraphQL({ + query: ` query { Post(where: { id: "${createPost.id}" }) { postedAt } } `, - }); - expect(errors).toBe(undefined); - expect(data).toHaveProperty('Post.postedAt', postedAt); - }) - ); - - test( - 'input type is accepted as a String', - runner(setupKeystone, async ({ keystone }) => { - const postedAt = '2018-08-31T06:49:07.000Z'; - - // Create an item that does the linking - const { data, errors } = await keystone.executeGraphQL({ - query: ` + }); + expect(errors).toBe(undefined); + expect(data).toHaveProperty('Post.postedAt', postedAt); + }) + ); + + test( + 'input type is accepted as a String', + runner(setupKeystone, async ({ keystone }) => { + const postedAt = '2018-08-31T06:49:07.000Z'; + + // Create an item that does the linking + const { data, errors } = await keystone.executeGraphQL({ + query: ` mutation { createPost(data: { postedAt: "${postedAt}" }) { postedAt } } `, - }); - - expect(errors).toBe(undefined); - expect(data).toHaveProperty('createPost.postedAt', postedAt); - }) - ); - - test( - 'correctly overrides with new value', - runner(setupKeystone, async ({ keystone }) => { - const postedAt = '2018-08-31T06:49:07.000Z'; - const updatedPostedAt = '2018-12-07T05:54:00.556Z'; - - const createPost = await createItem({ keystone, listKey: 'Post', item: { postedAt } }); - - // Create an item that does the linking - const { data, errors } = await keystone.executeGraphQL({ - query: ` + }); + + expect(errors).toBe(undefined); + expect(data).toHaveProperty('createPost.postedAt', postedAt); + }) + ); + + test( + 'correctly overrides with new value', + runner(setupKeystone, async ({ keystone }) => { + const postedAt = '2018-08-31T06:49:07.000Z'; + const updatedPostedAt = '2018-12-07T05:54:00.556Z'; + + const createPost = await createItem({ + keystone, + listKey: 'Post', + item: { postedAt }, + }); + + // Create an item that does the linking + const { data, errors } = await keystone.executeGraphQL({ + query: ` mutation { updatePost(id: "${createPost.id}", data: { postedAt: "${updatedPostedAt}" }) { postedAt } } `, - }); - expect(errors).toBe(undefined); - expect(data).toHaveProperty('updatePost.postedAt', updatedPostedAt); - }) - ); - - test( - 'allows replacing date with null', - runner(setupKeystone, async ({ keystone }) => { - const postedAt = '2018-08-31T06:49:07.000Z'; - - const createPost = await createItem({ keystone, listKey: 'Post', item: { postedAt } }); - - // Create an item that does the linking - const { data, errors } = await keystone.executeGraphQL({ - query: ` + }); + expect(errors).toBe(undefined); + expect(data).toHaveProperty('updatePost.postedAt', updatedPostedAt); + }) + ); + + test( + 'allows replacing date with null', + runner(setupKeystone, async ({ keystone }) => { + const postedAt = '2018-08-31T06:49:07.000Z'; + + const createPost = await createItem({ + keystone, + listKey: 'Post', + item: { postedAt }, + }); + + // Create an item that does the linking + const { data, errors } = await keystone.executeGraphQL({ + query: ` mutation { updatePost(id: "${createPost.id}", data: { postedAt: null }) { postedAt } } `, - }); - expect(errors).toBe(undefined); - expect(data).toHaveProperty('updatePost.postedAt', null); - }) - ); - - test( - 'allows initialising to null', - runner(setupKeystone, async ({ keystone }) => { - // Create an item that does the linking - const { data, errors } = await keystone.executeGraphQL({ - query: ` + }); + expect(errors).toBe(undefined); + expect(data).toHaveProperty('updatePost.postedAt', null); + }) + ); + + test( + 'allows initialising to null', + runner(setupKeystone, async ({ keystone }) => { + // Create an item that does the linking + const { data, errors } = await keystone.executeGraphQL({ + query: ` mutation { createPost(data: { postedAt: null }) { postedAt } } `, - }); - expect(errors).toBe(undefined); - expect(data).toHaveProperty('createPost.postedAt', null); - }) - ); - - test( - 'Does not get clobbered when updating unrelated field', - runner(setupKeystone, async ({ keystone }) => { - const postedAt = '2018-08-31T06:49:07.000Z'; - const title = 'Hello world'; - - const createPost = await createItem({ - keystone, - listKey: 'Post', - item: { postedAt, title }, - }); - - // Create an item that does the linking - const { data, errors } = await keystone.executeGraphQL({ - query: ` + }); + expect(errors).toBe(undefined); + expect(data).toHaveProperty('createPost.postedAt', null); + }) + ); + + test( + 'Does not get clobbered when updating unrelated field', + runner(setupKeystone, async ({ keystone }) => { + const postedAt = '2018-08-31T06:49:07.000Z'; + const title = 'Hello world'; + + const createPost = await createItem({ + keystone, + listKey: 'Post', + item: { postedAt, title }, + }); + + // Create an item that does the linking + const { data, errors } = await keystone.executeGraphQL({ + query: ` mutation { updatePost(id: "${createPost.id}", data: { title: "Something else" }) { postedAt } } `, - }); - expect(errors).toBe(undefined); - expect(data).toHaveProperty('updatePost.postedAt', postedAt); - }) - ); + }); + expect(errors).toBe(undefined); + expect(data).toHaveProperty('updatePost.postedAt', postedAt); + }) + ); + }); }); - }) -); + } +});