diff --git a/.github/scripts/test_manual_release_workflow.py b/.github/scripts/test_manual_release_workflow.py index 6adda138..d39dcfb2 100644 --- a/.github/scripts/test_manual_release_workflow.py +++ b/.github/scripts/test_manual_release_workflow.py @@ -273,6 +273,10 @@ def test_patch_fan_allows_only_the_supported_schema_transitions(self): '[ "$PREV_SCHEMA" = 2 ] && [ "$THIS_SCHEMA" = 3 ]', '[ "$PREV_SCHEMA" = 2 ] && [ "$THIS_SCHEMA" = 4 ]', '[ "$PREV_SCHEMA" = 3 ] && [ "$THIS_SCHEMA" = 4 ]', + '[ "$PREV_SCHEMA" = 1 ] && [ "$THIS_SCHEMA" = 5 ]', + '[ "$PREV_SCHEMA" = 2 ] && [ "$THIS_SCHEMA" = 5 ]', + '[ "$PREV_SCHEMA" = 3 ] && [ "$THIS_SCHEMA" = 5 ]', + '[ "$PREV_SCHEMA" = 4 ] && [ "$THIS_SCHEMA" = 5 ]', ) for transition in supported: self.assertIn(transition, patch_fan) diff --git a/.github/workflows/contract.yml b/.github/workflows/contract.yml index 1942c96e..6ead9b77 100644 --- a/.github/workflows/contract.yml +++ b/.github/workflows/contract.yml @@ -18,10 +18,38 @@ jobs: steps: - uses: actions/checkout@v4 + # A coordinated contract change spans two repositories. On a PR, prefer + # a linked open companion PR by the same author; on push (or when none is + # linked) keep validating against the companion's default branch. + - name: Resolve linked updater PR + id: updater + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + repo=Otzaria/otzaria_library_updater + if [ "$GITHUB_EVENT_NAME" = pull_request ]; then + marker="$GITHUB_REPOSITORY#${{ github.event.pull_request.number }}" + author='${{ github.event.pull_request.user.login }}' + number=$(gh api -X GET search/issues \ + -f q="repo:$repo is:pr is:open author:$author \"$marker\"" \ + --jq '.items[0].number // empty') + if [ -n "$number" ]; then + companion_repo=$(gh api "repos/$repo/pulls/$number" --jq .head.repo.full_name) + companion_ref=$(gh api "repos/$repo/pulls/$number" --jq .head.ref) + echo "repository=$companion_repo" >> "$GITHUB_OUTPUT" + echo "ref=$companion_ref" >> "$GITHUB_OUTPUT" + exit 0 + fi + fi + echo "repository=$repo" >> "$GITHUB_OUTPUT" + echo "ref=main" >> "$GITHUB_OUTPUT" + - name: Checkout Otzaria/otzaria_library_updater (צד ה-Dart) uses: actions/checkout@v4 with: - repository: Otzaria/otzaria_library_updater + repository: ${{ steps.updater.outputs.repository }} + ref: ${{ steps.updater.outputs.ref }} path: seforim-library-updater - name: Checkout Otzaria/SefariaExport visibility contract diff --git a/.github/workflows/manual-generate-release.yml b/.github/workflows/manual-generate-release.yml index b7d47c50..462ed9a2 100644 --- a/.github/workflows/manual-generate-release.yml +++ b/.github/workflows/manual-generate-release.yml @@ -1495,7 +1495,11 @@ jobs: if { [ "$PREV_SCHEMA" = 2 ] && [ "$THIS_SCHEMA" = 3 ]; } || \ { [ "$PREV_SCHEMA" = 1 ] && [ "$THIS_SCHEMA" = 4 ]; } || \ { [ "$PREV_SCHEMA" = 2 ] && [ "$THIS_SCHEMA" = 4 ]; } || \ - { [ "$PREV_SCHEMA" = 3 ] && [ "$THIS_SCHEMA" = 4 ]; }; then + { [ "$PREV_SCHEMA" = 3 ] && [ "$THIS_SCHEMA" = 4 ]; } || \ + { [ "$PREV_SCHEMA" = 1 ] && [ "$THIS_SCHEMA" = 5 ]; } || \ + { [ "$PREV_SCHEMA" = 2 ] && [ "$THIS_SCHEMA" = 5 ]; } || \ + { [ "$PREV_SCHEMA" = 3 ] && [ "$THIS_SCHEMA" = 5 ]; } || \ + { [ "$PREV_SCHEMA" = 4 ] && [ "$THIS_SCHEMA" = 5 ]; }; then echo "schema $PREV_SCHEMA → $THIS_SCHEMA — producing the supported cross-schema delta" else echo "schema $PREV_SCHEMA → $THIS_SCHEMA is unsupported — skip anchor" diff --git a/dao/src/commonMain/sqldelight/io/github/kdroidfilter/seforimlibrary/db/Database.sq b/dao/src/commonMain/sqldelight/io/github/kdroidfilter/seforimlibrary/db/Database.sq index d5c3e7f7..a0842e46 100644 --- a/dao/src/commonMain/sqldelight/io/github/kdroidfilter/seforimlibrary/db/Database.sq +++ b/dao/src/commonMain/sqldelight/io/github/kdroidfilter/seforimlibrary/db/Database.sq @@ -306,7 +306,9 @@ CREATE TABLE IF NOT EXISTS line_ref ( -- Dibbur-hamatchil index: the normalised opening words of each commentary -- comment (the bold or dash-separated prefix of the line's content), keyed -- as text so the client can prefix-match while the user types, and render --- them as virtual sub-headings under the book's TOC. Populated by the +-- them as virtual sub-headings under the book's TOC. dhDisplay is the same +-- dibbur as printed (points and quote marks kept) for those sub-headings — +-- the normalised key is unfit to show. Populated by the -- buildLineDhIndex stage. Existing client DBs receive the table and its -- contents through the schema-4 delta migration registered by the patch -- pipeline; fresh DBs include it directly. @@ -314,6 +316,7 @@ CREATE TABLE IF NOT EXISTS line_dh ( bookId INTEGER NOT NULL, dhText TEXT NOT NULL, lineIndex INTEGER NOT NULL, + dhDisplay TEXT NOT NULL, PRIMARY KEY (bookId, dhText, lineIndex) ) WITHOUT ROWID; diff --git a/dao/src/commonMain/sqldelight/io/github/kdroidfilter/seforimlibrary/db/LineDhQueries.sq b/dao/src/commonMain/sqldelight/io/github/kdroidfilter/seforimlibrary/db/LineDhQueries.sq index 3e126773..a62672f3 100644 --- a/dao/src/commonMain/sqldelight/io/github/kdroidfilter/seforimlibrary/db/LineDhQueries.sq +++ b/dao/src/commonMain/sqldelight/io/github/kdroidfilter/seforimlibrary/db/LineDhQueries.sq @@ -1,20 +1,20 @@ -- Queries for the dibbur-hamatchil index selectByBook: -SELECT dhText, lineIndex FROM line_dh WHERE bookId = ? ORDER BY lineIndex; +SELECT dhText, lineIndex, dhDisplay FROM line_dh WHERE bookId = ? ORDER BY lineIndex; -- Prefix match over the PK btree: the caller passes the normalised prefix -- and the same prefix with its last character incremented (exclusive bound), -- which keeps the scan index-only regardless of LIKE collation rules. selectByBookAndPrefixRange: -SELECT dhText, lineIndex FROM line_dh +SELECT dhText, lineIndex, dhDisplay FROM line_dh WHERE bookId = ? AND dhText >= :prefix AND dhText < :prefixEnd ORDER BY dhText, lineIndex LIMIT :limit; insert: -INSERT OR IGNORE INTO line_dh (bookId, dhText, lineIndex) -VALUES (?, ?, ?); +INSERT OR IGNORE INTO line_dh (bookId, dhText, lineIndex, dhDisplay) +VALUES (?, ?, ?, ?); deleteByBookId: DELETE FROM line_dh WHERE bookId = ?; diff --git a/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/dh/BuildLineDhIndexCli.kt b/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/dh/BuildLineDhIndexCli.kt index 3f2ddf38..fa6a4b44 100644 --- a/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/dh/BuildLineDhIndexCli.kt +++ b/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/dh/BuildLineDhIndexCli.kt @@ -10,7 +10,7 @@ import java.sql.DriverManager /** * Fills `line_dh` — the (bookId, dhText) -> lineIndex dibbur-hamatchil index * the Otzaria client uses to search commentaries by their opening words and - * to render them as virtual sub-headings. + * to render them as virtual sub-headings (dhDisplay carries the printed form). * * Runs as a late DB-writing stage (`:generator-common:buildLineDhIndex`), * after every book-writing stage, and is idempotent: the table is rebuilt @@ -60,6 +60,7 @@ internal fun rebuildLineDhIndex(conn: Connection, logger: Logger): LineDhIndexRe bookId INTEGER NOT NULL, dhText TEXT NOT NULL, lineIndex INTEGER NOT NULL, + dhDisplay TEXT NOT NULL, PRIMARY KEY (bookId, dhText, lineIndex) ) WITHOUT ROWID """.trimIndent(), @@ -108,15 +109,15 @@ internal fun indexAllBooks(conn: Connection, logger: Logger): LineDhIndexReport } conn.prepareStatement( - "INSERT OR IGNORE INTO line_dh (bookId, dhText, lineIndex) VALUES (?, ?, ?)", + "INSERT OR IGNORE INTO line_dh (bookId, dhText, lineIndex, dhDisplay) VALUES (?, ?, ?, ?)", ).use { insert -> conn.prepareStatement( "SELECT lineIndex, content FROM line WHERE bookId = ? ORDER BY lineIndex", ).use { selectLines -> for (bookId in bookIds) { var contentLines = 0 - val bold = ArrayList>() - val dash = ArrayList>() + val bold = ArrayList>() + val dash = ArrayList>() selectLines.setLong(1, bookId) selectLines.executeQuery().use { rs -> @@ -141,10 +142,11 @@ internal fun indexAllBooks(conn: Connection, logger: Logger): LineDhIndexReport continue } - for ((lineIndex, dhText) in winner) { + for ((lineIndex, dh) in winner) { insert.setLong(1, bookId) - insert.setString(2, dhText) + insert.setString(2, dh.key) insert.setLong(3, lineIndex) + insert.setString(4, dh.display) insert.addBatch() } insert.executeBatch() diff --git a/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/dh/DhExtractor.kt b/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/dh/DhExtractor.kt index c8482229..b3e4be97 100644 --- a/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/dh/DhExtractor.kt +++ b/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/dh/DhExtractor.kt @@ -24,6 +24,14 @@ object DhExtractor { enum class Format { BOLD, DASH } + /** + * One extracted dibbur: [key] is the [DhKey] form the index is searched + * by; [display] is the dibbur as printed — points and quote marks kept, + * whitespace collapsed, edge punctuation trimmed — for showing it as a + * sub-heading. + */ + data class Dh(val key: String, val display: String) + /** Longest raw dibbur accepted, in characters (real ones median ~11). */ private const val MAX_DH_LENGTH = 100 @@ -66,9 +74,13 @@ object DhExtractor { ) private val POINTS = Regex("[֑-ׇ]") + private val WHITESPACE = Regex("""\s+""") + + /** Same edge trim as DhKey, so key and display agree on where the dibbur ends. */ + private const val EDGE_PUNCTUATION = ".,:;?!()[]" - /** Extracts and normalises the dibbur of [line] in [format], or `null`. */ - fun extract(line: String, format: Format): String? = when (format) { + /** Extracts the dibbur of [line] in [format], or `null`. */ + fun extract(line: String, format: Format): Dh? = when (format) { Format.BOLD -> extractBold(line) Format.DASH -> extractDash(line) } @@ -76,7 +88,7 @@ object DhExtractor { /** `true` when [line] is a `

`–`

` heading (never carries a dibbur). */ fun isHeadingLine(line: String): Boolean = HEADING_LINE.containsMatchIn(line) - private fun extractBold(line: String): String? { + private fun extractBold(line: String): Dh? { if (isHeadingLine(line)) return null val m = BOLD_PREFIX.find(line) ?: return null val rest = TAG.replace(m.groupValues[2], "").trim() @@ -84,7 +96,7 @@ object DhExtractor { return accept(m.groupValues[1]) } - private fun extractDash(line: String): String? { + private fun extractDash(line: String): Dh? { if (isHeadingLine(line)) return null val m = SPACED_DASH.find(line) ?: return null var dh = line.substring(0, m.range.first) @@ -99,7 +111,7 @@ object DhExtractor { return accept(dh) } - private fun accept(rawDh: String): String? { + private fun accept(rawDh: String): Dh? { // Match DhKey's edge trimming while deliberately preserving quote // marks: תוד"ה is a locator, while תודה is a genuine Hebrew word. val marker = POINTS.replace(rawDh, "") @@ -111,8 +123,10 @@ object DhExtractor { .replace('‘', '\'') .replace("''", "\"") .trim() - .trim { it in ".,:;?!()[]" || it == ' ' } + .trim { it in EDGE_PUNCTUATION || it == ' ' } if (marker in STOP_MARKERS) return null - return DhKey.normalize(rawDh) + val key = DhKey.normalize(rawDh) ?: return null + val display = WHITESPACE.replace(rawDh, " ").trim { it in EDGE_PUNCTUATION || it == ' ' } + return Dh(key, display) } } diff --git a/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/LogicalContentHasher.kt b/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/LogicalContentHasher.kt index 22abe39d..07502b82 100644 --- a/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/LogicalContentHasher.kt +++ b/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/LogicalContentHasher.kt @@ -126,14 +126,18 @@ class LogicalContentHasher( add(indexOf("line_ref") + 1, "line_dh") } + /** Schema 5 changes line_dh columns, not the set or order of tables. */ + val TABLES_SCHEMA_5: List = TABLES_SCHEMA_4 + /** Current-schema default for build-time diagnostics and current DB tests. */ - val DEFAULT_TABLES: List = TABLES_SCHEMA_4 + val DEFAULT_TABLES: List = TABLES_SCHEMA_5 fun tablesForSchemaVersion(schemaVersion: Int): List = when (schemaVersion) { 1 -> TABLES_SCHEMA_1 2 -> TABLES_SCHEMA_2 3 -> TABLES_SCHEMA_3 4 -> TABLES_SCHEMA_4 + 5 -> TABLES_SCHEMA_5 else -> error("Unsupported logical-hash schema version $schemaVersion") } diff --git a/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchDbProducer.kt b/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchDbProducer.kt index 2aa671b6..2c267ee6 100644 --- a/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchDbProducer.kt +++ b/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchDbProducer.kt @@ -58,8 +58,8 @@ class PatchDbProducer( fromVersion: Int, toVersion: Int, migrations: List> = emptyList(), - fromSchemaVersion: Int = PatchDbSchema.CURRENT_VERSION, - toSchemaVersion: Int = PatchDbSchema.CURRENT_VERSION, + fromSchemaVersion: Int = CURRENT_DB_SCHEMA_VERSION, + toSchemaVersion: Int = CURRENT_DB_SCHEMA_VERSION, ): Output { require(fromSchemaVersion <= toSchemaVersion) { "Schema downgrade $fromSchemaVersion -> $toSchemaVersion is not supported" diff --git a/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchTables.kt b/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchTables.kt index b9f68d0b..51c30923 100644 --- a/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchTables.kt +++ b/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchTables.kt @@ -18,6 +18,9 @@ internal data class PatchTable( val updatable: Boolean, ) +/** Current `seforim.db` schema produced by this revision. */ +internal const val CURRENT_DB_SCHEMA_VERSION: Int = 5 + /** * Canonical table order — parents (referenced) come before children * (referencing) for upserts. The applier runs upserts in this order and @@ -62,8 +65,8 @@ internal val PATCH_TABLES_IN_FK_ORDER: List = listOf( // Schema 4. Canonical line-reference index — pure key table (PK == all // columns), so there is nothing to update on conflict. PatchTable("line_ref", listOf("bookId", "refKeyHash", "lineIndex"), updatable = false), - // Schema 4. Dibbur-hamatchil index — pure key table, same shape. - PatchTable("line_dh", listOf("bookId", "dhText", "lineIndex"), updatable = false), + // Schema 5. Dibbur-hamatchil index — dhDisplay rides along the key. + PatchTable("line_dh", listOf("bookId", "dhText", "lineIndex"), updatable = true), // Links. PatchTable("link", listOf("id"), updatable = true), @@ -91,9 +94,15 @@ internal val PATCH_TABLES_IN_FK_ORDER: List = listOf( PatchTable("schema_meta", listOf("key"), updatable = true), ) +/** Schema-4 contract shipped in v26, before line_dh gained dhDisplay. */ +internal val PATCH_TABLES_SCHEMA_4: List = + PATCH_TABLES_IN_FK_ORDER.map { table -> + if (table.name == "line_dh") table.copy(updatable = false) else table + } + /** Schema-3 contract retained for updater compatibility tests. */ internal val PATCH_TABLES_SCHEMA_3: List = - PATCH_TABLES_IN_FK_ORDER.filterNot { it.name in setOf("line_ref", "line_dh") } + PATCH_TABLES_SCHEMA_4.filterNot { it.name in setOf("line_ref", "line_dh") } /** Schema-2 contract retained for updater compatibility tests. */ internal val PATCH_TABLES_SCHEMA_2: List = @@ -108,6 +117,7 @@ internal fun patchTablesForSchemaVersion(schemaVersion: Int): List = 1 -> PATCH_TABLES_SCHEMA_1 2 -> PATCH_TABLES_SCHEMA_2 3 -> PATCH_TABLES_SCHEMA_3 - 4 -> PATCH_TABLES_IN_FK_ORDER + 4 -> PATCH_TABLES_SCHEMA_4 + 5 -> PATCH_TABLES_IN_FK_ORDER else -> error("Unsupported patch-table schema version $schemaVersion") } diff --git a/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/StampSchemaVersionCli.kt b/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/StampSchemaVersionCli.kt index 7e83e5d4..31c83d58 100644 --- a/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/StampSchemaVersionCli.kt +++ b/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/StampSchemaVersionCli.kt @@ -18,7 +18,7 @@ import java.sql.DriverManager * - `dbVersion` integer release version (matches release_meta.json * `latestVersion` and `deltas[].toVersion`) * - `dbSchemaVersion` integer SQLDelight schema version (optional, defaults - * to [PatchDbSchema.CURRENT_VERSION]); matches the + * to [CURRENT_DB_SCHEMA_VERSION]); matches the * manifest's `toSchemaVersion` * * Idempotent: re-running with the same values is a no-op @@ -32,7 +32,7 @@ fun main() { val dbVersion = System.getProperty("dbVersion")?.toIntOrNull() ?: error("-PdbVersion= missing or not an integer") val dbSchemaVersion = System.getProperty("dbSchemaVersion")?.toIntOrNull() - ?: PatchDbSchema.CURRENT_VERSION + ?: CURRENT_DB_SCHEMA_VERSION val path = Paths.get(dbPath) require(Files.isRegularFile(path)) { "Database file not found: $dbPath" } @@ -48,13 +48,13 @@ fun main() { /** Writes release/schema metadata only after the DB satisfies that schema's table contract. */ internal fun stampSchemaVersion(conn: Connection, dbVersion: Int, dbSchemaVersion: Int) { require(dbVersion >= 1) { "dbVersion=$dbVersion must be positive" } - require(dbSchemaVersion in 1..PatchDbSchema.CURRENT_VERSION) { - "dbSchemaVersion=$dbSchemaVersion is outside the supported range 1..${PatchDbSchema.CURRENT_VERSION}" + require(dbSchemaVersion in 1..CURRENT_DB_SCHEMA_VERSION) { + "dbSchemaVersion=$dbSchemaVersion is outside the supported range 1..$CURRENT_DB_SCHEMA_VERSION" } check(conn.autoCommit) { "stampSchemaVersion requires an unowned JDBC connection" } val requiredTables = when (dbSchemaVersion) { - 4 -> setOf("line_ref", "line_dh") + 4, 5 -> setOf("line_ref", "line_dh") else -> emptySet() } val existingTables = if (requiredTables.isEmpty()) { @@ -74,6 +74,19 @@ internal fun stampSchemaVersion(conn: Connection, dbVersion: Int, dbSchemaVersio missingTables.sorted().joinToString() } + if (dbSchemaVersion >= 5) { + val dhDisplay = PatchDbSchema.readTableInfo(conn, "main", "line_dh") + .find { it.name == "dhDisplay" } + require( + dhDisplay != null && + dhDisplay.type.equals("TEXT", ignoreCase = true) && + dhDisplay.notNull, + ) { + "Cannot stamp DB as schema $dbSchemaVersion; " + + "line_dh.dhDisplay must exist as TEXT NOT NULL" + } + } + conn.autoCommit = false try { conn.prepareStatement( diff --git a/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/dh/BuildLineDhIndexCliTest.kt b/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/dh/BuildLineDhIndexCliTest.kt index 2f2c05f9..fa1f6ba7 100644 --- a/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/dh/BuildLineDhIndexCliTest.kt +++ b/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/dh/BuildLineDhIndexCliTest.kt @@ -7,6 +7,7 @@ import java.sql.SQLException import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertTrue class BuildLineDhIndexCliTest { @@ -24,7 +25,8 @@ class BuildLineDhIndexCliTest { st.execute("CREATE TABLE line (bookId INTEGER NOT NULL, lineIndex INTEGER NOT NULL, content TEXT NOT NULL)") st.execute( "CREATE TABLE line_dh (bookId INTEGER NOT NULL, dhText TEXT NOT NULL, " + - "lineIndex INTEGER NOT NULL, PRIMARY KEY (bookId, dhText, lineIndex)) WITHOUT ROWID", + "lineIndex INTEGER NOT NULL, dhDisplay TEXT NOT NULL, " + + "PRIMARY KEY (bookId, dhText, lineIndex)) WITHOUT ROWID", ) } block(conn) @@ -76,15 +78,22 @@ class BuildLineDhIndexCliTest { } @Test - fun `a bold-dominant book is indexed in bold format`() { + fun `a bold-dominant book is indexed in bold format, display alongside the key`() { withDb { conn -> conn.createStatement().use { it.execute("INSERT INTO book (id, title) VALUES (2, 'רש\"י על בראשית')") } - insertLines(conn, 2, List(10) { "דיבור $it. פירוש כלשהו" }) + insertLines(conn, 2, List(10) { "דִּבּוּר $it. פירוש כלשהו" }) val report = indexAllBooks(conn, Logger.withTag("test")) assertEquals(1, report.boldBooks) assertEquals(10, report.indexed) + conn.createStatement().use { st -> + st.executeQuery("SELECT dhText, dhDisplay FROM line_dh WHERE lineIndex = 3").use { rs -> + assertTrue(rs.next()) + assertEquals("דבור 3", rs.getString(1)) + assertEquals("דִּבּוּר 3", rs.getString(2)) + } + } } } @@ -155,7 +164,7 @@ class BuildLineDhIndexCliTest { withDb { conn -> conn.createStatement().use { it.execute("INSERT INTO book (id, title) VALUES (5, 'ספר')") } insertLines(conn, 5, List(10) { "דיבור $it – פירוש" }) - conn.createStatement().use { it.execute("INSERT INTO line_dh VALUES (99, 'ישן', 99)") } + conn.createStatement().use { it.execute("INSERT INTO line_dh VALUES (99, 'ישן', 99, 'ישן')") } rebuildLineDhIndex(conn, Logger.withTag("test")) @@ -172,7 +181,7 @@ class BuildLineDhIndexCliTest { conn.createStatement().use { st -> st.execute("INSERT INTO book (id, title) VALUES (6, 'ספר')") st.execute("INSERT INTO line VALUES (6, 0, 'דיבור. פירוש')") - st.execute("INSERT INTO line_dh VALUES (99, 'ישן', 99)") + st.execute("INSERT INTO line_dh VALUES (99, 'ישן', 99, 'ישן')") st.execute( "CREATE TRIGGER reject_line_dh BEFORE INSERT ON line_dh " + "BEGIN SELECT RAISE(ABORT, 'forced failure'); END", diff --git a/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/dh/DhExtractorTest.kt b/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/dh/DhExtractorTest.kt index 526ae23f..7c4c2ef5 100644 --- a/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/dh/DhExtractorTest.kt +++ b/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/dh/DhExtractorTest.kt @@ -8,13 +8,15 @@ import kotlin.test.assertNull /** Shapes below are verbatim from a real seforim.db unless noted. */ class DhExtractorTest { + private fun key(line: String, format: Format): String? = DhExtractor.extract(line, format)?.key + // ── DASH format (Sefaria Talmud commentaries) ────────────────────────── @Test fun `dash-separated dibbur is extracted and normalised`() { assertEquals( "עד סוף האשמורה הראשונה", - DhExtractor.extract( + key( "עד סוף האשמורה הראשונה – שליש הלילה כדמפרש בגמרא", Format.DASH, ), @@ -25,7 +27,7 @@ class DhExtractorTest { fun `plain hyphen works as separator too`() { assertEquals( "אי הכי סיפא דקתני שחרית ברישא", - DhExtractor.extract( + key( "אי הכי סיפא דקתני שחרית ברישא - אי אמרת בשלמא דסמיך אקרא", Format.DASH, ), @@ -38,7 +40,7 @@ class DhExtractorTest { // the naive dash cut would swallow the whole quoted mishnah. assertEquals( "מאימתי קורין את שמע בערבין", - DhExtractor.extract( + key( "מאימתי קורין את שמע בערבין. משעה שהכהנים נכנסים לאכול בתרומתן – כהנים שנטמאו וטבלו", Format.DASH, ), @@ -47,23 +49,23 @@ class DhExtractorTest { @Test fun `maqaf does not separate a dibbur`() { - assertNull(DhExtractor.extract("בית־השלחין שדה שצריך להשקותה", Format.DASH)) + assertNull(key("בית־השלחין שדה שצריך להשקותה", Format.DASH)) } @Test fun `a line without a spaced dash yields nothing`() { - assertNull(DhExtractor.extract("שורה רגילה בלי מפריד כלל", Format.DASH)) + assertNull(key("שורה רגילה בלי מפריד כלל", Format.DASH)) } @Test fun `a dash with nothing after it yields nothing`() { - assertNull(DhExtractor.extract("עד סוף האשמורה - ", Format.DASH)) + assertNull(key("עד סוף האשמורה - ", Format.DASH)) } @Test fun `an implausibly long dash prefix without a sentence break yields nothing`() { val prefix = "מילים ".repeat(30).trim() - assertNull(DhExtractor.extract("$prefix - פירוש", Format.DASH)) + assertNull(key("$prefix - פירוש", Format.DASH)) } // ── BOLD format (Rashi on Tanakh, Mishnah commentaries) ──────────────── @@ -72,7 +74,7 @@ class DhExtractorTest { fun `bold dibbur is extracted, nikud stripped`() { assertEquals( "בראשית", - DhExtractor.extract( + key( "בְּרֵאשִׁית. אָמַר רַבִּי יִצְחָק לֹא הָיָה צָרִיךְ", Format.BOLD, ), @@ -81,22 +83,22 @@ class DhExtractorTest { @Test fun `whole-line bold is a decorated heading, not a dibbur`() { - assertNull(DhExtractor.extract("הדרן עלך מאימתי", Format.BOLD)) + assertNull(key("הדרן עלך מאימתי", Format.BOLD)) } @Test fun `structural markers are not dibburim`() { - assertNull(DhExtractor.extract("מתני' ביצה שנולדה ביום טוב", Format.BOLD)) - assertNull(DhExtractor.extract("גמרא במאי אוקימתא", Format.BOLD)) - assertNull(DhExtractor.extract("(גמרא) במאי אוקימתא", Format.BOLD)) - assertNull(DhExtractor.extract("גמרא! במאי אוקימתא", Format.BOLD)) - assertNull(DhExtractor.extract("שם ד\"ה הורו, עד עפ\"י ב\"ד", Format.BOLD)) - assertNull(DhExtractor.extract("תוד\"ה חייב, בהקפת הראש", Format.BOLD)) - assertNull(DhExtractor.extract("ד״ה הורו, עד עפ״י ב״ד", Format.BOLD)) - assertNull(DhExtractor.extract("בד״ה הורו, עד עפ״י ב״ד", Format.BOLD)) - assertNull(DhExtractor.extract("תוד״ה חייב, בהקפת הראש", Format.BOLD)) - assertNull(DhExtractor.extract("בד''ה אתנו ב''ד", Format.BOLD)) - assertNull(DhExtractor.extract("רשי' פירש כאן", Format.BOLD)) + assertNull(key("מתני' ביצה שנולדה ביום טוב", Format.BOLD)) + assertNull(key("גמרא במאי אוקימתא", Format.BOLD)) + assertNull(key("(גמרא) במאי אוקימתא", Format.BOLD)) + assertNull(key("גמרא! במאי אוקימתא", Format.BOLD)) + assertNull(key("שם ד\"ה הורו, עד עפ\"י ב\"ד", Format.BOLD)) + assertNull(key("תוד\"ה חייב, בהקפת הראש", Format.BOLD)) + assertNull(key("ד״ה הורו, עד עפ״י ב״ד", Format.BOLD)) + assertNull(key("בד״ה הורו, עד עפ״י ב״ד", Format.BOLD)) + assertNull(key("תוד״ה חייב, בהקפת הראש", Format.BOLD)) + assertNull(key("בד''ה אתנו ב''ד", Format.BOLD)) + assertNull(key("רשי' פירש כאן", Format.BOLD)) } @Test @@ -104,7 +106,7 @@ class DhExtractorTest { // תוד"ה is a locator; תודה is a real dibbur (the korban). assertEquals( "תודה", - DhExtractor.extract("תודה. הבא תודה על חטאתו", Format.BOLD), + key("תודה. הבא תודה על חטאתו", Format.BOLD), ) } @@ -113,7 +115,7 @@ class DhExtractorTest { // גליון הש"ס: the bold locator IS the searchable dibbur. assertEquals( "תוס דה חייב וכו בהקפת הראש חייב אף במספרים", - DhExtractor.extract( + key( "תוס' ד\"ה חייב וכו' בהקפת הראש חייב אף במספרים. לפ\"ז נראה דגם במלקט חייב", Format.BOLD, ), @@ -122,16 +124,41 @@ class DhExtractorTest { @Test fun `bold mid-line is not a dibbur`() { - assertNull(DhExtractor.extract("עיין רע\"ב דאזיל בשטת הר\"ש. וקשיא לי ביה", Format.BOLD)) + assertNull(key("עיין רע\"ב דאזיל בשטת הר\"ש. וקשיא לי ביה", Format.BOLD)) } // ── shared guards ─────────────────────────────────────────────────────── @Test fun `heading lines never carry a dibbur`() { - assertNull(DhExtractor.extract("

דף ב.

", Format.DASH)) - assertNull(DhExtractor.extract("

דף ב.

", Format.BOLD)) + assertNull(key("

דף ב.

", Format.DASH)) + assertNull(key("

דף ב.

", Format.BOLD)) // BOM-prefixed heading, as emitted by some source files. - assertNull(DhExtractor.extract("

רש\"י על ברכות

", Format.BOLD)) + assertNull(key("

רש\"י על ברכות

", Format.BOLD)) + } + + // ── display form ─────────────────────────────────────────────────────── + + @Test + fun `display keeps points and quote marks, drops the closing period`() { + assertEquals( + DhExtractor.Dh(key = "בראשית", display = "בְּרֵאשִׁית"), + DhExtractor.extract( + "בְּרֵאשִׁית. אָמַר רַבִּי יִצְחָק לֹא הָיָה צָרִיךְ", + Format.BOLD, + ), + ) + assertEquals( + DhExtractor.Dh(key = "אר וכו", display = "א\"ר וכו'"), + DhExtractor.extract("א\"ר וכו' – פירוש הדברים", Format.DASH), + ) + } + + @Test + fun `display collapses inner whitespace like the key does`() { + assertEquals( + "עד סוף האשמורה", + DhExtractor.extract("עד סוף\tהאשמורה – שליש הלילה", Format.DASH)?.display, + ) } } diff --git a/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchColumnAdditionTest.kt b/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchColumnAdditionTest.kt index 6fa1f769..c11afca1 100644 --- a/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchColumnAdditionTest.kt +++ b/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchColumnAdditionTest.kt @@ -166,6 +166,58 @@ class PatchColumnAdditionTest { } } + @Test + fun `v26 to v27 adds dhDisplay and ships a full line_dh snapshot`() { + val prev = newDbPath("prev.db") + val next = newDbPath("next.db") + val patch = newDbPath("patch.db") + + buildDb(prev) { st -> + st.executeUpdate( + "CREATE TABLE line_dh (bookId INTEGER NOT NULL, dhText TEXT NOT NULL, " + + "lineIndex INTEGER NOT NULL, PRIMARY KEY (bookId, dhText, lineIndex)) WITHOUT ROWID", + ) + st.executeUpdate("INSERT INTO line_dh VALUES (1, 'מאימתי קורין', 0), (1, 'משעה', 1)") + } + buildDb(next) { st -> + st.executeUpdate( + "CREATE TABLE line_dh (bookId INTEGER NOT NULL, dhText TEXT NOT NULL, " + + "lineIndex INTEGER NOT NULL, dhDisplay TEXT NOT NULL, " + + "PRIMARY KEY (bookId, dhText, lineIndex)) WITHOUT ROWID", + ) + st.executeUpdate( + "INSERT INTO line_dh VALUES " + + "(1, 'מאימתי קורין', 0, 'מאימתי קורין'), (1, 'משעה', 1, 'משעה שהכהנים')", + ) + } + + val produced = PatchDbProducer().produce( + prev, + next, + patch, + fromVersion = 26, + toVersion = 27, + fromSchemaVersion = 4, + toSchemaVersion = 5, + ) + + assertEquals( + listOf("""ALTER TABLE "line_dh" ADD COLUMN "dhDisplay" TEXT NOT NULL DEFAULT ''"""), + migrationsOf(patch), + ) + assertEquals(2, produced.upsertCounts.getValue("line_dh")) + assertAppliesTo(prev, next, patch) { conn -> + conn.createStatement().use { st -> + st.executeQuery("SELECT dhDisplay FROM line_dh ORDER BY lineIndex").use { rs -> + assertTrue(rs.next()) + assertEquals("מאימתי קורין", rs.getString(1)) + assertTrue(rs.next()) + assertEquals("משעה שהכהנים", rs.getString(1)) + } + } + } + } + @Test fun `a CURRENT_TIMESTAMP default is not constant so it is synthesised away`() { val prev = newDbPath("prev.db") diff --git a/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchMultiSchemaPromotionTest.kt b/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchMultiSchemaPromotionTest.kt index 8c2d2c73..d1df9c10 100644 --- a/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchMultiSchemaPromotionTest.kt +++ b/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchMultiSchemaPromotionTest.kt @@ -75,6 +75,51 @@ class PatchMultiSchemaPromotionTest { } } + @Test + fun `schema 1 through 3 clients can jump directly to schema 5`() { + for (fromSchema in 1..3) { + val prev = path("prev-v$fromSchema-to-v5.db") + val next = path("next-v$fromSchema-to-v5.db") + val patch = path("v$fromSchema-v5-patch.db") + val target = path("target-v$fromSchema-to-v5.db") + + buildDb(prev, schemaVersion = fromSchema, stalePromotedTables = true) + buildDb(next, schemaVersion = 5, stalePromotedTables = false) + + val produced = PatchDbProducer().produce( + prev, + next, + patch, + fromVersion = 20 + fromSchema, + toVersion = 27, + fromSchemaVersion = fromSchema, + toSchemaVersion = 5, + ) + + val promoted = when (fromSchema) { + 1 -> listOf("book_base_text", "line_ref", "line_dh", "link_suppressed_side") + 2 -> listOf("line_ref", "line_dh", "link_suppressed_side") + else -> listOf("line_ref", "line_dh") + } + for (table in promoted) { + assertEquals(1, produced.upsertCounts.getValue(table), "full snapshot for $table") + assertEquals(0, produced.deleteCounts.getValue(table), "no stale deletes for $table") + } + + Files.copy(prev, target) + val expectedHash = hash(next, 5) + DriverManager.getConnection("jdbc:sqlite:${target.toAbsolutePath()}").use { conn -> + PatchApplier().apply( + conn, + patch, + expectedToContentHash = expectedHash, + expectedToSchemaVersion = 5, + ) + } + assertEquals(expectedHash, hash(target, 5)) + } + } + private fun path(name: String): Path = tmp.root.toPath().resolve(name) private fun buildDb(path: Path, schemaVersion: Int, stalePromotedTables: Boolean) { @@ -100,13 +145,15 @@ class PatchMultiSchemaPromotionTest { ) val refKey = if (stalePromotedTables) 700 else 800 st.execute("INSERT INTO line_ref VALUES (1, $refKey, 0)") + val dhDisplayColumn = if (schemaVersion >= 5) ", dhDisplay TEXT NOT NULL" else "" st.execute( "CREATE TABLE line_dh (" + - "bookId INTEGER NOT NULL, dhText TEXT NOT NULL, lineIndex INTEGER NOT NULL, " + - "PRIMARY KEY(bookId, dhText, lineIndex)) WITHOUT ROWID", + "bookId INTEGER NOT NULL, dhText TEXT NOT NULL, lineIndex INTEGER NOT NULL" + + "$dhDisplayColumn, PRIMARY KEY(bookId, dhText, lineIndex)) WITHOUT ROWID", ) val dh = if (stalePromotedTables) "ישן" else "חדש" - st.execute("INSERT INTO line_dh VALUES (1, '$dh', 0)") + val dhValues = if (schemaVersion >= 5) "1, '$dh', 0, '$dh מודפס'" else "1, '$dh', 0" + st.execute("INSERT INTO line_dh VALUES ($dhValues)") st.execute("CREATE TABLE link (id INTEGER PRIMARY KEY NOT NULL, label TEXT NOT NULL)") st.execute("INSERT INTO link VALUES (100, 'link')") diff --git a/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchTableColumnContractTest.kt b/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchTableColumnContractTest.kt index ea823e43..14aa43f9 100644 --- a/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchTableColumnContractTest.kt +++ b/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchTableColumnContractTest.kt @@ -30,13 +30,10 @@ class PatchTableColumnContractTest { @JvmField @Rule val tmp = TemporaryFolder() - // The fixture is keyed by the DB's `schema_meta.db_schema_version`. There is - // no compile-time constant for it: StampSchemaVersionCli defaults it to - // PatchDbSchema.CURRENT_VERSION (the *patch format* version, documented as - // independent), and the two happen to coincide at 4 today because every - // table-contract bump so far also bumped the patch format. If they ever - // diverge, pin the number here explicitly instead of deriving it. - private val dbSchemaVersion = PatchDbSchema.CURRENT_VERSION + // The DB schema and patch-artifact format are independent version axes. + // v26 froze DB schema 4; dhDisplay therefore starts a new schema 5 fixture + // while PatchDbSchema.CURRENT_VERSION remains the patch format version. + private val dbSchemaVersion = CURRENT_DB_SCHEMA_VERSION private val fixtureName = "/patch_table_columns_schema_$dbSchemaVersion.json" @Test diff --git a/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchTablesContractTest.kt b/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchTablesContractTest.kt index f69f0186..f030d4d0 100644 --- a/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchTablesContractTest.kt +++ b/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchTablesContractTest.kt @@ -7,7 +7,7 @@ import kotlin.test.assertEquals * Contract guard against the Dart `seforim_library_updater` package. * * Serializes [PATCH_TABLES_IN_FK_ORDER], [LogicalContentHasher.DEFAULT_TABLES] - * and [PatchDbSchema.CURRENT_VERSION] to a canonical JSON form and compares it + * and [CURRENT_DB_SCHEMA_VERSION] to a canonical JSON form and compares it * to a committed fixture. The identical fixture lives in the updater repo * (`test/patch_tables_contract.json`), where its own test asserts the Dart * lists produce byte-identical output — so the two table specs cannot drift. @@ -17,12 +17,22 @@ import kotlin.test.assertEquals */ class PatchTablesContractTest { + private fun fixture(path: String): String = javaClass.getResourceAsStream(path) + ?.readBytes()?.toString(Charsets.UTF_8)?.replace("\r\n", "\n") + ?: error("fixture $path missing from test resources") + @Test fun `frozen schema contracts derive from the current list by dropping additions`() { assertEquals( - PATCH_TABLES_IN_FK_ORDER.filterNot { it.name in setOf("line_ref", "line_dh") }, + PATCH_TABLES_SCHEMA_4.filterNot { it.name in setOf("line_ref", "line_dh") }, PATCH_TABLES_SCHEMA_3, ) + assertEquals( + PATCH_TABLES_IN_FK_ORDER.map { table -> + if (table.name == "line_dh") table.copy(updatable = false) else table + }, + PATCH_TABLES_SCHEMA_4, + ) assertEquals( PATCH_TABLES_SCHEMA_3.filterNot { it.name == "link_suppressed_side" }, PATCH_TABLES_SCHEMA_2, @@ -59,6 +69,7 @@ class PatchTablesContractTest { }, LogicalContentHasher.TABLES_SCHEMA_4, ) + assertEquals(LogicalContentHasher.TABLES_SCHEMA_4, LogicalContentHasher.TABLES_SCHEMA_5) } private fun canonicalContract( @@ -92,22 +103,29 @@ class PatchTablesContractTest { @Test fun `canonical serialization matches committed fixture`() { - val expected = javaClass.getResourceAsStream("/patch_tables_contract.json") - ?.readBytes()?.toString(Charsets.UTF_8) - ?: error("fixture patch_tables_contract.json missing from test resources") + val expected = fixture("/patch_tables_contract.json") val actual = canonicalContract( PATCH_TABLES_IN_FK_ORDER, LogicalContentHasher.DEFAULT_TABLES, - PatchDbSchema.CURRENT_VERSION, + CURRENT_DB_SCHEMA_VERSION, + ) + assertEquals(expected, actual) + } + + @Test + fun `schema 4 canonical contract remains byte frozen`() { + val expected = fixture("/patch_tables_contract_schema_4.json") + val actual = canonicalContract( + PATCH_TABLES_SCHEMA_4, + LogicalContentHasher.TABLES_SCHEMA_4, + schemaVersion = 4, ) assertEquals(expected, actual) } @Test fun `schema 3 canonical contract remains byte frozen`() { - val expected = javaClass.getResourceAsStream("/patch_tables_contract_schema_3.json") - ?.readBytes()?.toString(Charsets.UTF_8) - ?: error("fixture patch_tables_contract_schema_3.json missing from test resources") + val expected = fixture("/patch_tables_contract_schema_3.json") val actual = canonicalContract( PATCH_TABLES_SCHEMA_3, LogicalContentHasher.TABLES_SCHEMA_3, @@ -118,9 +136,7 @@ class PatchTablesContractTest { @Test fun `schema 1 canonical contract remains byte frozen`() { - val expected = javaClass.getResourceAsStream("/patch_tables_contract_schema_1.json") - ?.readBytes()?.toString(Charsets.UTF_8) - ?: error("fixture patch_tables_contract_schema_1.json missing from test resources") + val expected = fixture("/patch_tables_contract_schema_1.json") val actual = canonicalContract( PATCH_TABLES_SCHEMA_1, LogicalContentHasher.TABLES_SCHEMA_1, diff --git a/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/StampSchemaVersionCliTest.kt b/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/StampSchemaVersionCliTest.kt index 9fe50507..89c1d1f7 100644 --- a/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/StampSchemaVersionCliTest.kt +++ b/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/StampSchemaVersionCliTest.kt @@ -40,6 +40,39 @@ class StampSchemaVersionCliTest { } } + @Test + fun `schema 5 stamp requires dhDisplay TEXT NOT NULL before writing metadata`() { + DriverManager.getConnection("jdbc:sqlite::memory:").use { conn -> + conn.createStatement().use { st -> + st.execute("CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT)") + st.execute("INSERT INTO schema_meta VALUES ('db_version', '26'), ('db_schema_version', '4')") + st.execute( + "CREATE TABLE line_ref (bookId INTEGER, refKeyHash INTEGER, lineIndex INTEGER, " + + "PRIMARY KEY (bookId, refKeyHash, lineIndex)) WITHOUT ROWID", + ) + st.execute( + "CREATE TABLE line_dh (bookId INTEGER, dhText TEXT, lineIndex INTEGER, " + + "PRIMARY KEY (bookId, dhText, lineIndex)) WITHOUT ROWID", + ) + } + + val error = assertFailsWith { + stampSchemaVersion(conn, dbVersion = 27, dbSchemaVersion = 5) + } + assertTrue("line_dh.dhDisplay" in error.message.orEmpty()) + assertEquals("26", meta(conn, "db_version")) + assertEquals("4", meta(conn, "db_schema_version")) + + conn.createStatement().use { + it.execute("ALTER TABLE line_dh ADD COLUMN dhDisplay TEXT NOT NULL DEFAULT ''") + } + stampSchemaVersion(conn, dbVersion = 27, dbSchemaVersion = 5) + assertEquals("27", meta(conn, "db_version")) + assertEquals("5", meta(conn, "db_schema_version")) + assertTrue(conn.autoCommit) + } + } + @Test fun `schema 3 remains stampable when an unsigned line ref table is present`() { DriverManager.getConnection("jdbc:sqlite::memory:").use { conn -> diff --git a/generator/common/src/jvmTest/resources/patch_table_columns_schema_5.json b/generator/common/src/jvmTest/resources/patch_table_columns_schema_5.json new file mode 100644 index 00000000..50017d3a --- /dev/null +++ b/generator/common/src/jvmTest/resources/patch_table_columns_schema_5.json @@ -0,0 +1,42 @@ +{ + "dbSchemaVersion": 5, + "tables": { + "source": ["id", "name"], + "author": ["id", "name"], + "topic": ["id", "name"], + "pub_place": ["id", "name"], + "pub_date": ["date", "id"], + "connection_type": ["id", "name"], + "tocText": ["id", "text"], + "generation": ["id", "name"], + "category": ["heDesc", "heShortDesc", "id", "level", "orderIndex", "parentId", "title"], + "category_closure": ["ancestorId", "descendantId"], + "book": ["categoryId", "collectiveTitleEn", "collectiveTitleHe", "dependenceType", "hasAltStructures", "hasCommentaryConnection", "hasNekudot", "hasOtherConnection", "hasReferenceConnection", "hasSourceConnection", "hasTargumConnection", "hasTeamim", "heDesc", "heRef", "heShortDesc", "id", "isBaseBook", "notesContent", "orderIndex", "sourceId", "title", "totalLines"], + "book_author": ["authorId", "bookId"], + "book_base_text": ["baseBookId", "bookId"], + "book_topic": ["bookId", "topicId"], + "book_pub_place": ["bookId", "pubPlaceId"], + "book_pub_date": ["bookId", "pubDateId"], + "book_acronym": ["bookId", "term"], + "book_generation": ["bookId", "generationId"], + "tocEntry": ["bookId", "hasChildren", "id", "isLastChild", "level", "lineId", "parentId", "textId"], + "line": ["bookId", "charCount", "content", "heRef", "id", "lineIndex", "tocEntryId"], + "line_toc": ["lineId", "tocEntryId"], + "line_ref": ["bookId", "lineIndex", "refKeyHash"], + "line_dh": ["bookId", "dhDisplay", "dhText", "lineIndex"], + "link": ["baseProvenance", "connectionTypeId", "id", "sourceBookId", "sourceLineId", "targetBookId", "targetBookOrderIndex", "targetLineId", "targetLineIndex"], + "link_anchor": ["charEnd", "charStart", "label", "linkId", "side"], + "link_range": ["endLineId", "endLineIndex", "linkId", "side"], + "link_coverage": ["lineId", "linkId", "side"], + "link_suppressed_side": ["linkId", "reasonMask", "side"], + "book_has_links": ["bookId", "hasSourceLinks", "hasTargetLinks"], + "book_version": ["bookId", "hasContent", "heVersionNotes", "heVersionTitle", "id", "license", "priority", "versionNotes", "versionSource", "versionTitle"], + "version_line": ["charCount", "content", "lineId", "versionId"], + "alt_toc_structure": ["bookId", "heTitle", "id", "key", "title"], + "alt_toc_entry": ["hasChildren", "id", "isLastChild", "level", "lineId", "parentId", "structureId", "textId"], + "line_alt_toc": ["altTocEntryId", "lineId", "structureId"], + "default_commentator": ["bookId", "commentatorBookId", "position"], + "default_targum": ["bookId", "position", "targumBookId"], + "schema_meta": ["key", "value"] + } +} diff --git a/generator/common/src/jvmTest/resources/patch_tables_contract.json b/generator/common/src/jvmTest/resources/patch_tables_contract.json index 81ee6aee..61021953 100644 --- a/generator/common/src/jvmTest/resources/patch_tables_contract.json +++ b/generator/common/src/jvmTest/resources/patch_tables_contract.json @@ -1,5 +1,5 @@ { - "schemaVersion": 4, + "schemaVersion": 5, "fkOrder": [ { "table": "source", "pk": ["id"], "updatable": true }, { "table": "author", "pk": ["id"], "updatable": true }, @@ -23,7 +23,7 @@ { "table": "line", "pk": ["id"], "updatable": true }, { "table": "line_toc", "pk": ["lineId"], "updatable": true }, { "table": "line_ref", "pk": ["bookId", "refKeyHash", "lineIndex"], "updatable": false }, - { "table": "line_dh", "pk": ["bookId", "dhText", "lineIndex"], "updatable": false }, + { "table": "line_dh", "pk": ["bookId", "dhText", "lineIndex"], "updatable": true }, { "table": "link", "pk": ["id"], "updatable": true }, { "table": "link_anchor", "pk": ["linkId", "side", "charStart"], "updatable": true }, { "table": "link_range", "pk": ["linkId", "side"], "updatable": true }, diff --git a/generator/common/src/jvmTest/resources/patch_tables_contract_schema_4.json b/generator/common/src/jvmTest/resources/patch_tables_contract_schema_4.json new file mode 100644 index 00000000..81ee6aee --- /dev/null +++ b/generator/common/src/jvmTest/resources/patch_tables_contract_schema_4.json @@ -0,0 +1,81 @@ +{ + "schemaVersion": 4, + "fkOrder": [ + { "table": "source", "pk": ["id"], "updatable": true }, + { "table": "author", "pk": ["id"], "updatable": true }, + { "table": "topic", "pk": ["id"], "updatable": true }, + { "table": "pub_place", "pk": ["id"], "updatable": true }, + { "table": "pub_date", "pk": ["id"], "updatable": true }, + { "table": "connection_type", "pk": ["id"], "updatable": true }, + { "table": "tocText", "pk": ["id"], "updatable": true }, + { "table": "generation", "pk": ["id"], "updatable": true }, + { "table": "category", "pk": ["id"], "updatable": true }, + { "table": "category_closure", "pk": ["ancestorId", "descendantId"], "updatable": false }, + { "table": "book", "pk": ["id"], "updatable": true }, + { "table": "book_author", "pk": ["bookId", "authorId"], "updatable": false }, + { "table": "book_base_text", "pk": ["bookId", "baseBookId"], "updatable": false }, + { "table": "book_topic", "pk": ["bookId", "topicId"], "updatable": false }, + { "table": "book_pub_place", "pk": ["bookId", "pubPlaceId"], "updatable": false }, + { "table": "book_pub_date", "pk": ["bookId", "pubDateId"], "updatable": false }, + { "table": "book_acronym", "pk": ["bookId", "term"], "updatable": false }, + { "table": "book_generation", "pk": ["bookId", "generationId"], "updatable": false }, + { "table": "tocEntry", "pk": ["id"], "updatable": true }, + { "table": "line", "pk": ["id"], "updatable": true }, + { "table": "line_toc", "pk": ["lineId"], "updatable": true }, + { "table": "line_ref", "pk": ["bookId", "refKeyHash", "lineIndex"], "updatable": false }, + { "table": "line_dh", "pk": ["bookId", "dhText", "lineIndex"], "updatable": false }, + { "table": "link", "pk": ["id"], "updatable": true }, + { "table": "link_anchor", "pk": ["linkId", "side", "charStart"], "updatable": true }, + { "table": "link_range", "pk": ["linkId", "side"], "updatable": true }, + { "table": "link_coverage", "pk": ["lineId", "linkId", "side"], "updatable": false }, + { "table": "link_suppressed_side", "pk": ["linkId", "side"], "updatable": true }, + { "table": "book_has_links", "pk": ["bookId"], "updatable": true }, + { "table": "book_version", "pk": ["id"], "updatable": true }, + { "table": "version_line", "pk": ["versionId", "lineId"], "updatable": true }, + { "table": "alt_toc_structure", "pk": ["id"], "updatable": true }, + { "table": "alt_toc_entry", "pk": ["id"], "updatable": true }, + { "table": "line_alt_toc", "pk": ["lineId", "structureId"], "updatable": true }, + { "table": "default_commentator", "pk": ["bookId", "commentatorBookId"], "updatable": true }, + { "table": "default_targum", "pk": ["bookId", "targumBookId"], "updatable": true }, + { "table": "schema_meta", "pk": ["key"], "updatable": true } + ], + "hashOrder": [ + "source", + "author", + "topic", + "pub_place", + "pub_date", + "connection_type", + "generation", + "category", + "category_closure", + "tocText", + "book", + "book_topic", + "book_author", + "book_base_text", + "book_pub_place", + "book_pub_date", + "book_generation", + "tocEntry", + "line", + "line_toc", + "line_ref", + "line_dh", + "link", + "link_anchor", + "link_range", + "link_coverage", + "link_suppressed_side", + "book_has_links", + "book_version", + "version_line", + "book_acronym", + "alt_toc_structure", + "alt_toc_entry", + "line_alt_toc", + "default_commentator", + "default_targum", + "schema_meta" + ] +}