Skip to content

Commit f59ae96

Browse files
committed
feat(schema): add partial index support for @@unique and @@index
Add support for partial indexes with WHERE clauses in Prisma Schema Language. Features: - New raw() syntax for WHERE clauses: @@unique([email], where: raw("status = 'active'")) - Object literal syntax for conditions: @@unique([email], where: { active: true }) - Supports: boolean (true/false), null, string, and number values - Conditions: equals, not equals (!=), IS NULL, IS NOT NULL - Support for PostgreSQL, SQLite, SQL Server (filtered indexes), and CockroachDB - Preview feature flag: partialIndexes - Auto-injection of preview feature during introspection Schema Engine: - SQL generation for partial indexes across all supported databases - Migration diffing with predicate comparison - Introspection of existing partial indexes - CockroachDB limitation handling (cannot introspect predicate text) SQL Server specifics: - Filtered unique indexes created via CREATE INDEX (not table constraints) - Predicate normalization for consistent diffing Tests: - Comprehensive migration tests for all supported databases - Introspection tests for PostgreSQL, SQLite, SQL Server, and CockroachDB
1 parent 9d6ad21 commit f59ae96

File tree

56 files changed

+2263
-66
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

56 files changed

+2263
-66
lines changed

libs/sql-ddl/src/postgres.rs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,7 @@ pub struct CreateIndex<'a> {
347347
pub table_reference: &'a dyn Display,
348348
pub columns: Vec<IndexColumn<'a>>,
349349
pub using: Option<IndexAlgorithm>,
350+
pub where_clause: Option<Cow<'a, str>>,
350351
}
351352

352353
impl Display for CreateIndex<'_> {
@@ -388,7 +389,13 @@ impl Display for CreateIndex<'_> {
388389
})
389390
.join(", ", f)?;
390391

391-
f.write_str(")")
392+
f.write_str(")")?;
393+
394+
if let Some(predicate) = &self.where_clause {
395+
write!(f, " WHERE {}", predicate)?;
396+
}
397+
398+
Ok(())
392399
}
393400
}
394401

@@ -432,6 +439,7 @@ mod tests {
432439
table_reference: &PostgresIdentifier::Simple(Cow::Borrowed("Cat")),
433440
columns,
434441
using: None,
442+
where_clause: None,
435443
};
436444

437445
assert_eq!(
@@ -450,6 +458,7 @@ mod tests {
450458
table_reference: &PostgresIdentifier::Simple(Cow::Borrowed("Cat")),
451459
columns,
452460
using: Some(IndexAlgorithm::Hash),
461+
where_clause: None,
453462
};
454463

455464
assert_eq!(
@@ -479,6 +488,7 @@ mod tests {
479488
table_reference: &PostgresIdentifier::Simple("Cat".into()),
480489
columns,
481490
using: None,
491+
where_clause: None,
482492
};
483493

484494
assert_eq!(
@@ -487,6 +497,25 @@ mod tests {
487497
)
488498
}
489499

500+
#[test]
501+
fn create_partial_unique_index() {
502+
let columns = vec![IndexColumn::new("name")];
503+
504+
let create_index = CreateIndex {
505+
is_unique: true,
506+
index_name: "meow_idx".into(),
507+
table_reference: &PostgresIdentifier::Simple(Cow::Borrowed("Cat")),
508+
columns,
509+
using: None,
510+
where_clause: Some("status = 'active'".into()),
511+
};
512+
513+
assert_eq!(
514+
create_index.to_string(),
515+
"CREATE UNIQUE INDEX \"meow_idx\" ON \"Cat\"(\"name\") WHERE status = 'active'"
516+
)
517+
}
518+
490519
#[test]
491520
fn full_alter_table_add_foreign_key() {
492521
let alter_table = AlterTable {

libs/sql-ddl/src/sqlite.rs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,55 @@ impl Display for Column<'_> {
148148
}
149149
}
150150

151+
/// A column in an index definition.
152+
#[derive(Debug, Default)]
153+
pub struct IndexColumn<'a> {
154+
pub name: Cow<'a, str>,
155+
pub sort_order: Option<crate::SortOrder>,
156+
}
157+
158+
/// Create an index statement.
159+
#[derive(Debug)]
160+
pub struct CreateIndex<'a> {
161+
pub index_name: Cow<'a, str>,
162+
pub table_name: Cow<'a, str>,
163+
pub columns: Vec<IndexColumn<'a>>,
164+
pub is_unique: bool,
165+
pub where_clause: Option<Cow<'a, str>>,
166+
}
167+
168+
impl Display for CreateIndex<'_> {
169+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170+
write!(
171+
f,
172+
"CREATE {uniqueness}INDEX \"{index_name}\" ON \"{table_name}\"(",
173+
uniqueness = if self.is_unique { "UNIQUE " } else { "" },
174+
index_name = self.index_name,
175+
table_name = self.table_name,
176+
)?;
177+
178+
self.columns
179+
.iter()
180+
.map(|c| {
181+
let mut rendered = format!("\"{}\"", c.name);
182+
if let Some(sort_order) = c.sort_order {
183+
rendered.push(' ');
184+
rendered.push_str(sort_order.as_ref());
185+
}
186+
rendered
187+
})
188+
.join(", ", f)?;
189+
190+
f.write_str(")")?;
191+
192+
if let Some(predicate) = &self.where_clause {
193+
write!(f, " WHERE {}", predicate)?;
194+
}
195+
196+
Ok(())
197+
}
198+
}
199+
151200
#[cfg(test)]
152201
mod tests {
153202
use super::*;
@@ -270,4 +319,42 @@ mod tests {
270319

271320
assert_eq!(create_table.to_string(), expected.trim_matches('\n'))
272321
}
322+
323+
#[test]
324+
fn create_unique_index() {
325+
let create_index = CreateIndex {
326+
index_name: "idx_name".into(),
327+
table_name: "Cat".into(),
328+
columns: vec![IndexColumn {
329+
name: "name".into(),
330+
sort_order: None,
331+
}],
332+
is_unique: true,
333+
where_clause: None,
334+
};
335+
336+
assert_eq!(
337+
create_index.to_string(),
338+
r#"CREATE UNIQUE INDEX "idx_name" ON "Cat"("name")"#
339+
)
340+
}
341+
342+
#[test]
343+
fn create_partial_unique_index() {
344+
let create_index = CreateIndex {
345+
index_name: "idx_name".into(),
346+
table_name: "Cat".into(),
347+
columns: vec![IndexColumn {
348+
name: "name".into(),
349+
sort_order: None,
350+
}],
351+
is_unique: true,
352+
where_clause: Some("status = 'active'".into()),
353+
};
354+
355+
assert_eq!(
356+
create_index.to_string(),
357+
r#"CREATE UNIQUE INDEX "idx_name" ON "Cat"("name") WHERE status = 'active'"#
358+
)
359+
}
273360
}

psl/parser-database/src/attributes.rs

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ use crate::{
1212
context::Context,
1313
types::{
1414
CompositeTypeField, EnumAttributes, FieldWithArgs, IndexAlgorithm, IndexAttribute, IndexFieldPath, IndexType,
15-
ModelAttributes, OperatorClassStore, RelationField, ScalarField, ScalarFieldType, SortOrder,
15+
ModelAttributes, OperatorClassStore, RelationField, ScalarField, ScalarFieldType, SortOrder, WhereClause,
16+
WhereCondition, WhereFieldCondition, WhereValue,
1617
},
1718
walkers::RelationFieldId,
1819
};
@@ -536,6 +537,7 @@ fn model_index(data: &mut ModelAttributes, model_id: crate::ModelId, ctx: &mut C
536537

537538
index_attribute.algorithm = algo;
538539
index_attribute.clustered = validate_clustering_setting(ctx);
540+
index_attribute.where_clause = parse_where_clause(ctx);
539541

540542
data.ast_indexes.push((ctx.current_attribute_id().1, index_attribute));
541543
}
@@ -592,10 +594,134 @@ fn model_unique(data: &mut ModelAttributes, model_id: crate::ModelId, ctx: &mut
592594
index_attribute.name = name;
593595
index_attribute.mapped_name = mapped_name;
594596
index_attribute.clustered = validate_clustering_setting(ctx);
597+
index_attribute.where_clause = parse_where_clause(ctx);
595598

596599
data.ast_indexes.push((current_attribute_id.1, index_attribute));
597600
}
598601

602+
/// Parse the `where` argument for partial indexes.
603+
fn parse_where_clause(ctx: &mut Context<'_>) -> Option<WhereClause> {
604+
let expression = ctx.visit_optional_arg("where")?;
605+
606+
// Object syntax: { field: value, ... }
607+
if let Some((members, _span)) = expression.as_object() {
608+
if members.is_empty() {
609+
ctx.push_attribute_validation_error("The `where` argument cannot be an empty object.");
610+
return None;
611+
}
612+
613+
let mut conditions = Vec::new();
614+
615+
for member in members {
616+
conditions.push(parse_where_object_member(member, ctx)?);
617+
}
618+
619+
return Some(WhereClause::Object(conditions));
620+
}
621+
622+
// raw("...") function call
623+
if let Some(("raw", args)) = coerce::function(expression, ctx.diagnostics) {
624+
return parse_raw_where_clause(args, ctx);
625+
}
626+
627+
ctx.push_attribute_validation_error(
628+
"The `where` argument must be either a raw() function call or an object literal, e.g. `where: raw(\"status = 'active'\")` or `where: { active: true }`.",
629+
);
630+
631+
None
632+
}
633+
634+
/// Parse raw("...") where clause.
635+
fn parse_raw_where_clause(args: &[ast::Argument], ctx: &mut Context<'_>) -> Option<WhereClause> {
636+
let Some(first_arg) = args.first() else {
637+
ctx.push_attribute_validation_error(
638+
"The `where` argument must be a raw() function with a string argument, e.g. `where: raw(\"status = 'active'\")`.",
639+
);
640+
return None;
641+
};
642+
643+
let Some(predicate) = coerce::string(&first_arg.value, ctx.diagnostics) else {
644+
ctx.push_attribute_validation_error(
645+
"The `where` argument must be a raw() function with a string argument, e.g. `where: raw(\"status = 'active'\")`.",
646+
);
647+
return None;
648+
};
649+
650+
if predicate.is_empty() {
651+
ctx.push_attribute_validation_error("The `where` argument cannot contain an empty string.");
652+
return None;
653+
}
654+
655+
Some(WhereClause::Raw(ctx.interner.intern(predicate)))
656+
}
657+
658+
fn parse_where_object_member(member: &ast::ObjectMember, ctx: &mut Context<'_>) -> Option<WhereFieldCondition> {
659+
let field_name = ctx.interner.intern(&member.key);
660+
661+
let condition = match &member.value {
662+
ast::Expression::ConstantValue(val, _) => match val.as_str() {
663+
"true" => WhereCondition::Equals(WhereValue::Boolean(true)),
664+
"false" => WhereCondition::Equals(WhereValue::Boolean(false)),
665+
"null" => WhereCondition::IsNull,
666+
other => {
667+
ctx.push_attribute_validation_error(&format!(
668+
"Invalid value '{other}' in where clause. Expected true, false, null, a string, a number, or an object like {{ not: null }}."
669+
));
670+
return None;
671+
}
672+
},
673+
ast::Expression::StringValue(val, _) => WhereCondition::Equals(WhereValue::String(ctx.interner.intern(val))),
674+
ast::Expression::NumericValue(val, _) => WhereCondition::Equals(WhereValue::Number(ctx.interner.intern(val))),
675+
ast::Expression::Object(inner_members, _) => {
676+
if inner_members.len() != 1 {
677+
ctx.push_attribute_validation_error(
678+
"Nested object in where clause must have exactly one key. Use `{ not: null }` or `{ not: \"value\" }`.",
679+
);
680+
return None;
681+
}
682+
683+
let inner = &inner_members[0];
684+
if inner.key != "not" {
685+
ctx.push_attribute_validation_error(&format!(
686+
"Unknown key '{}' in nested where clause object. Only 'not' is supported.",
687+
inner.key
688+
));
689+
return None;
690+
}
691+
692+
match &inner.value {
693+
ast::Expression::ConstantValue(val, _) if val == "null" => WhereCondition::IsNotNull,
694+
ast::Expression::StringValue(val, _) => {
695+
WhereCondition::NotEquals(WhereValue::String(ctx.interner.intern(val)))
696+
}
697+
ast::Expression::NumericValue(val, _) => {
698+
WhereCondition::NotEquals(WhereValue::Number(ctx.interner.intern(val)))
699+
}
700+
ast::Expression::ConstantValue(val, _) if val == "true" => {
701+
WhereCondition::NotEquals(WhereValue::Boolean(true))
702+
}
703+
ast::Expression::ConstantValue(val, _) if val == "false" => {
704+
WhereCondition::NotEquals(WhereValue::Boolean(false))
705+
}
706+
_ => {
707+
ctx.push_attribute_validation_error(
708+
"Invalid value for 'not' in where clause. Expected null, a string, a number, or a boolean.",
709+
);
710+
return None;
711+
}
712+
}
713+
}
714+
_ => {
715+
ctx.push_attribute_validation_error(
716+
"Invalid value in where clause. Expected true, false, null, a string, a number, or an object like { not: null }.",
717+
);
718+
return None;
719+
}
720+
};
721+
722+
Some(WhereFieldCondition { field_name, condition })
723+
}
724+
599725
fn common_index_validations(
600726
index_data: &mut IndexAttribute,
601727
model_id: crate::ModelId,

psl/parser-database/src/types.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,54 @@ pub enum IndexType {
528528
Fulltext,
529529
}
530530

531+
#[derive(Debug, Clone, PartialEq)]
532+
pub(crate) enum WhereCondition {
533+
IsNull,
534+
IsNotNull,
535+
Equals(WhereValue),
536+
NotEquals(WhereValue),
537+
}
538+
539+
#[derive(Debug, Clone, PartialEq)]
540+
pub(crate) enum WhereValue {
541+
String(StringId),
542+
Number(StringId),
543+
Boolean(bool),
544+
}
545+
546+
impl WhereValue {
547+
pub(crate) fn to_sql(&self, db: &ParserDatabase) -> String {
548+
match self {
549+
WhereValue::String(s) => format!("'{}'", db[*s].replace('\'', "''")),
550+
WhereValue::Number(n) => db[*n].to_string(),
551+
WhereValue::Boolean(b) => b.to_string(),
552+
}
553+
}
554+
}
555+
556+
impl WhereCondition {
557+
pub(crate) fn to_sql(&self, field_name: &str, db: &ParserDatabase) -> String {
558+
match self {
559+
WhereCondition::IsNull => format!("{field_name} IS NULL"),
560+
WhereCondition::IsNotNull => format!("{field_name} IS NOT NULL"),
561+
WhereCondition::Equals(v) => format!("{field_name} = {}", v.to_sql(db)),
562+
WhereCondition::NotEquals(v) => format!("{field_name} != {}", v.to_sql(db)),
563+
}
564+
}
565+
}
566+
567+
#[derive(Debug, Clone)]
568+
pub(crate) struct WhereFieldCondition {
569+
pub(crate) field_name: StringId,
570+
pub(crate) condition: WhereCondition,
571+
}
572+
573+
#[derive(Debug, Clone)]
574+
pub(crate) enum WhereClause {
575+
Raw(StringId),
576+
Object(Vec<WhereFieldCondition>),
577+
}
578+
531579
#[derive(Debug, Default)]
532580
pub(crate) struct IndexAttribute {
533581
pub(crate) r#type: IndexType,
@@ -537,6 +585,7 @@ pub(crate) struct IndexAttribute {
537585
pub(crate) mapped_name: Option<StringId>,
538586
pub(crate) algorithm: Option<IndexAlgorithm>,
539587
pub(crate) clustered: Option<bool>,
588+
pub(crate) where_clause: Option<WhereClause>,
540589
}
541590

542591
impl IndexAttribute {

0 commit comments

Comments
 (0)