diff --git a/API.md b/API.md index 7891605..b0045e8 100644 --- a/API.md +++ b/API.md @@ -337,7 +337,9 @@ Renames an indexed file path in memory without reprocessing content. **Notes:** - Updates `dbmem_content.path` and keeps any local `dbmem_content_source` metadata attached to the renamed logical path - It does not rename the file on disk or change the stored `source_path` value -- Does not change `hash`, `value`, embeddings, or FTS entries +- With `preserve_duplicate_paths=1`, recomputes the path-scoped `hash` and updates related embedding and FTS rows +- With `preserve_duplicate_paths=1`, rename requires saved content; if `save_content=0` was used for the row, `memory_rename_file()` returns an error because the path-scoped hash cannot be recomputed safely +- Does not change `value` or stored embedding/FTS content - Fails if `new_path` already exists because `dbmem_content.path` is unique - Explicit directory markers can be renamed only to another trailing-slash marker path; this renames only the marker row, not child paths - Fails if `old_path` matches more than one row across `path` and local `dbmem_content_source.source_path`; pass a unique logical path or exact local source path @@ -829,7 +831,7 @@ sqlite3_memory_register_provider(db, "my-engine", &provider); | `max_tokens` | INTEGER | 400 | Maximum tokens per chunk | | `overlay_tokens` | INTEGER | 80 | Token overlap between consecutive chunks | | `chars_per_tokens` | INTEGER | 4 | Estimated characters per token | -| `save_content` | INTEGER | 1 | Store original content (1=yes, 0=no) | +| `save_content` | INTEGER | 1 | Store original content (1=yes, 0=no). Required for renaming rows created with `preserve_duplicate_paths=1` | | `skip_semantic` | INTEGER | 0 | Skip markdown parsing, treat as raw text | | `skip_html` | INTEGER | 1 | Strip HTML tags when parsing | | `extensions` | TEXT | "md,mdx" | Comma-separated file extensions to process | diff --git a/src/sqlite-memory.c b/src/sqlite-memory.c index 5bad0c6..3a47fff 100644 --- a/src/sqlite-memory.c +++ b/src/sqlite-memory.c @@ -1584,12 +1584,15 @@ static void dbmem_rename_file (sqlite3_context *context, int argc, sqlite3_value } sqlite3 *db = sqlite3_context_db_handle(context); + dbmem_context *ctx = (dbmem_context *)sqlite3_user_data(context); const char *old_path = (const char *)sqlite3_value_text(argv[0]); const char *new_path = (const char *)sqlite3_value_text(argv[1]); uint64_t hash = 0; + uint64_t new_hash = 0; int matches = 0; char *resolved_path = NULL; char *new_storage_path = NULL; + const char *result_error = NULL; int rc = dbmem_resolve_content_hash_for_path(db, old_path, &hash, &matches); if (rc != SQLITE_OK) { @@ -1659,11 +1662,66 @@ static void dbmem_rename_file (sqlite3_context *context, int argc, sqlite3_value rc = dbmem_database_begin_transaction(db); if (rc != SQLITE_OK) goto cleanup; - rc = sqlite3_prepare_v2(db, "UPDATE dbmem_content SET path = ?2 WHERE hash = ?1;", -1, &vm, NULL); + new_hash = hash; + if (ctx->preserve_duplicate_paths) { + rc = sqlite3_prepare_v2(db, "SELECT value FROM dbmem_content WHERE hash = ?1;", -1, &vm, NULL); + if (rc != SQLITE_OK) goto rollback; + rc = dbmem_bind_hash(vm, 1, hash); + if (rc != SQLITE_OK) goto rollback; + rc = sqlite3_step(vm); + if (rc == SQLITE_ROW) { + if (sqlite3_column_type(vm, 0) == SQLITE_NULL) { + result_error = "memory_rename_file cannot rekey preserve_duplicate_paths content when save_content=0"; + sqlite3_finalize(vm); + vm = NULL; + rc = SQLITE_ERROR; + goto rollback; + } + const char *value = (const char *)sqlite3_column_text(vm, 0); + int value_len = sqlite3_column_bytes(vm, 0); + new_hash = dbmem_storage_hash_compute(value, (size_t)value_len, new_storage_path, true); + } + if (rc == SQLITE_ROW || rc == SQLITE_DONE) rc = SQLITE_OK; + if (rc != SQLITE_OK) goto rollback; + sqlite3_finalize(vm); + vm = NULL; + } + + if (new_hash != hash) { + rc = sqlite3_prepare_v2(db, "UPDATE dbmem_vault SET hash = ?2 WHERE hash = ?1;", -1, &vm, NULL); + if (rc != SQLITE_OK) goto rollback; + rc = dbmem_bind_hash(vm, 1, hash); + if (rc != SQLITE_OK) goto rollback; + rc = dbmem_bind_hash(vm, 2, new_hash); + if (rc != SQLITE_OK) goto rollback; + rc = sqlite3_step(vm); + if (rc == SQLITE_DONE) rc = SQLITE_OK; + if (rc != SQLITE_OK) goto rollback; + sqlite3_finalize(vm); + vm = NULL; + + if (fts5_is_available) { + rc = sqlite3_prepare_v2(db, "UPDATE dbmem_vault_fts SET hash = ?2 WHERE hash = ?1;", -1, &vm, NULL); + if (rc != SQLITE_OK) goto rollback; + rc = dbmem_bind_hash(vm, 1, hash); + if (rc != SQLITE_OK) goto rollback; + rc = dbmem_bind_hash(vm, 2, new_hash); + if (rc != SQLITE_OK) goto rollback; + rc = sqlite3_step(vm); + if (rc == SQLITE_DONE) rc = SQLITE_OK; + if (rc != SQLITE_OK) goto rollback; + sqlite3_finalize(vm); + vm = NULL; + } + } + + rc = sqlite3_prepare_v2(db, "UPDATE dbmem_content SET hash = ?2, path = ?3 WHERE hash = ?1;", -1, &vm, NULL); if (rc != SQLITE_OK) goto rollback; rc = dbmem_bind_hash(vm, 1, hash); if (rc != SQLITE_OK) goto rollback; - rc = sqlite3_bind_text(vm, 2, new_storage_path, -1, SQLITE_STATIC); + rc = dbmem_bind_hash(vm, 2, new_hash); + if (rc != SQLITE_OK) goto rollback; + rc = sqlite3_bind_text(vm, 3, new_storage_path, -1, SQLITE_STATIC); if (rc != SQLITE_OK) goto rollback; rc = sqlite3_step(vm); @@ -1697,7 +1755,7 @@ static void dbmem_rename_file (sqlite3_context *context, int argc, sqlite3_value if (vm) sqlite3_finalize(vm); dbmemory_free(resolved_path); dbmemory_free(new_storage_path); - sqlite3_result_error(context, sqlite3_errmsg(db), -1); + sqlite3_result_error(context, result_error ? result_error : sqlite3_errmsg(db), -1); } // MARK: - Path Listing - diff --git a/src/sqlite-memory.h b/src/sqlite-memory.h index a39ae0d..52a1ded 100644 --- a/src/sqlite-memory.h +++ b/src/sqlite-memory.h @@ -26,7 +26,7 @@ extern "C" { #endif -#define SQLITE_DBMEMORY_VERSION "1.3.3" +#define SQLITE_DBMEMORY_VERSION "1.3.4" // public API SQLITE_DBMEMORY_API int sqlite3_memory_init (sqlite3 *db, char **pzErrMsg, const sqlite3_api_routines *pApi); diff --git a/test/unittest.c b/test/unittest.c index 8d8f5dc..5d4fb4d 100644 --- a/test/unittest.c +++ b/test/unittest.c @@ -3680,6 +3680,156 @@ TEST(sqlite_memory_add_content_preserves_duplicate_empty_paths_when_enabled) { sqlite3_close(db); } +TEST(sqlite_memory_rename_file_rekeys_preserved_empty_path_hash) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + sqlite3_int64 result = 0; + int rc = exec_get_int(db, "SELECT memory_set_option('preserve_duplicate_paths', 1);", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT memory_add_content('untitled-1.md', '');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT memory_rename_file('untitled-1.md', '1.md');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + rc = exec_get_int(db, "SELECT memory_add_content('untitled-1.md', '');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content WHERE path IN ('1.md', 'untitled-1.md');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 2); + + rc = exec_get_int(db, "SELECT COUNT(DISTINCT hash) FROM dbmem_content WHERE path IN ('1.md', 'untitled-1.md');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 2); + + sqlite3_close(db); +} + +TEST(sqlite_memory_rename_file_rekeys_preserved_index_hashes) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + dbmem_provider_t prov = { .init = dummy_init, .compute = dummy_compute, .free = dummy_free }; + int rc = sqlite3_memory_register_provider(db, "dummy", &prov); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 result = 0; + rc = exec_get_int(db, "SELECT memory_set_model('dummy', 'test-model');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT memory_set_option('preserve_duplicate_paths', 1);", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT memory_add_content('untitled-1.md', '# Heading\nIndexed body text.');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + char old_hash[DBMEM_HASH_STR_MAXLEN]; + rc = exec_get_text(db, "SELECT hash FROM dbmem_content WHERE path = 'untitled-1.md';", old_hash, sizeof(old_hash)); + ASSERT_EQ(rc, SQLITE_OK); + + char *sql = sqlite3_mprintf("SELECT COUNT(*) FROM dbmem_vault WHERE hash = '%q';", old_hash); + ASSERT(sql != NULL); + rc = exec_get_int(db, sql, &result); + sqlite3_free(sql); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT(result > 0); + + sql = sqlite3_mprintf("SELECT COUNT(*) FROM dbmem_vault_fts WHERE hash = '%q';", old_hash); + ASSERT(sql != NULL); + rc = exec_get_int(db, sql, &result); + sqlite3_free(sql); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT(result > 0); + + rc = exec_get_int(db, "SELECT memory_rename_file('untitled-1.md', '1.md');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + char new_hash[DBMEM_HASH_STR_MAXLEN]; + rc = exec_get_text(db, "SELECT hash FROM dbmem_content WHERE path = '1.md';", new_hash, sizeof(new_hash)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT(strcmp(old_hash, new_hash) != 0); + + sql = sqlite3_mprintf("SELECT COUNT(*) FROM dbmem_vault WHERE hash = '%q';", old_hash); + ASSERT(sql != NULL); + rc = exec_get_int(db, sql, &result); + sqlite3_free(sql); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + + sql = sqlite3_mprintf("SELECT COUNT(*) FROM dbmem_vault WHERE hash = '%q';", new_hash); + ASSERT(sql != NULL); + rc = exec_get_int(db, sql, &result); + sqlite3_free(sql); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT(result > 0); + + sql = sqlite3_mprintf("SELECT COUNT(*) FROM dbmem_vault_fts WHERE hash = '%q';", old_hash); + ASSERT(sql != NULL); + rc = exec_get_int(db, sql, &result); + sqlite3_free(sql); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + + sql = sqlite3_mprintf("SELECT COUNT(*) FROM dbmem_vault_fts WHERE hash = '%q';", new_hash); + ASSERT(sql != NULL); + rc = exec_get_int(db, sql, &result); + sqlite3_free(sql); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT(result > 0); + + rc = exec_get_int(db, "SELECT memory_add_content('untitled-1.md', '# Heading\nIndexed body text.');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content WHERE path IN ('1.md', 'untitled-1.md');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 2); + + sqlite3_close(db); +} + +TEST(sqlite_memory_rename_file_rejects_preserved_path_without_saved_content) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + dbmem_provider_t prov = { .init = dummy_init, .compute = dummy_compute, .free = dummy_free }; + int rc = sqlite3_memory_register_provider(db, "dummy", &prov); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 result = 0; + rc = exec_get_int(db, "SELECT memory_set_model('dummy', 'test-model');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT memory_set_option('preserve_duplicate_paths', 1);", &result); + ASSERT_EQ(rc, SQLITE_OK); + rc = exec_get_int(db, "SELECT memory_set_option('save_content', 0);", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT memory_add_content('untitled-1.md', '# Heading\nUnsaved body text.');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_stmt *stmt = NULL; + rc = sqlite3_prepare_v2(db, "SELECT memory_rename_file('untitled-1.md', '1.md');", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + rc = sqlite3_step(stmt); + ASSERT_EQ(rc, SQLITE_ERROR); + ASSERT(strstr(sqlite3_errmsg(db), "save_content=0") != NULL); + sqlite3_finalize(stmt); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content WHERE path = 'untitled-1.md';", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content WHERE path = '1.md';", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + + sqlite3_close(db); +} + TEST(sqlite_memory_add_content_keeps_same_empty_path_idempotent_when_preserving_duplicates) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); @@ -5217,6 +5367,9 @@ int main(int argc, char *argv[]) { RUN_TEST(sqlite_memory_preserve_duplicate_paths_option_defaults_to_zero); RUN_TEST(sqlite_memory_add_content_stores_empty_content); RUN_TEST(sqlite_memory_add_content_preserves_duplicate_empty_paths_when_enabled); + RUN_TEST(sqlite_memory_rename_file_rekeys_preserved_empty_path_hash); + RUN_TEST(sqlite_memory_rename_file_rekeys_preserved_index_hashes); + RUN_TEST(sqlite_memory_rename_file_rejects_preserved_path_without_saved_content); RUN_TEST(sqlite_memory_add_content_keeps_same_empty_path_idempotent_when_preserving_duplicates); RUN_TEST(sqlite_memory_add_content_requires_preserve_for_directory_marker); RUN_TEST(sqlite_memory_add_content_creates_directory_marker);