Skip to main content

Validation Pipeline

The validation pipeline transforms a parsed AST into a semantically validated schema. This is handled by the parser-database and psl-core crates.

Parser Database

The ParserDatabase is the core container for validated schema information:
[dependencies]
diagnostics.workspace = true
schema-ast.workspace = true
indexmap.workspace = true
enumflags2.workspace = true
itertools.workspace = true
either.workspace = true
rustc-hash.workspace = true

Purpose and Scope

The parser database is connector-agnostic - it performs generic validations that apply to all databases:
  • Name resolution and uniqueness
  • Type checking
  • Relationship validation
  • Attribute syntax validation
  • Constraint name generation
Connector-specific validations (like native types, capabilities) happen later in psl-core.
ParserDatabase never fails. It accumulates diagnostics while building as complete a database as possible, even with errors in the schema.

Validation Phases

Validation proceeds in four phases:

1. Name Resolution

First pass: resolve all identifiers to IDs
pub struct ParserDatabase {
    names: Names,  // Maps names to IDs
    // ...
}
Resolved items:
  • Models and views
  • Enums
  • Composite types
  • Fields (model fields, composite fields)
  • Datasources
  • Generators
Each item gets a unique ID:
pub struct ModelId(u32);
pub struct EnumId(u32);
pub struct ScalarFieldId { model: ModelId, field: u32 };
// ... other ID types

2. Type Resolution

Second pass: resolve type references
pub struct Types {
    // Field type -> (ScalarType | Model | Enum | CompositeType)
    // ...
}
For each field:
  • Resolve type identifier to its definition
  • Determine if it’s a scalar, relation, enum, or composite
  • Handle optional/required/list arity
  • Validate type compatibility

3. Attribute Validation

Third pass: validate attributes on models and fields
// Attributes on fields
@id
@unique
@default(...)
@relation(...)
@map("db_name")
@db.NativeType(...)

// Attributes on models
@@id([field1, field2])
@@unique([field1, field2])
@@index([field1, field2])
@@map("table_name")
Validations:
  • Required vs. optional arguments
  • Argument types
  • Attribute compatibility
  • Duplicate attributes

4. Global Validations

Fourth pass: cross-cutting validations
  • Relation completeness and consistency
  • Index name collisions
  • Unique constraint validation
  • Primary key requirements
  • Referential actions compatibility

Database Structure

pub struct ParserDatabase {
    asts: Files,                      // Multi-file ASTs
    interner: StringInterner,         // Deduplicated strings
    names: Names,                     // Name -> ID mappings
    types: Types,                     // Type resolution data
    relations: Relations,             // Relation graph
    extension_metadata: ExtensionMetadata,
}

String Interning

Efficient string storage:
pub struct StringInterner {
    // Deduplicates strings, returns StringId
}

pub struct StringId(u32);
Benefits:
  • Reduced memory usage
  • Fast string comparison (compare IDs)
  • Canonical representation

Type System

pub enum ScalarType {
    Int,
    BigInt,
    Float,
    Decimal,
    Boolean,
    String,
    DateTime,
    Json,
    Bytes,
}

pub enum ScalarFieldType {
    BuiltInScalar(ScalarType),
    CompositeType(CompositeTypeId),
    Enum(EnumId),
    Unsupported(String),
}

Relations

pub struct Relations {
    // Tracks all relations in the schema
}

pub struct RelationId(u32);

pub struct ManyToManyRelationId(u32);

pub enum ReferentialAction {
    Cascade,
    Restrict,
    NoAction,
    SetNull,
    SetDefault,
}

Walker API

The walker API provides type-safe schema traversal:
use parser_database::walkers::*;

let db: ParserDatabase = /* ... */;

// Iterate all models
for model in db.walk_models() {
    println!("Model: {}", model.name());
    
    // Iterate fields
    for field in model.scalar_fields() {
        println!("  Field: {} : {}", 
                 field.name(), 
                 field.scalar_type());
    }
    
    // Iterate relations
    for relation in model.relation_fields() {
        println!("  Relation: {} -> {}", 
                 relation.name(),
                 relation.related_model().name());
    }
}

Walker Types

pub struct ModelWalker<'db> { /* ... */ }
pub struct EnumWalker<'db> { /* ... */ }
pub struct ScalarFieldWalker<'db> { /* ... */ }
pub struct RelationFieldWalker<'db> { /* ... */ }
pub struct IndexWalker<'db> { /* ... */ }
// ... many more
Walkers provide:
  • Safe access to schema data
  • Navigation between related items
  • Computed properties
  • Database name resolution
Walkers borrow from ParserDatabase, ensuring data consistency. They’re lightweight and can be created on-demand.

Validation Examples

Name Uniqueness

model User {
  id   Int @id
  name String
}

model User {  // ERROR: Duplicate model name
  id Int @id
}
Validation error: DatamodelError::new_duplicate_top_error("User", ...)

Type Resolution

model Post {
  id       Int  @id
  authorId Int
  author   User @relation(fields: [authorId], references: [id])
  category Category  // ERROR if Category doesn't exist
}

Relation Validation

model User {
  id    Int    @id
  posts Post[]  // One-to-many relation
}

model Post {
  id       Int  @id
  authorId Int
  // ERROR: Missing relation field
  // Should have: author User @relation(...)
}

Attribute Validation

model User {
  id    Int    @id @default(autoincrement())
  email String @unique @unique  // ERROR: Duplicate attribute
  name  String @default(123)     // ERROR: Type mismatch
}

Constraint Names

The database generates default constraint names:
model User {
  id        Int    @id
  email     String @unique
  firstName String
  lastName  String
  
  @@unique([firstName, lastName])  // Gets generated name
  @@index([email])                 // Gets generated name
}
Constraint scopes:
pub enum ConstraintScope {
    GlobalPrimaryKeyKeyIndex,
    ModelPrimaryKeyKeyIndexForeignKey,
    GlobalForeignKey,
    ModelKeyIndex,
}
Constraint name generation is connector-specific, handled by the connector trait’s constraint_violation_scopes() method.

Reserved Names

Certain names are reserved and validated:
pub fn is_reserved_type_name(name: &str) -> bool {
    matches!(
        name,
        "String" | "Int" | "Float" | "Boolean" | "DateTime" | "Json" | "Bytes" | "Decimal" | "BigInt"
    )
}

Index Definitions

Indexes are validated and stored:
pub struct IndexFieldPath {
    // Path to the indexed field (supports nested fields)
}

pub enum IndexType {
    Normal,
    Unique,
    Fulltext,
}

pub enum IndexAlgorithm {
    BTree,
    Hash,
    Gist,
    Gin,
    // ... connector-specific algorithms
}

pub enum SortOrder {
    Asc,
    Desc,
}

Extension Types

Support for custom types via extensions:
pub trait ExtensionTypes: Send + Sync {
    fn get(&self, name: &str) -> Option<ExtensionTypeId>;
    fn get_entry(&self, id: ExtensionTypeId) -> &ExtensionTypeEntry;
}

pub struct NoExtensionTypes;  // Default: no extensions

impl ExtensionTypes for NoExtensionTypes {
    fn get(&self, _: &str) -> Option<ExtensionTypeId> {
        None
    }
    // ...
}
Extensions allow adding custom types to the schema without modifying PSL core.

Diagnostic Collection

Diagnostics accumulate throughout validation:
use diagnostics::{Diagnostics, DatamodelError, DatamodelWarning};

let mut diagnostics = Diagnostics::new();

let db = ParserDatabase::new_single_file(
    source,
    &mut diagnostics,
    extension_types,
);

if diagnostics.has_errors() {
    eprintln!("Schema validation failed:");
    eprintln!("{}", diagnostics.to_pretty_string(
        "schema.prisma",
        &source,
    ));
}

Error Types

pub enum DatamodelError {
    DuplicateTopError { name: String, span: Span },
    TypeNotFoundError { type_name: String, span: Span },
    RelationValidationError { message: String, span: Span },
    // ... many more variants
}

Validation Pipeline Entry Points

Single File

use psl_core::validate;

let validated = validate(
    source_file,
    connector_registry,
    extension_types,
);

Multi-File

use psl_core::validate_multi_file;

let files = vec![
    ("schema.prisma".into(), source1),
    ("models.prisma".into(), source2),
];

let validated = validate_multi_file(
    &files,
    connector_registry,
    extension_types,
);

Parse Only (Skip Validation)

use psl_core::parse_without_validation;

let schema = parse_without_validation(
    source_file,
    connector_registry,
    extension_types,
);

Testing

PSL uses declarative validation tests:
tests/validation/
  models/
    valid_model.prisma
    duplicate_field.prisma
    invalid_type.prisma
  relations/
    one_to_many.prisma
    many_to_many.prisma
Test format:
model User {
  id   Int @id
  name name  // Lowercase type
}

// error: Type "name" is not defined.
//   --> schema.prisma:3
//    |
//  2 |   id   Int @id
//  3 |   name name
//    |
Running tests:
# All validation tests
cargo test -p psl --test validation_tests

# Update expectations
UPDATE_EXPECT=1 cargo test -p psl --test validation_tests

# Specific test directory
cargo test -p psl --test validation_tests models
Tests with no error comment at the end expect a valid schema. Tests with error comments expect those exact errors.

Configuration Parsing

Parse only datasource and generator blocks:
use psl::parse_configuration;

let config = parse_configuration(schema)?;

for datasource in &config.datasources {
    println!("Datasource: {}", datasource.name);
    println!("Provider: {}", datasource.active_provider);
}

for generator in &config.generators {
    println!("Generator: {}", generator.name);
    println!("Provider: {:?}", generator.provider);
}
This is faster than full validation when you only need configuration.

Common Validations

Primary Key Validation

  • Every model needs an @id or @@id
  • Single-field or compound primary keys
  • Primary key fields cannot be optional

Unique Constraint Validation

  • Single-field @unique or compound @@unique
  • Unique fields can be optional
  • Unique constraints can have names via map:

Relation Validation

  • Both sides of relation must have fields
  • Referencing fields must match referenced fields in type and count
  • Referential actions must be supported by the connector

Default Value Validation

  • Default must match field type
  • Functions like autoincrement(), now(), uuid() validated per connector
  • Constants validated for type compatibility

Next Steps

Connectors

Learn about database-specific validations

Parser

Understand the AST and parsing phase

Build docs developers (and LLMs) love