Skip to content

Commit 81ab837

Browse files
committed
support deferrable FK clauses in schema parsing and document INITIALLY DEFERRED guidance for oversqlite
1 parent c6ca20d commit 81ab837

7 files changed

Lines changed: 282 additions & 6 deletions

File tree

docs/sync/core-concepts.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,24 @@ configuration obey the same runtime contract:
120120
- the local sync key type must be `TEXT` or `BLOB`
121121
- local sync-enabled tables must not include the reserved server column `_sync_scope_id`
122122

123+
### Foreign Key Recommendation For Sync Schemas
124+
125+
For foreign keys between sync-managed tables, prefer `DEFERRABLE INITIALLY DEFERRED`.
126+
127+
Why this is the default recommendation:
128+
129+
- oversqlite apply transactions already defer foreign-key checks while authoritative bundles are
130+
replayed
131+
- `INITIALLY DEFERRED` makes ordinary app writes behave more like sync apply, which reduces
132+
surprises
133+
- it is more tolerant of valid multi-step local transactions that temporarily create rows out of
134+
parent/child order before commit
135+
136+
Use `DEFERRABLE INITIALLY IMMEDIATE` only when you specifically want non-sync application writes
137+
to fail on foreign-key violations at statement time unless those transactions explicitly defer
138+
checks themselves. In practice, `INITIALLY IMMEDIATE` is mainly a stricter rule for your own local
139+
write paths, not a sync feature.
140+
123141
### Change Metadata
124142
Each change includes:
125143
- **What changed**: The actual data that was modified

docs/sync/getting-started.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ CREATE TABLE note (
6363
id BLOB PRIMARY KEY NOT NULL DEFAULT (randomblob(16)), -- BLOB with UUID bytes
6464
title TEXT NOT NULL,
6565
content TEXT,
66-
person_id TEXT REFERENCES person(id), -- Foreign key matches referenced table type
66+
person_id TEXT REFERENCES person(id) DEFERRABLE INITIALLY DEFERRED,
6767
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
6868
);
6969
```
@@ -102,6 +102,23 @@ CREATE TABLE users (
102102
performance
103103
- **Foreign keys**: Must match the type of the referenced primary key
104104

105+
### Recommended Foreign Key Mode For Sync-Managed Tables
106+
107+
When a sync-managed table references another sync-managed table, prefer:
108+
109+
```sql
110+
person_id TEXT REFERENCES person(id) DEFERRABLE INITIALLY DEFERRED
111+
```
112+
113+
Why:
114+
115+
- oversqlite apply already defers foreign-key checks while remote bundles are replayed
116+
- `INITIALLY DEFERRED` keeps your normal application writes closer to that behavior
117+
- local transactions that insert related rows in multiple steps are less likely to fail
118+
119+
Use `INITIALLY IMMEDIATE` only if you deliberately want ordinary non-sync writes to fail earlier
120+
when they temporarily violate FK ordering inside a transaction.
121+
105122

106123
### Custom Primary Key Column Names
107124

@@ -135,6 +152,7 @@ CREATE TABLE orders (
135152
- Use `syncKeyColumnName` annotation for custom primary key column names
136153
- The system can auto-detect primary key columns if not explicitly specified
137154
- **Foreign keys must match the type of the referenced primary key** (TEXT or BLOB)
155+
- **For sync-managed foreign keys, prefer `DEFERRABLE INITIALLY DEFERRED`**
138156
- Sync-enabled local tables must not include the reserved server column `_sync_scope_id`
139157

140158
### UUID Generation in Your App

docs/sync/sync-operations.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,15 @@ client.bootstrap(userId = userId, sourceId = deviceId).getOrThrow()
3232
Call bootstrap after authentication and before any sync operation. Client construction alone does
3333
not create sync metadata or install triggers.
3434

35+
Note on foreign keys:
36+
37+
- oversqlite apply runs inside a transaction that defers foreign-key checks while authoritative
38+
remote bundles are replayed
39+
- because of that, `DEFERRABLE INITIALLY DEFERRED` is the recommended schema default for
40+
sync-managed foreign keys
41+
- `INITIALLY IMMEDIATE` mainly changes how your own non-sync local writes behave outside apply
42+
transactions
43+
3544
## Push Pending
3645

3746
`pushPending()` freezes all currently dirty rows into one logical outbound bundle, uploads that

library-oversqlite-test/composeApp/src/commonMain/sql/RealServerGeneratedDatabase/schema/posts.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ CREATE TABLE posts (
33
id TEXT PRIMARY KEY NOT NULL,
44
title TEXT NOT NULL,
55
content TEXT NOT NULL,
6-
author_id TEXT REFERENCES users(id),
6+
author_id TEXT REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED,
77
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
88
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
99
);

sqlitenow-gradle-plugin/src/main/kotlin/dev/goquick/sqlitenow/gradle/sqlinspect/SchemaInspector.kt

Lines changed: 105 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import net.sf.jsqlparser.schema.Table
2828
import net.sf.jsqlparser.statement.create.table.CreateTable
2929
import net.sf.jsqlparser.statement.create.view.CreateView
3030

31+
private enum class NormalizationState { OUTSIDE, LINE_COMMENT, BLOCK_COMMENT, SINGLE_QUOTE, DOUBLE_QUOTE }
32+
3133
internal class SchemaInspector(
3234
schemaDirectory: File
3335
) {
@@ -138,13 +140,112 @@ internal class SchemaInspector(
138140

139141
/**
140142
* JSqlParser doesn't understand some SQLite-specific tail modifiers like "WITHOUT ROWID".
141-
* We strip such suffixes for parsing only, but we keep the original SQL for execution
143+
* It also rejects foreign-key deferrable clauses that SQLite accepts. We strip only the
144+
* parser-hostile syntax for parsing, but keep the original SQL for execution
142145
* (CreateTableStatement stores the original sql passed in SchemaInspector).
143146
*/
144147
private fun normalizeForParser(sql: String): String {
145-
// Remove trailing WITHOUT ROWID (case-insensitive), optionally before semicolon
146-
val regex = Regex("(?is)\\s+WITHOUT\\s+ROWID\\s*;?\\s*$")
147-
return sql.replace(regex, ";")
148+
val withoutRowId = sql.replace(Regex("(?is)\\s+WITHOUT\\s+ROWID\\s*;?\\s*$"), ";")
149+
return stripParserUnsupportedForeignKeyClauses(withoutRowId)
150+
}
151+
152+
private fun stripParserUnsupportedForeignKeyClauses(sql: String): String {
153+
val unsupportedClause = Regex(
154+
"(?i)\\b(?:NOT\\s+DEFERRABLE|DEFERRABLE)(?:\\s+INITIALLY\\s+(?:DEFERRED|IMMEDIATE))?\\b"
155+
)
156+
val normalized = StringBuilder(sql.length)
157+
var outsideStart = 0
158+
var state = NormalizationState.OUTSIDE
159+
var i = 0
160+
161+
fun appendNormalizedOutside(endExclusive: Int) {
162+
if (outsideStart >= endExclusive) return
163+
normalized.append(sql.substring(outsideStart, endExclusive).replace(unsupportedClause, ""))
164+
}
165+
166+
while (i < sql.length) {
167+
val c = sql[i]
168+
val c2 = sql.getOrNull(i + 1)
169+
170+
when (state) {
171+
NormalizationState.OUTSIDE -> when {
172+
c == '-' && c2 == '-' -> {
173+
appendNormalizedOutside(i)
174+
normalized.append("--")
175+
state = NormalizationState.LINE_COMMENT
176+
i += 2
177+
}
178+
c == '/' && c2 == '*' -> {
179+
appendNormalizedOutside(i)
180+
normalized.append("/*")
181+
state = NormalizationState.BLOCK_COMMENT
182+
i += 2
183+
}
184+
c == '\'' -> {
185+
appendNormalizedOutside(i)
186+
normalized.append(c)
187+
state = NormalizationState.SINGLE_QUOTE
188+
i++
189+
}
190+
c == '"' -> {
191+
appendNormalizedOutside(i)
192+
normalized.append(c)
193+
state = NormalizationState.DOUBLE_QUOTE
194+
i++
195+
}
196+
else -> i++
197+
}
198+
199+
NormalizationState.LINE_COMMENT -> {
200+
normalized.append(c)
201+
i++
202+
if (c == '\n' || c == '\r') {
203+
state = NormalizationState.OUTSIDE
204+
outsideStart = i
205+
}
206+
}
207+
208+
NormalizationState.BLOCK_COMMENT -> if (c == '*' && c2 == '/') {
209+
normalized.append("*/")
210+
i += 2
211+
state = NormalizationState.OUTSIDE
212+
outsideStart = i
213+
} else {
214+
normalized.append(c)
215+
i++
216+
}
217+
218+
NormalizationState.SINGLE_QUOTE -> if (c == '\'' && c2 == '\'') {
219+
normalized.append("''")
220+
i += 2
221+
} else {
222+
normalized.append(c)
223+
i++
224+
if (c == '\'') {
225+
state = NormalizationState.OUTSIDE
226+
outsideStart = i
227+
}
228+
}
229+
230+
NormalizationState.DOUBLE_QUOTE -> if (c == '"' && c2 == '"') {
231+
normalized.append("\"\"")
232+
i += 2
233+
} else {
234+
normalized.append(c)
235+
i++
236+
if (c == '"') {
237+
state = NormalizationState.OUTSIDE
238+
outsideStart = i
239+
}
240+
}
241+
}
242+
}
243+
244+
if (state == NormalizationState.OUTSIDE) {
245+
appendNormalizedOutside(sql.length)
246+
}
247+
248+
return normalized.toString()
148249
}
149250
}
150251

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package dev.goquick.sqlitenow.gradle
2+
3+
import java.io.File
4+
import java.sql.DriverManager
5+
import kotlin.io.path.createTempDirectory
6+
import kotlin.test.assertNotNull
7+
import kotlin.test.assertTrue
8+
import org.junit.jupiter.api.Test
9+
10+
class GenerateDatabaseFilesDeferrableForeignKeyTest {
11+
12+
@Test
13+
fun generateDatabaseFilesSupportsDeferrableForeignKeys() {
14+
val root = createTempDirectory(prefix = "deferrable-fk-").toFile()
15+
val schemaDir = File(root, "schema").apply { mkdirs() }
16+
val queryDir = File(root, "queries/posts").apply { mkdirs() }
17+
val outDir = File(root, "out").apply { mkdirs() }
18+
val schemaDatabaseFile = File(root, "schema.db")
19+
20+
File(schemaDir, "users.sql").writeText(
21+
"""
22+
CREATE TABLE users (
23+
id TEXT PRIMARY KEY NOT NULL
24+
);
25+
""".trimIndent()
26+
)
27+
File(schemaDir, "posts.sql").writeText(
28+
"""
29+
CREATE TABLE posts (
30+
id TEXT PRIMARY KEY NOT NULL,
31+
author_id TEXT REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED
32+
);
33+
""".trimIndent()
34+
)
35+
File(queryDir, "selectAll.sql").writeText(
36+
"""
37+
SELECT id, author_id FROM posts;
38+
""".trimIndent()
39+
)
40+
41+
generateDatabaseFiles(
42+
dbName = "TestDbDeferrable",
43+
sqlDir = root,
44+
packageName = "dev.test.deferrable",
45+
outDir = outDir,
46+
schemaDatabaseFile = schemaDatabaseFile,
47+
debug = false,
48+
)
49+
50+
val generatedSource = outDir.walkTopDown().firstOrNull { it.extension == "kt" }
51+
assertNotNull(generatedSource, "Expected generateDatabaseFiles to emit Kotlin sources")
52+
53+
DriverManager.getConnection("jdbc:sqlite:${schemaDatabaseFile.absolutePath}").use { conn ->
54+
val createdSql = conn.createStatement().use { stmt ->
55+
stmt.executeQuery("SELECT sql FROM sqlite_master WHERE type='table' AND name='posts'").use { rs ->
56+
assertTrue(rs.next(), "posts table should exist in the generated schema database")
57+
rs.getString("sql")
58+
}
59+
}
60+
assertTrue(createdSql.contains("DEFERRABLE INITIALLY DEFERRED"))
61+
}
62+
}
63+
}

sqlitenow-gradle-plugin/src/test/kotlin/dev/goquick/sqlitenow/gradle/SchemaInspectorTest.kt

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import org.junit.jupiter.api.BeforeEach
1111
import kotlin.test.assertTrue
1212
import kotlin.test.assertEquals
1313
import kotlin.test.assertFailsWith
14+
import kotlin.test.assertNotNull
1415

1516
class SchemaInspectorTest {
1617

@@ -209,4 +210,70 @@ class SchemaInspectorTest {
209210
}
210211
}
211212
}
213+
214+
@Test
215+
@DisplayName("Test SchemaInspector supports deferrable foreign key clauses")
216+
fun testSchemaInspectorSupportsDeferrableForeignKeys() {
217+
File(schemaDir, "01_users.sql").writeText(
218+
"""
219+
CREATE TABLE users (
220+
id TEXT PRIMARY KEY NOT NULL
221+
);
222+
""".trimIndent()
223+
)
224+
File(schemaDir, "02_posts.sql").writeText(
225+
"""
226+
CREATE TABLE posts (
227+
id TEXT PRIMARY KEY NOT NULL,
228+
author_id TEXT REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED
229+
);
230+
""".trimIndent()
231+
)
232+
File(schemaDir, "03_comments.sql").writeText(
233+
"""
234+
CREATE TABLE comments (
235+
id TEXT PRIMARY KEY NOT NULL,
236+
post_id TEXT NOT NULL,
237+
FOREIGN KEY (post_id) REFERENCES posts(id) NOT DEFERRABLE
238+
);
239+
""".trimIndent()
240+
)
241+
File(schemaDir, "04_likes.sql").writeText(
242+
"""
243+
CREATE TABLE likes (
244+
id TEXT PRIMARY KEY NOT NULL,
245+
post_id TEXT NOT NULL,
246+
FOREIGN KEY (post_id) REFERENCES posts(id) DEFERRABLE INITIALLY IMMEDIATE
247+
);
248+
""".trimIndent()
249+
)
250+
251+
val inspector = SchemaInspector(schemaDirectory = schemaDir)
252+
val createdTables = inspector.getCreateTableStatements(conn)
253+
254+
assertEquals(4, createdTables.size, "Should execute all CREATE TABLE statements with deferrable clauses")
255+
assertTrue(inspector.sqlStatements.any { it.sql.contains("DEFERRABLE INITIALLY DEFERRED") })
256+
assertTrue(inspector.sqlStatements.any { it.sql.contains("NOT DEFERRABLE") })
257+
assertTrue(inspector.sqlStatements.any { it.sql.contains("DEFERRABLE INITIALLY IMMEDIATE") })
258+
259+
fun tableSql(name: String): String {
260+
return conn.createStatement().use { stmt ->
261+
stmt.executeQuery("SELECT sql FROM sqlite_master WHERE type='table' AND name='$name'").use { rs ->
262+
assertTrue(rs.next(), "$name table should exist")
263+
rs.getString("sql")
264+
}
265+
}
266+
}
267+
268+
val postsSql = tableSql("posts")
269+
val commentsSql = tableSql("comments")
270+
val likesSql = tableSql("likes")
271+
272+
assertNotNull(postsSql)
273+
assertNotNull(commentsSql)
274+
assertNotNull(likesSql)
275+
assertTrue(postsSql.contains("DEFERRABLE INITIALLY DEFERRED"))
276+
assertTrue(commentsSql.contains("NOT DEFERRABLE"))
277+
assertTrue(likesSql.contains("DEFERRABLE INITIALLY IMMEDIATE"))
278+
}
212279
}

0 commit comments

Comments
 (0)