| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| |
| #include <algorithm> |
| #include <cassert> |
| #include <memory> |
| #include <string> |
| #include <vector> |
| |
| #include "arrow/array.h" |
| #include "arrow/c/bridge.h" |
| #include "arrow/ipc/api.h" |
| #include "arrow/type.h" |
| #include "gtest/gtest.h" |
| #include "paimon/common/utils/path_util.h" |
| #include "paimon/core/global_index/global_index_file_manager.h" |
| #include "paimon/core/index/index_path_factory.h" |
| #include "paimon/fs/local/local_file_system.h" |
| #include "paimon/global_index/bitmap_global_index_result.h" |
| #include "paimon/global_index/bitmap_scored_global_index_result.h" |
| #include "paimon/global_index/tantivy/tantivy_archive_layout.h" |
| #include "paimon/global_index/tantivy/tantivy_defs.h" |
| #include "paimon/global_index/tantivy/tantivy_global_index.h" |
| #include "paimon/global_index/tantivy/tantivy_global_index_reader.h" |
| #include "paimon/global_index/tantivy/tantivy_global_index_writer.h" |
| #include "paimon/predicate/full_text_search.h" |
| #include "paimon/testing/utils/testharness.h" |
| |
| #ifndef JIEBA_TEST_DICT_DIR |
| #error "JIEBA_TEST_DICT_DIR must be set at compile time" |
| #endif |
| #ifndef PAIMON_TANTIVY_JAVA_FIXTURE_DIR |
| #error "PAIMON_TANTIVY_JAVA_FIXTURE_DIR must be set at compile time" |
| #endif |
| |
| namespace paimon::tantivy::test { |
| |
| namespace { |
| |
| class FixturePathFactory : public IndexPathFactory { |
| public: |
| explicit FixturePathFactory(const std::string& root) : root_(root) {} |
| std::string NewPath() const override { |
| assert(false); |
| return ""; |
| } |
| std::string ToPath(const std::shared_ptr<IndexFileMeta>&) const override { |
| assert(false); |
| return ""; |
| } |
| std::string ToPath(const std::string& file_name) const override { |
| return PathUtil::JoinPath(root_, file_name); |
| } |
| bool IsExternalPath() const override { |
| return false; |
| } |
| |
| private: |
| std::string root_; |
| }; |
| |
| class JavaCompatTest : public ::testing::Test { |
| public: |
| /// Build a TantivyGlobalIndexReader on top of the Java-produced fixture. |
| /// `fixture_name` is relative to `PAIMON_TANTIVY_JAVA_FIXTURE_DIR`. |
| std::shared_ptr<GlobalIndexReader> OpenFixture(const std::string& fixture_name) { |
| std::string fixture_dir = PAIMON_TANTIVY_JAVA_FIXTURE_DIR; |
| std::string archive_path = PathUtil::JoinPath(fixture_dir, fixture_name); |
| |
| EXPECT_OK_AND_ASSIGN(auto file_status, fs_->GetFileStatus(archive_path)); |
| int64_t file_size = file_status.GetLen(); |
| EXPECT_GT(file_size, 4) << "fixture archive must exist and be > 4 bytes"; |
| |
| // Empty metadata (options not needed for cross-read — we use defaults) |
| std::string metadata_json = "{}"; |
| auto meta_bytes = std::make_shared<Bytes>(metadata_json, pool_.get()); |
| |
| GlobalIndexIOMeta io_meta(archive_path, file_size, meta_bytes); |
| |
| std::map<std::string, std::string> options; |
| auto global_index = std::make_shared<TantivyGlobalIndex>(options); |
| auto path_factory = std::make_shared<FixturePathFactory>(fixture_dir); |
| auto file_reader = std::make_shared<GlobalIndexFileManager>(fs_, path_factory); |
| |
| auto data_type = arrow::struct_({arrow::field("f0", arrow::utf8())}); |
| auto c_schema = std::make_unique<::ArrowSchema>(); |
| EXPECT_TRUE(arrow::ExportType(*data_type, c_schema.get()).ok()); |
| |
| EXPECT_OK_AND_ASSIGN(auto reader_res, global_index->CreateReader( |
| c_schema.get(), file_reader, {io_meta}, pool_)); |
| return reader_res; |
| } |
| |
| std::shared_ptr<FullTextSearch> BuildFts(FullTextSearch::SearchType type, |
| const std::string& query) { |
| return std::make_shared<FullTextSearch>( |
| /*_field_name=*/"f0", |
| /*_limit=*/std::optional<int32_t>{}, |
| /*_query=*/query, |
| /*_search_type=*/type, |
| /*_pre_filter=*/std::optional<RoaringBitmap64>{}); |
| } |
| |
| /// Run the search and return the sorted row_ids from the result bitmap. |
| std::vector<int64_t> RunSearchRowIds(const std::shared_ptr<GlobalIndexReader>& reader, |
| FullTextSearch::SearchType type, |
| const std::string& query) { |
| auto fts = BuildFts(type, query); |
| // This helper returns a value, so gtest ASSERT_* (which `return;`) cannot |
| // be used here; use the EXPECT_OK family. |
| EXPECT_OK_AND_ASSIGN(std::shared_ptr<GlobalIndexResult> r, |
| reader->VisitFullTextSearch(fts)); |
| |
| const RoaringBitmap64* bitmap = nullptr; |
| if (auto plain = std::dynamic_pointer_cast<BitmapGlobalIndexResult>(r)) { |
| EXPECT_OK_AND_ASSIGN(bitmap, plain->GetBitmap()); |
| } else if (auto scored = std::dynamic_pointer_cast<BitmapScoredGlobalIndexResult>(r)) { |
| EXPECT_OK_AND_ASSIGN(bitmap, scored->GetBitmap()); |
| } |
| EXPECT_TRUE(bitmap != nullptr); |
| if (bitmap == nullptr) { |
| return {}; |
| } |
| |
| std::vector<int64_t> out; |
| for (auto it = bitmap->Begin(); it != bitmap->End(); ++it) { |
| out.push_back(static_cast<int64_t>(*it)); |
| } |
| std::sort(out.begin(), out.end()); |
| return out; |
| } |
| |
| protected: |
| std::shared_ptr<MemoryPool> pool_ = GetDefaultPool(); |
| std::shared_ptr<FileSystem> fs_ = std::make_shared<LocalFileSystem>(); |
| }; |
| |
| } // namespace |
| |
| // ============================================================================ |
| // 1. Archive basics: opening the Java-produced fixture succeeds |
| // ============================================================================ |
| |
| TEST_F(JavaCompatTest, OpenJavaArchiveSucceeds) { |
| auto reader = OpenFixture("english_simple.archive"); |
| ASSERT_TRUE(reader != nullptr); |
| } |
| |
| // ============================================================================ |
| // 2. MATCH_ALL — single and multi-term |
| // ============================================================================ |
| |
| TEST_F(JavaCompatTest, MatchAllApple) { |
| auto reader = OpenFixture("english_simple.archive"); |
| auto ids = RunSearchRowIds(reader, FullTextSearch::SearchType::MATCH_ALL, "apple"); |
| // Docs containing "apple": 0 ("apple banana cherry"), 1 ("apple durian"), |
| // 4 ("apple cherry fig"), 7 ("apple") |
| ASSERT_EQ(ids, (std::vector<int64_t>{0, 1, 4, 7})); |
| } |
| |
| TEST_F(JavaCompatTest, MatchAllAppleBananaIntersection) { |
| auto reader = OpenFixture("english_simple.archive"); |
| auto ids = RunSearchRowIds(reader, FullTextSearch::SearchType::MATCH_ALL, "apple banana"); |
| // Only doc 0 contains both "apple" and "banana" |
| ASSERT_EQ(ids, (std::vector<int64_t>{0})); |
| } |
| |
| // ============================================================================ |
| // 3. MATCH_ANY — union |
| // ============================================================================ |
| |
| TEST_F(JavaCompatTest, MatchAnyDurianElderberryUnion) { |
| auto reader = OpenFixture("english_simple.archive"); |
| auto ids = RunSearchRowIds(reader, FullTextSearch::SearchType::MATCH_ANY, "durian elderberry"); |
| // durian: 1, 6 elderberry: 5, 8 union: {1, 5, 6, 8} |
| ASSERT_EQ(ids, (std::vector<int64_t>{1, 5, 6, 8})); |
| } |
| |
| // ============================================================================ |
| // 4. PHRASE — consecutive term order matters |
| // ============================================================================ |
| |
| TEST_F(JavaCompatTest, PhraseAppleBanana) { |
| auto reader = OpenFixture("english_simple.archive"); |
| auto ids = RunSearchRowIds(reader, FullTextSearch::SearchType::PHRASE, "apple banana"); |
| // Only doc 0 has "apple banana" as consecutive phrase |
| ASSERT_EQ(ids, (std::vector<int64_t>{0})); |
| } |
| |
| TEST_F(JavaCompatTest, PhraseBananaCherry) { |
| auto reader = OpenFixture("english_simple.archive"); |
| auto ids = RunSearchRowIds(reader, FullTextSearch::SearchType::PHRASE, "banana cherry"); |
| // "banana cherry" consecutive in doc 0 ("apple banana cherry") and doc 2 ("banana cherry") |
| ASSERT_EQ(ids, (std::vector<int64_t>{0, 2})); |
| } |
| |
| // ============================================================================ |
| // 5. PREFIX — byte-level (not tokenized) via RegexQuery |
| // ============================================================================ |
| |
| TEST_F(JavaCompatTest, PrefixAp) { |
| auto reader = OpenFixture("english_simple.archive"); |
| auto ids = RunSearchRowIds(reader, FullTextSearch::SearchType::PREFIX, "ap"); |
| // Tokens starting with "ap": "apple" → docs 0, 1, 4, 7 |
| ASSERT_EQ(ids, (std::vector<int64_t>{0, 1, 4, 7})); |
| } |
| |
| // ============================================================================ |
| // 6. WILDCARD — glob-style via regex |
| // ============================================================================ |
| |
| TEST_F(JavaCompatTest, WildcardErr) { |
| auto reader = OpenFixture("english_simple.archive"); |
| auto ids = RunSearchRowIds(reader, FullTextSearch::SearchType::WILDCARD, "*err*"); |
| // Tokens matching *err*: "cherry" (0,2,4,6,9), "elderberry" (5,8) |
| ASSERT_EQ(ids, (std::vector<int64_t>{0, 2, 4, 5, 6, 8, 9})); |
| } |
| |
| // ============================================================================ |
| // 7. row_id invariant — must return the *caller-supplied* row_ids (not doc_ids) |
| // ============================================================================ |
| |
| TEST_F(JavaCompatTest, AllDocsReachableByRowId) { |
| auto reader = OpenFixture("english_simple.archive"); |
| // Union of all terms matches all 10 docs. |
| auto ids = RunSearchRowIds(reader, FullTextSearch::SearchType::MATCH_ANY, |
| "apple banana cherry durian fig grape elderberry"); |
| ASSERT_EQ(ids, (std::vector<int64_t>{0, 1, 2, 3, 4, 5, 6, 7, 8, 9})); |
| // This confirms Java wrote row_ids 0..9 via `addDocument(rowId, text)` and |
| // paimon-cpp reader extracted them via fast_fields().u64("row_id") — |
| // the schema invariant survives round-trip across implementations. |
| } |
| |
| // ============================================================================ |
| // 8. Probe: real paimon-java production archive (handed over by Java team). |
| // Data was claimed to be (id INT, content STRING) with 5 rows but ids |
| // rewritten multiple times; dump layout + per-term hits so caller can |
| // reverse-engineer what's actually inside. |
| // ============================================================================ |
| |
| TEST_F(JavaCompatTest, ProductionSampleProbe) { |
| const std::string fixture_name = "production_sample.archive"; |
| |
| // Open a reader over the Java-written production sample archive. |
| auto reader = OpenFixture(fixture_name); |
| ASSERT_TRUE(reader != nullptr); |
| |
| // Keywords expected from the production text samples; tokenizer is "default" |
| // (lowercased, word-granular). |
| const std::vector<std::string> probes = { |
| "apache", "paimon", "is", "a", "lake", "format", "supports", |
| "full", "text", "search", "in", "vector", "similarity", "using", |
| "lumina", "streaming", "and", "batch", "processing", "engine", |
| }; |
| |
| // The archive must be readable — at least one probe term hits. |
| bool any_hit = false; |
| for (const auto& term : probes) { |
| if (!RunSearchRowIds(reader, FullTextSearch::SearchType::MATCH_ALL, term).empty()) { |
| any_hit = true; |
| break; |
| } |
| } |
| ASSERT_TRUE(any_hit) << "no probe term hit; archive may be empty or schema mismatched"; |
| } |
| |
| // ============================================================================ |
| // 9. Reverse direction: paimon-cpp writes with tokenizer="default" → fixture |
| // consumed by paimon-java test. This test emits the archive into |
| // test/test_data/cpp_tantivy_fixtures/english_default.archive and |
| // round-trips it through the cpp reader first (schema-driven tokenizer |
| // dispatch picks "default" automatically). |
| // ============================================================================ |
| |
| namespace { |
| |
| /// GlobalIndexFileWriter that emits to a single fixed filename under `root`. |
| /// Mirrors paimon-java's `FixedNameLocalFileWriter` from |
| /// `TantivyIndexFixtureGen.java`: `newFileName(prefix)` ignores the prefix and |
| /// always returns the caller-chosen name. Used to produce a stable fixture |
| /// path consumed by the paimon-java cross-read test. |
| class FixedNameGlobalIndexFileWriter : public GlobalIndexFileWriter { |
| public: |
| FixedNameGlobalIndexFileWriter(std::shared_ptr<FileSystem> fs, std::string root, |
| std::string fixed_name) |
| : fs_(std::move(fs)), root_(std::move(root)), fixed_name_(std::move(fixed_name)) {} |
| |
| Result<std::string> NewFileName(const std::string& /*prefix*/) const override { |
| return fixed_name_; |
| } |
| std::string ToPath(const std::string& file_name) const override { |
| return PathUtil::JoinPath(root_, file_name); |
| } |
| Result<std::unique_ptr<OutputStream>> NewOutputStream( |
| const std::string& file_name) const override { |
| return fs_->Create(ToPath(file_name), /*overwrite=*/true); |
| } |
| Result<int64_t> GetFileSize(const std::string& file_name) const override { |
| PAIMON_ASSIGN_OR_RAISE(FileStatus file_status, fs_->GetFileStatus(ToPath(file_name))); |
| return file_status.GetLen(); |
| } |
| |
| private: |
| std::shared_ptr<FileSystem> fs_; |
| std::string root_; |
| std::string fixed_name_; |
| }; |
| |
| /// Same 10-doc English corpus paimon-java uses in TantivyIndexFixtureGen |
| /// (pure ASCII, no punctuation inside words). SimpleTokenizer (tantivy's |
| /// "default") tokenizes identically on both sides for this subset, so the |
| /// golden row_ids match byte-for-byte between cpp-write and java-read. |
| constexpr const char* kEnglishDocs[] = { |
| "apple banana cherry", // 0 |
| "apple durian", // 1 |
| "banana cherry", // 2 |
| "fig grape", // 3 |
| "apple cherry fig", // 4 |
| "banana elderberry", // 5 |
| "cherry durian", // 6 |
| "apple", // 7 |
| "grape fig elderberry", // 8 |
| "cherry fig", // 9 |
| }; |
| |
| } // namespace |
| |
| TEST_F(JavaCompatTest, CppWriteDefaultTokenizerForJavaCrossRead) { |
| // 1) Produce an archive into test/test_data/cpp_tantivy_fixtures/ via the |
| // production TantivyGlobalIndexWriter, configured with tantivy's |
| // built-in "default" tokenizer (same as paimon-java's TEXT field). |
| const std::string out_dir = PAIMON_TANTIVY_CPP_FIXTURE_DIR; |
| const std::string fixture_name = "english_default.archive"; |
| // Ensure dir exists (CMake does NOT create it automatically). |
| ASSERT_OK(fs_->Mkdirs(out_dir)); |
| // Clean any prior fixture so each test run writes fresh bytes. |
| { |
| const std::string archive_path_cleanup = PathUtil::JoinPath(out_dir, fixture_name); |
| auto existing = fs_->GetFileStatus(archive_path_cleanup); |
| if (existing.ok()) { |
| ASSERT_TRUE(fs_->Delete(archive_path_cleanup, false).ok()); |
| } |
| } |
| |
| auto file_writer = std::make_shared<FixedNameGlobalIndexFileWriter>(fs_, out_dir, fixture_name); |
| |
| auto data_type = arrow::struct_({arrow::field("f0", arrow::utf8())}); |
| |
| std::map<std::string, std::string> options{ |
| {kTantivyWriteTokenizer, "default"}, |
| }; |
| ASSERT_OK_AND_ASSIGN(auto writer_res, TantivyGlobalIndexWriter::Create( |
| "f0", data_type, file_writer, options, pool_)); |
| auto writer = writer_res; |
| |
| // Build an arrow batch from kEnglishDocs. |
| std::string json = "["; |
| for (std::size_t i = 0; i < sizeof(kEnglishDocs) / sizeof(kEnglishDocs[0]); ++i) { |
| if (i > 0) { |
| json += ","; |
| } |
| json += "[\""; |
| json += kEnglishDocs[i]; |
| json += "\"]"; |
| } |
| json += "]"; |
| auto array = arrow::ipc::internal::json::ArrayFromJSON(data_type, json).ValueOrDie(); |
| ::ArrowArray c_array; |
| ASSERT_TRUE(arrow::ExportArray(*array, &c_array).ok()); |
| std::vector<int64_t> relative_row_ids(array->length()); |
| for (int64_t i = 0; i < array->length(); ++i) { |
| relative_row_ids[i] = i; |
| } |
| ASSERT_TRUE(writer->AddBatch(&c_array, std::move(relative_row_ids)).ok()); |
| ASSERT_OK_AND_ASSIGN(auto metas_res, writer->Finish()); |
| ASSERT_EQ(metas_res.size(), 1u); |
| const auto& meta = metas_res.front(); |
| const std::string archive_path = meta.file_path; |
| |
| // 2) Archive header sanity: 16+ files, meta.json present, tokenizer in schema. |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<InputStream> stream, fs_->Open(archive_path)); |
| ASSERT_OK_AND_ASSIGN(auto layout_res, ArchiveLayout::Parse(stream.get())); |
| const auto& layout = layout_res; |
| bool has_meta_json = false; |
| for (std::size_t i = 0; i < layout.count; ++i) { |
| if (layout.names[i] == "meta.json") { |
| has_meta_json = true; |
| } |
| } |
| ASSERT_TRUE(has_meta_json); |
| |
| // 3) Round-trip through the cpp reader first — the reader must auto-register |
| // "default" from the schema so the search path works without passing |
| // any reader-side tokenizer config. |
| // Build a reader directly off the archive path (mirrors OpenFixture |
| // but rooted at the cpp fixtures dir). |
| ASSERT_OK_AND_ASSIGN(auto file_status, fs_->GetFileStatus(archive_path)); |
| int64_t file_size = file_status.GetLen(); |
| auto meta_bytes = std::make_shared<Bytes>(std::string("{}"), pool_.get()); |
| GlobalIndexIOMeta io_meta(archive_path, file_size, meta_bytes); |
| auto reader_factory = |
| std::make_shared<TantivyGlobalIndex>(std::map<std::string, std::string>{}); |
| auto reader_path_factory = std::make_shared<FixturePathFactory>(out_dir); |
| auto reader_file_mgr = std::make_shared<GlobalIndexFileManager>(fs_, reader_path_factory); |
| |
| auto c_schema = std::make_unique<::ArrowSchema>(); |
| ASSERT_TRUE(arrow::ExportType(*data_type, c_schema.get()).ok()); |
| |
| ASSERT_OK_AND_ASSIGN(auto reader_res, reader_factory->CreateReader( |
| c_schema.get(), reader_file_mgr, {io_meta}, pool_)); |
| auto reader = reader_res; |
| |
| // Golden expectations (identical to paimon-java's english_simple.golden.json) |
| ASSERT_EQ(RunSearchRowIds(reader, FullTextSearch::SearchType::MATCH_ALL, "apple"), |
| (std::vector<int64_t>{0, 1, 4, 7})); |
| ASSERT_EQ(RunSearchRowIds(reader, FullTextSearch::SearchType::MATCH_ALL, "apple banana"), |
| (std::vector<int64_t>{0})); |
| ASSERT_EQ(RunSearchRowIds(reader, FullTextSearch::SearchType::MATCH_ANY, "durian elderberry"), |
| (std::vector<int64_t>{1, 5, 6, 8})); |
| ASSERT_EQ(RunSearchRowIds(reader, FullTextSearch::SearchType::PHRASE, "apple banana"), |
| (std::vector<int64_t>{0})); |
| ASSERT_EQ(RunSearchRowIds(reader, FullTextSearch::SearchType::PHRASE, "banana cherry"), |
| (std::vector<int64_t>{0, 2})); |
| ASSERT_EQ(RunSearchRowIds(reader, FullTextSearch::SearchType::PREFIX, "ap"), |
| (std::vector<int64_t>{0, 1, 4, 7})); |
| ASSERT_EQ(RunSearchRowIds(reader, FullTextSearch::SearchType::WILDCARD, "*err*"), |
| (std::vector<int64_t>{0, 2, 4, 5, 6, 8, 9})); |
| ASSERT_EQ(RunSearchRowIds(reader, FullTextSearch::SearchType::MATCH_ANY, |
| "apple banana cherry durian fig grape elderberry"), |
| (std::vector<int64_t>{0, 1, 2, 3, 4, 5, 6, 7, 8, 9})); |
| } |
| |
| } // namespace paimon::tantivy::test |