Skip to content

fix: prevent breaking default expr in SQLite#5748

Open
ken0x0a wants to merge 5 commits intoprisma:mainfrom
ken0x0a:fix/sql-default-expr
Open

fix: prevent breaking default expr in SQLite#5748
ken0x0a wants to merge 5 commits intoprisma:mainfrom
ken0x0a:fix/sql-default-expr

Conversation

@ken0x0a
Copy link

@ken0x0a ken0x0a commented Jan 22, 2026

This PR is intended to fix:

Summary by CodeRabbit

  • New Features

    • Introspection now preserves timestamp-based defaults and surfaces a createdAt field on Post models when present.
  • Bug Fixes

    • Fixed SQLite default value representation so timestamp/default expressions are consistently recognized and compatible with schema generation.
  • Tests

    • Added test coverage verifying timestamp default expressions and their introspection in SQLite.

Copilot AI review requested due to automatic review settings January 22, 2026 18:31
@CLAassistant
Copy link

CLAassistant commented Jan 22, 2026

CLA assistant check
All committers have signed the CLA.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes issue #29064 where SQLite's PRAGMA table_info removes parentheses from default value expressions, causing schema drift on re-introspection. A wrap_in_parentheses helper function is added to restore parentheses when generating Prisma schemas.

Changes:

  • Added wrap_in_parentheses function to restore parentheses removed by SQLite's PRAGMA
  • Applied the function to db_generated defaults for Int, BigInt, Float, Decimal, Boolean, and DateTime types
  • Added test case with complex default expression to verify the fix

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
schema-engine/sql-schema-describer/src/sqlite.rs Implements wrap_in_parentheses helper and applies it to various column type default values
schema-engine/sql-introspection-tests/tests/re_introspection/sqlite.rs Adds test case with CAST(unixepoch('subsec') * 1000 AS INTEGER) default to verify parentheses are preserved

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

_ => DefaultValue::db_generated(wrap_in_parentheses(&default_string)),
},
ColumnTypeFamily::Json => DefaultValue::value(default_string),
ColumnTypeFamily::Binary => DefaultValue::db_generated(default_string),
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wrap_in_parentheses function is not applied to the Binary column type, but it is applied to other column types (Int, BigInt, Float, Decimal, Boolean, DateTime). Since the issue being fixed is that SQLite's PRAGMA table_info removes parentheses from default values for all types, this should likely be applied consistently to Binary as well for consistency, unless there's a specific reason Binary defaults don't need parentheses wrapping.

Suggested change
ColumnTypeFamily::Binary => DefaultValue::db_generated(default_string),
ColumnTypeFamily::Binary => DefaultValue::db_generated(wrap_in_parentheses(&default_string)),

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ken0x0a I think this comment is valid and the function should be applied for all types

/// so we need to add parentheses when generating prisma schema.
/// See <https://github.com/prisma/prisma/issues/29064>
fn wrap_in_parentheses(s: &str) -> Cow<'_, str> {
if s[0..1] != *"(" && s[s.len() - 1..] == *")" {
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The condition logic is incorrect. This checks if the first character is NOT '(' AND the last character IS ')', which is the opposite of what should be checked. This will wrap strings that end with ')' but don't start with '(', and will not wrap strings that need wrapping.

The condition should be: !s.starts_with('(') || !s.ends_with(')') to check if either parenthesis is missing, or more likely: !(s.starts_with('(') && s.ends_with(')')) to check if the string is not already fully wrapped.

Suggested change
if s[0..1] != *"(" && s[s.len() - 1..] == *")" {
if !(s.starts_with('(') && s.ends_with(')')) {

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ken0x0a indeed, the condition should probably be !(s.starts_with('(') && s.ends_with(')'))

@ken0x0a
Copy link
Author

ken0x0a commented Jan 22, 2026

The most important thing I must confess is,
I'm not sure if this PR fixes the above ISSUE (prisma/prisma#29064) ...

If this doesn't fix it, could anyone let me know how to fix it...

@codspeed-hq
Copy link

codspeed-hq bot commented Feb 5, 2026

Merging this PR will not alter performance

✅ 11 untouched benchmarks
⏩ 11 skipped benchmarks1


Comparing ken0x0a:fix/sql-default-expr (ee8ace3) with main (94a226b)

Open in CodSpeed

Footnotes

  1. 11 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

ken0x0a and others added 2 commits February 10, 2026 19:26
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
/// so we need to add parentheses when generating prisma schema.
/// See <https://github.com/prisma/prisma/issues/29064>
fn wrap_in_parentheses(s: &str) -> Cow<'_, str> {
if !s.starts_with('(') && s.ends_with(')') {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is meant to be
!(s.starts_with('(') && s.ends_with(')'))

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it's not.

We need to wrap with parentheses as PRAGMA table_info removes parentheses from default values.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think your condition is wrong though, it checks whether the expxression DOES NOT start with ( and DOES end with ). I think the intended condition is whether it DOES NOT start with ( and DOES NOT end with ).

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops,
I was wrong.
Sorry.

But I'm worried about some special case like CURRENT_TIMESTAMP.
If there is other special case except for CURRENT_TIMESTAMP,
!(s.starts_with('(') && s.ends_with(')')) might break behavior.

If there is no such case, I should replace that condition as you suggest.

Thank you for your comments!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the special case with CURRENT_TIMESTAMP?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought something like following will be error, but it doesn't.
(I haven't tried before)

create table a(
  id integer primary key,
  dt integer not null default (CURRENT_TIME)
);

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or maybe I thought, the following is allowed (which is not)

create table a(
  dt integer not null default unixepoch('subsec')
);

I'm not sure what I was thinking 🤔

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Thank you!

@coderabbitai
Copy link

coderabbitai bot commented Feb 25, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 447aaff and ee8ace3.

📒 Files selected for processing (1)
  • schema-engine/sql-schema-describer/src/sqlite.rs

Walkthrough

Added a new createdAt column/field with a SQLite UNIX-timestamp db-generated default in a re-introspection test, and updated SQLite schema-describer logic to wrap db-generated default expressions in parentheses across multiple column type families.

Changes

Cohort / File(s) Summary
SQLite re-introspection tests
schema-engine/sql-introspection-tests/tests/re_introspection/sqlite.rs
Added createdAt column to Post in the initial migration, added createdAt field to the input data model, and updated the expected introspected model to include @default(dbgenerated("(CAST(unixepoch('subsec') * 1000 AS INTEGER))")).
SQLite schema describer
schema-engine/sql-schema-describer/src/sqlite.rs
Added wrap_in_parentheses() helper and applied it when constructing DefaultValue::db_generated(...) for multiple column type branches (Int, BigInt, Float, Decimal, Boolean, DateTime, Binary, Uuid, Udt/unsupported and fallback paths) so PRAGMA-derived/default strings are consistently wrapped in parentheses.
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly describes the main purpose of the changes: preventing breaking of default expressions in SQLite through wrapping in parentheses.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9d6ad21 and 447aaff.

📒 Files selected for processing (2)
  • schema-engine/sql-introspection-tests/tests/re_introspection/sqlite.rs
  • schema-engine/sql-schema-describer/src/sqlite.rs

Comment on lines +16 to +19
t.add_column(
"createdAt",
types::custom("INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER))"),
);
Copy link

@coderabbitai coderabbitai bot Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Consider adding a dedicated test for the default expression fix instead of modifying an unrelated test.

This test is named multiple_changed_relation_names_due_to_mapped_models and focuses on relation renaming. Adding createdAt with a complex default expression here conflates two unrelated concerns. A standalone test specifically for the SQLite default expression parenthesization fix would:

  1. Make the regression coverage self-documenting and easy to find.
  2. Avoid coupling — if the relation-renaming test is ever removed or refactored, the default-expression coverage silently disappears.

Also, please ensure the expect! snapshot was regenerated via UPDATE_EXPECT=1 cargo test. As per coding guidelines, "When updating PSL diagnostics or error messages, always regenerate expect! snapshot tests using UPDATE_EXPECT=1 cargo test to ensure test assertions match new output."

Also applies to: 36-36, 55-55

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is valid, it might make sense to introduce a similar test that just covers this

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jacek-prisma, thank you for confirming! I'd be happy to help create a dedicated test that specifically covers the SQLite default expression parenthesization fix. This will make the regression coverage clear and independent of the relation-renaming test.

Would you like me to create a new test function that:

  1. Sets up a table with a column using a complex default expression (like the unixepoch example)
  2. Performs introspection to verify the default is correctly wrapped in parentheses
  3. Tests re-introspection to ensure the default expression remains stable

Task: Add dedicated test for SQLite default expression parenthesization

  • Create new test function named sqlite_default_expression_with_parentheses in the same file
  • Set up a simple table (e.g., "Post") with a column using a complex default expression
  • Verify initial introspection wraps the default in parentheses correctly
  • Verify re-introspection preserves the parenthesized default expression
  • Keep the test focused solely on default expression handling (no relations, no other concerns)

💡 Use @coderabbitai plan to create a plan for code changes

Comment on lines +273 to +283
/// Wraps the given string in parentheses if it is not already.
/// The result of `PRAGMA table_info` removes parentheses from default values,
/// so we need to add parentheses when generating prisma schema.
/// See <https://github.com/prisma/prisma/issues/29064>
fn wrap_in_parentheses(s: &str) -> Cow<'_, str> {
if !(s.starts_with('(') && s.ends_with(')')) {
format!("({})", s).into()
} else {
s.into()
}
}
Copy link

@coderabbitai coderabbitai bot Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Edge case: wrap_in_parentheses can be fooled by non-matching outer parentheses.

The check s.starts_with('(') && s.ends_with(')') does not verify that the first ( and last ) are actually a matched pair. For example, a default expression like (a) + (b) would pass the check and not be wrapped, producing an invalid result.

In practice this may be unlikely for SQLite defaults reaching the db_generated path, but a more robust approach would verify the outer parentheses are balanced:

🛡️ Proposed robust implementation
 fn wrap_in_parentheses(s: &str) -> Cow<'_, str> {
-    if !(s.starts_with('(') && s.ends_with(')')) {
+    if !is_wrapped_in_parentheses(s) {
         format!("({})", s).into()
     } else {
         s.into()
     }
 }
+
+/// Returns true only if the outermost characters are a matched pair of parentheses
+/// enclosing the entire expression.
+fn is_wrapped_in_parentheses(s: &str) -> bool {
+    if !(s.starts_with('(') && s.ends_with(')')) {
+        return false;
+    }
+    let mut depth = 0i32;
+    for (i, ch) in s.char_indices() {
+        match ch {
+            '(' => depth += 1,
+            ')' => {
+                depth -= 1;
+                // If depth hits 0 before the last character, the outer parens aren't wrapping the whole thing.
+                if depth == 0 && i < s.len() - 1 {
+                    return false;
+                }
+            }
+            _ => {}
+        }
+    }
+    depth == 0
+}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This appears a legitimate issue with this code

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jacek-prisma, thank you for confirming! You're right—the current implementation can be fooled by cases like (a) + (b) where the outer characters are parentheses but don't form a matched pair wrapping the entire expression.

Would you like me to implement the more robust version that properly validates balanced parentheses? I can create the fix following the approach suggested in the review comment.

Task: Fix wrap_in_parentheses edge case

  • Add a new helper function is_wrapped_in_parentheses that checks if the outer parentheses are a matched pair wrapping the entire expression
  • Update wrap_in_parentheses to use the new helper
  • Add unit tests to verify the fix handles edge cases like (a) + (b)

💡 Use @coderabbitai plan to create a plan for code changes

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants