Skip to content

Commit

Permalink
WIP(schema-wasm): support schema split into multiple files (#4787)
Browse files Browse the repository at this point in the history
* Implement multi-file schema handling in PSL

This commit implements multi-file schema handling in the Prisma Schema Language.

At a high level, instead of accepting a single string, `psl::validate_multi_file()` is an alternative to `psl::validate()` that accepts something morally equivalent to:

```json
{
  "./prisma/schema/a.prisma": "datasource db { ... }",
  "./prisma/schema/nested/b.prisma": "model Test { ... }"
}
```

There are tests for PSL validation with multiple schema files, but most of the rest of engines still consumes the single file version of `psl::validate()`. The implementation and the return type are shared between `psl::validate_multi_file()` and `psl::validate()`, so the change is completely transparent, other than the expectation of passing in a list of (file_name, file_contents) instead of a single string. The `psl::validate()` entry point should behave exactly the same as `psl::multi_schema()` with a single file named `schema.prisma`. In particular, it has the exact same return type.

Implementation
==============

This is achieved by extending `Span` to contain, in addition to a start and end offset, a `FileId`. The `FileId` is a unique identifier for a file and its parsed `SchemaAst` inside `ParserDatabase`. The identifier types for AST items in `ParserDatabase` are also extended to contain the `FileId`, so that they can be uniquely referred to in the context of the (multi-file) schema. After the analysis phase (the `parser_database` crate), consumers of the analyzed schema become multi-file aware completely transparently, no change is necessary in the other engines.

The only changes that will be required at scattered points across the codebase are the `psl::validate()` call sites that will need to receive a `Vec<Box<Path>, SourceFile>` instead of a single `SourceFile`. This PR does _not_ deal with that, but it makes where these call sites are obvious by what entry points they use: `psl::validate()`, `psl::parse_schema()` and the various `*_assert_single()` methods on `ParserDatabase`.

The PR contains tests confirming that schema analysis, validation and displaying diagnostics across multiple files works as expected.

Status of this PR
=================

This is going to be directly mergeable after review, and it will not affect the current schema handling behaviour when dealing with a single schema file.

Next steps
==========

- Replace all calls to `psl::validate()` with calls to `psl::validate_multi_file()`.
- The `*_assert_single()` calls should be progressively replaced with their multi-file counterparts across engines.
- The language server should start sending multiple files to prisma-schema-wasm in all calls. This is not in the spirit of the language server spec, but that is the most immediate solution. We'll have to make `range_to_span()` in `prisma-fmt` multi-schema aware by taking a FileId param.

Links
=====

Relevant issue: prisma/prisma#2377

Also see the [internal design doc](https://www.notion.so/prismaio/Multi-file-Schema-24d68fe8664048ad86252fe446caac24?d=68ef128f25974e619671a9855f65f44d#2889a038e68c4fe1ac9afe3cd34978bd).

* WIP(schema-wasm): Support schema split into multiple files

* Reformat support (psl crate)

* Add multifile reformatting tests

* Clippy

* feat(prisma-fmt): addd support for mergeSchemas, expose functions to prisma-fmt-wasm

* chore(prisma-fmt): removed unused function

* chore: fix typo

Co-authored-by: Serhii Tatarintsev <tatarintsev@prisma.io>

* feat(prisma-fmt): apply validation to merge_schemas

* chore(prisma-fmt): update unit test

* chore: fix bad merge

* chore: fix tests

---------

Co-authored-by: Tom Houlé <tom@tomhoule.com>
Co-authored-by: Alberto Schiabel <jkomyno@users.noreply.github.com>
Co-authored-by: jkomyno <skiabo97@gmail.com>
  • Loading branch information
4 people committed Apr 8, 2024
1 parent dcdb692 commit 87bc6b8
Show file tree
Hide file tree
Showing 43 changed files with 750 additions and 86 deletions.
3 changes: 1 addition & 2 deletions prisma-fmt/src/code_actions/multi_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,7 @@ pub(super) fn add_schema_to_schemas(
)
}
None => {
let has_properties = datasource.provider_defined()
|| datasource.url_defined()
let has_properties = datasource.provider_defined() | datasource.url_defined()
|| datasource.direct_url_defined()
|| datasource.shadow_url_defined()
|| datasource.relation_mode_defined()
Expand Down
49 changes: 29 additions & 20 deletions prisma-fmt/src/get_config.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
use psl::Diagnostics;
use psl::{Diagnostics, ValidatedSchema};
use serde::Deserialize;
use serde_json::json;
use std::collections::HashMap;

use crate::validate::SCHEMA_PARSER_ERROR_CODE;
use crate::{schema_file_input::SchemaFileInput, validate::SCHEMA_PARSER_ERROR_CODE};

#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct GetConfigParams {
prisma_schema: String,
prisma_schema: SchemaFileInput,
#[serde(default)]
ignore_env_var_errors: bool,
#[serde(default)]
Expand Down Expand Up @@ -43,29 +43,38 @@ pub(crate) fn get_config(params: &str) -> Result<String, String> {
}

fn get_config_impl(params: GetConfigParams) -> Result<serde_json::Value, GetConfigError> {
let wrap_get_config_err = |errors: Diagnostics| -> GetConfigError {
use std::fmt::Write as _;

let mut full_error = errors.to_pretty_string("schema.prisma", &params.prisma_schema);
write!(full_error, "\nValidation Error Count: {}", errors.errors().len()).unwrap();

GetConfigError {
// this mirrors user_facing_errors::common::SchemaParserError
error_code: Some(SCHEMA_PARSER_ERROR_CODE),
message: full_error,
}
};

let mut config = psl::parse_configuration(&params.prisma_schema).map_err(wrap_get_config_err)?;
let mut schema = psl::validate_multi_file(params.prisma_schema.into());
if schema.diagnostics.has_errors() {
return Err(create_get_config_error(&schema, &schema.diagnostics));
}

if !params.ignore_env_var_errors {
let overrides: Vec<(_, _)> = params.datasource_overrides.into_iter().collect();
config
schema
.configuration
.resolve_datasource_urls_prisma_fmt(&overrides, |key| params.env.get(key).map(String::from))
.map_err(wrap_get_config_err)?;
.map_err(|diagnostics| create_get_config_error(&schema, &diagnostics))?;
}

Ok(psl::get_config(&config))
Ok(psl::get_config(&schema.configuration))
}

fn create_get_config_error(schema: &ValidatedSchema, diagnostics: &Diagnostics) -> GetConfigError {
use std::fmt::Write as _;

let mut rendered_diagnostics = schema.render_diagnostics(diagnostics);
write!(
rendered_diagnostics,
"\nValidation Error Count: {}",
diagnostics.errors().len()
)
.unwrap();

GetConfigError {
// this mirrors user_facing_errors::common::SchemaParserError
error_code: Some(SCHEMA_PARSER_ERROR_CODE),
message: rendered_diagnostics,
}
}

#[cfg(test)]
Expand Down
47 changes: 44 additions & 3 deletions prisma-fmt/src/get_dmmf.rs

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions prisma-fmt/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ mod code_actions;
mod get_config;
mod get_dmmf;
mod lint;
mod merge_schemas;
mod native;
mod preview;
mod schema_file_input;
mod text_document_completion;
mod validate;

Expand Down Expand Up @@ -89,6 +91,14 @@ pub fn validate(validate_params: String) -> Result<(), String> {
validate::validate(&validate_params)
}

/// Given a list of Prisma schema files (and their locations), returns the merged schema.
/// This is useful for `@prisma/client` generation, where the client needs a single - potentially large - schema,
/// while still allowing the user to split their schema copies into multiple files.
/// Internally, it uses `[validate]`.
pub fn merge_schemas(params: String) -> Result<String, String> {
merge_schemas::merge_schemas(&params)
}

pub fn native_types(schema: String) -> String {
native::run(&schema)
}
Expand Down
127 changes: 127 additions & 0 deletions prisma-fmt/src/merge_schemas.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
use psl::reformat_validated_schema_into_single;
use serde::Deserialize;

use crate::schema_file_input::SchemaFileInput;

#[derive(Debug, Deserialize)]
pub struct MergeSchemasParams {
schema: SchemaFileInput,
}

pub(crate) fn merge_schemas(params: &str) -> Result<String, String> {
let params: MergeSchemasParams = match serde_json::from_str(params) {
Ok(params) => params,
Err(serde_err) => {
panic!("Failed to deserialize MergeSchemasParams: {serde_err}");
}
};

let validated_schema = crate::validate::run(params.schema, false)?;

let indent_width = 2usize;
let merged_schema = reformat_validated_schema_into_single(validated_schema, indent_width).unwrap();

Ok(merged_schema)
}

#[cfg(test)]
mod tests {
use super::*;
use expect_test::expect;
use serde_json::json;

#[test]
fn merge_two_valid_schemas_succeeds() {
let schema = vec![
(
"b.prisma",
r#"
model B {
id String @id
a A?
}
"#,
),
(
"a.prisma",
r#"
datasource db {
provider = "postgresql"
url = env("DBURL")
}
model A {
id String @id
b_id String @unique
b B @relation(fields: [b_id], references: [id])
}
"#,
),
];

let request = json!({
"schema": schema,
});

let expected = expect![[r#"
model B {
id String @id
a A?
}
datasource db {
provider = "postgresql"
url = env("DBURL")
}
model A {
id String @id
b_id String @unique
b B @relation(fields: [b_id], references: [id])
}
"#]];

let response = merge_schemas(&request.to_string()).unwrap();
expected.assert_eq(&response);
}

#[test]
fn merge_two_invalid_schemas_panics() {
let schema = vec![
(
"b.prisma",
r#"
model B {
id String @id
a A?
}
"#,
),
(
"a.prisma",
r#"
datasource db {
provider = "postgresql"
url = env("DBURL")
}
model A {
id String @id
b_id String @unique
}
"#,
),
];

let request = json!({
"schema": schema,
});

let expected = expect![[
r#"{"error_code":"P1012","message":"\u001b[1;91merror\u001b[0m: \u001b[1mError validating field `a` in model `B`: The relation field `a` on model `B` is missing an opposite relation field on the model `A`. Either run `prisma format` or add it manually.\u001b[0m\n \u001b[1;94m-->\u001b[0m \u001b[4mb.prisma:4\u001b[0m\n\u001b[1;94m | \u001b[0m\n\u001b[1;94m 3 | \u001b[0m id String @id\n\u001b[1;94m 4 | \u001b[0m \u001b[1;91ma A?\u001b[0m\n\u001b[1;94m 5 | \u001b[0m }\n\u001b[1;94m | \u001b[0m\n\nValidation Error Count: 1"}"#
]];

let response = merge_schemas(&request.to_string()).unwrap_err();
expected.assert_eq(&response);
}
}
26 changes: 26 additions & 0 deletions prisma-fmt/src/schema_file_input.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
use psl::SourceFile;
use serde::Deserialize;

/// Struct for supporting multiple files
/// in a backward-compatible way: can either accept
/// a single file contents or vector of (filePath, content) tuples.
/// Can be converted to the input for `psl::validate_multi_file` from
/// any of the variants.
#[derive(Deserialize, Debug)]
#[serde(untagged)]
pub(crate) enum SchemaFileInput {
Single(String),
Multiple(Vec<(String, String)>),
}

impl From<SchemaFileInput> for Vec<(String, SourceFile)> {
fn from(value: SchemaFileInput) -> Self {
match value {
SchemaFileInput::Single(content) => vec![("schema.prisma".to_owned(), content.into())],
SchemaFileInput::Multiple(file_list) => file_list
.into_iter()
.map(|(filename, content)| (filename, content.into()))
.collect(),
}
}
}
93 changes: 87 additions & 6 deletions prisma-fmt/src/validate.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
use psl::ValidatedSchema;
use serde::Deserialize;
use serde_json::json;
use std::fmt::Write as _;

use crate::schema_file_input::SchemaFileInput;

// this mirrors user_facing_errors::common::SchemaParserError
pub(crate) static SCHEMA_PARSER_ERROR_CODE: &str = "P1012";

#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct ValidateParams {
prisma_schema: String,
prisma_schema: SchemaFileInput,
#[serde(default)]
no_color: bool,
}
Expand All @@ -21,21 +24,22 @@ pub(crate) fn validate(params: &str) -> Result<(), String> {
}
};

run(&params.prisma_schema, params.no_color)
run(params.prisma_schema, params.no_color)?;
Ok(())
}

pub fn run(input_schema: &str, no_color: bool) -> Result<(), String> {
let validate_schema = psl::validate(input_schema.into());
pub fn run(input_schema: SchemaFileInput, no_color: bool) -> Result<ValidatedSchema, String> {
let validate_schema = psl::validate_multi_file(input_schema.into());
let diagnostics = &validate_schema.diagnostics;

if !diagnostics.has_errors() {
return Ok(());
return Ok(validate_schema);
}

// always colorise output regardless of the environment, which is important for Wasm
colored::control::set_override(!no_color);

let mut formatted_error = diagnostics.to_pretty_string("schema.prisma", input_schema);
let mut formatted_error = validate_schema.render_own_diagnostics();
write!(
formatted_error,
"\nValidation Error Count: {}",
Expand Down Expand Up @@ -109,6 +113,83 @@ mod tests {
validate(&request.to_string()).unwrap();
}

#[test]
fn validate_multiple_files() {
let schema = vec![
(
"a.prisma",
r#"
datasource thedb {
provider = "postgresql"
url = env("DBURL")
}
model A {
id String @id
b_id String @unique
b B @relation(fields: [b_id], references: [id])
}
"#,
),
(
"b.prisma",
r#"
model B {
id String @id
a A?
}
"#,
),
];

let request = json!({
"prismaSchema": schema,
});

validate(&request.to_string()).unwrap();
}

#[test]
fn validate_multiple_files_error() {
let schema = vec![
(
"a.prisma",
r#"
datasource thedb {
provider = "postgresql"
url = env("DBURL")
}
model A {
id String @id
b_id String @unique
b B @relation(fields: [b_id], references: [id])
}
"#,
),
(
"b.prisma",
r#"
model B {
id String @id
a A
}
"#,
),
];

let request = json!({
"prismaSchema": schema,
});

let expected = expect![[
r#"{"error_code":"P1012","message":"\u001b[1;91merror\u001b[0m: \u001b[1mError parsing attribute \"@relation\": The relation field `a` on Model `B` is required. This is no longer valid because it's not possible to enforce this constraint on the database level. Please change the field type from `A` to `A?` to fix this.\u001b[0m\n \u001b[1;94m-->\u001b[0m \u001b[4mb.prisma:4\u001b[0m\n\u001b[1;94m | \u001b[0m\n\u001b[1;94m 3 | \u001b[0m id String @id\n\u001b[1;94m 4 | \u001b[0m \u001b[1;91ma A\u001b[0m\n\u001b[1;94m 5 | \u001b[0m }\n\u001b[1;94m | \u001b[0m\n\nValidation Error Count: 1"}"#
]];

let response = validate(&request.to_string()).unwrap_err();
expected.assert_eq(&response);
}

#[test]
fn validate_using_both_relation_mode_and_referential_integrity() {
let schema = r#"
Expand Down
Loading

0 comments on commit 87bc6b8

Please sign in to comment.