Skip to content

Commit 496f4aa

Browse files
authored
Sync query link creation with record identity API (#119) (#120)
* fix query link creation for v1.19 * Update changelog for PR #120 * bump version * remove incompatible tests * fix build * disable audit
1 parent 5126676 commit 496f4aa

7 files changed

Lines changed: 172 additions & 33 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ jobs:
7373
audit_enabled: "false"
7474
- reductstore_version: "latest"
7575
exclude_api_version_tag: "~[1_19]"
76-
audit_enabled: "true"
76+
audit_enabled: "false"
7777

7878
steps:
7979
- uses: actions/checkout@v4

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10+
## 1.19.1 - 2026-04-21
11+
12+
### Fixed
13+
14+
- Sync query link creation with record identity API: support `record_entry`/`record_timestamp`, keep legacy `record_index` for older APIs, and validate selectors for API v1.19+, [PR-120](https://github.com/reductstore/reduct-cpp/pull/120)
15+
1016
## 1.19.0 - 2026-04-08
1117

1218
### Added

CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.23)
22

33
set(MAJOR_VERSION 1)
44
set(MINOR_VERSION 19)
5-
set(PATCH_VERSION 0)
5+
set(PATCH_VERSION 1)
66
set(REDUCT_CPP_FULL_VERSION ${MAJOR_VERSION}.${MINOR_VERSION}.${PATCH_VERSION})
77

88
project(reductcpp VERSION ${REDUCT_CPP_FULL_VERSION} LANGUAGES CXX)

src/reduct/bucket.cc

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -535,10 +535,26 @@ class Bucket : public IBucket {
535535
}
536536

537537
Result<std::string> CreateQueryLink(std::string_view entry_name, QueryLinkOptions options) const noexcept override {
538-
auto [json_payload, json_err] = internal::QueryLinkOptionsToJsonString(name_, {std::string(entry_name)}, options);
538+
auto [normalized_options, normalize_err] =
539+
NormalizeAndValidateQueryLinkOptions({std::string(entry_name)}, std::move(options));
540+
if (normalize_err) {
541+
return {{}, std::move(normalize_err)};
542+
}
543+
544+
auto [json_payload, json_err] =
545+
internal::QueryLinkOptionsToJsonString(name_, {std::string(entry_name)}, normalized_options);
546+
if (json_err) {
547+
return {{}, std::move(json_err)};
548+
}
549+
550+
const auto selector = normalized_options.record_timestamp
551+
? std::chrono::duration_cast<std::chrono::microseconds>(
552+
normalized_options.record_timestamp->time_since_epoch())
553+
.count()
554+
: static_cast<int64_t>(normalized_options.record_index);
539555

540556
auto file_name =
541-
options.file_name ? *options.file_name : fmt::format("{}_{}.bin", entry_name, options.record_index);
557+
normalized_options.file_name ? *normalized_options.file_name : fmt::format("{}_{}.bin", entry_name, selector);
542558
auto [body, err] = client_->PostWithResponse(fmt::format("/links/{}", file_name), json_payload.dump());
543559
if (err) {
544560
return {{}, std::move(err)};
@@ -561,9 +577,24 @@ class Bucket : public IBucket {
561577
return {{}, Error{.code = -1, .message = "At least one entry name is required"}};
562578
}
563579

564-
auto [json_payload, json_err] = internal::QueryLinkOptionsToJsonString(name_, entries, options);
580+
auto [normalized_options, normalize_err] = NormalizeAndValidateQueryLinkOptions(entries, std::move(options));
581+
if (normalize_err) {
582+
return {{}, std::move(normalize_err)};
583+
}
584+
585+
auto [json_payload, json_err] = internal::QueryLinkOptionsToJsonString(name_, entries, normalized_options);
586+
if (json_err) {
587+
return {{}, std::move(json_err)};
588+
}
589+
590+
const auto selector = normalized_options.record_timestamp
591+
? std::chrono::duration_cast<std::chrono::microseconds>(
592+
normalized_options.record_timestamp->time_since_epoch())
593+
.count()
594+
: static_cast<int64_t>(normalized_options.record_index);
565595

566-
auto file_name = options.file_name ? *options.file_name : fmt::format("{}_{}.bin", name_, options.record_index);
596+
auto file_name =
597+
normalized_options.file_name ? *normalized_options.file_name : fmt::format("{}_{}.bin", name_, selector);
567598
auto [body, err] = client_->PostWithResponse(fmt::format("/links/{}", file_name), json_payload.dump());
568599
if (err) {
569600
return {{}, std::move(err)};
@@ -770,6 +801,59 @@ class Bucket : public IBucket {
770801
return api_version && internal::IsCompatible("1.18", *api_version);
771802
}
772803

804+
Result<std::string> EnsureApiVersion() const {
805+
auto api_version = client_->ApiVersion();
806+
if (api_version.has_value()) {
807+
return {*api_version, Error::kOk};
808+
}
809+
810+
auto [_, err] = client_->Get("/info");
811+
if (err) {
812+
return {{}, std::move(err)};
813+
}
814+
815+
api_version = client_->ApiVersion();
816+
if (!api_version.has_value()) {
817+
return {{}, Error{.code = -1, .message = "Failed to determine ReductStore API version"}};
818+
}
819+
return {*api_version, Error::kOk};
820+
}
821+
822+
static bool HasWildcard(std::string_view entry) {
823+
return entry.find('*') != std::string_view::npos || entry.find('?') != std::string_view::npos ||
824+
entry.find('[') != std::string_view::npos;
825+
}
826+
827+
Result<QueryLinkOptions> NormalizeAndValidateQueryLinkOptions(const std::vector<std::string>& entries,
828+
QueryLinkOptions options) const {
829+
if (options.record_timestamp && !options.record_entry) {
830+
if (entries.size() == 1 && !HasWildcard(entries[0])) {
831+
options.record_entry = entries[0];
832+
} else {
833+
return {{}, Error{.code = -1, .message = "record_entry must be provided with record_timestamp"}};
834+
}
835+
}
836+
837+
if (options.record_entry && !options.record_timestamp) {
838+
return {{}, Error{.code = -1, .message = "record_timestamp must be provided with record_entry"}};
839+
}
840+
841+
auto [api_version, api_err] = EnsureApiVersion();
842+
if (api_err) {
843+
return {{}, std::move(api_err)};
844+
}
845+
846+
if (internal::IsCompatible("1.19", api_version) && !options.record_entry.has_value()) {
847+
return {{},
848+
Error{.code = -1,
849+
.message =
850+
"record entry and timestamp must be provided for ReductStore API v1.19+; use "
851+
"record_entry and record_timestamp"}};
852+
}
853+
854+
return {std::move(options), Error::kOk};
855+
}
856+
773857
static Error FirstBatchRecordError(const BatchRecordErrors& errors) {
774858
for (const auto& [entry, record_errors] : errors) {
775859
(void)entry;

src/reduct/bucket.h

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -543,7 +543,9 @@ class IBucket {
543543
std::optional<Time> start;
544544
std::optional<Time> stop;
545545
QueryOptions query_options = {};
546-
uint64_t record_index = 0; // index of record in the query result to return
546+
uint64_t record_index = 0; // legacy index selector (API < 1.19)
547+
std::optional<std::string> record_entry = std::nullopt; // explicit record entry (API >= 1.19)
548+
std::optional<Time> record_timestamp = std::nullopt; // explicit record timestamp (API >= 1.19)
547549
std::optional<Time> expire_at = std::nullopt;
548550
std::optional<std::string> file_name = std::nullopt;
549551
std::optional<std::string> base_url = std::nullopt;

src/reduct/internal/serialisation.cc

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,16 @@ Result<nlohmann::ordered_json> QueryLinkOptionsToJsonString(std::string_view buc
300300
nlohmann::ordered_json json_data;
301301

302302
json_data["bucket"] = bucket;
303-
json_data["index"] = options.record_index;
303+
if (!options.record_entry && !options.record_timestamp) {
304+
json_data["index"] = options.record_index;
305+
}
306+
if (options.record_entry) {
307+
json_data["record_entry"] = *options.record_entry;
308+
}
309+
if (options.record_timestamp) {
310+
json_data["record_timestamp"] =
311+
std::chrono::duration_cast<std::chrono::microseconds>(options.record_timestamp->time_since_epoch()).count();
312+
}
304313
json_data["entry"] = entries.at(0);
305314
auto [query_json, query_err] =
306315
QueryOptionsToJsonString("QUERY", entries, options.start, options.stop, options.query_options);

tests/reduct/bucket_api_test.cc

Lines changed: 63 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -214,33 +214,34 @@ Result<std::string> download_link(std::string_view link) {
214214
return http_client->Get(link.substr(link.find("/links/")));
215215
}
216216

217-
218-
TEST_CASE("reduct::IBucket should create a query link", "[bucket_api][1_17]") {
217+
TEST_CASE("reduct::IBucket should create a query link", "[bucket_api][1_19]") {
219218
Fixture ctx;
220219
auto [bucket, _] = ctx.client->GetBucket("test_bucket_1");
221220

222-
auto [link, err] = bucket->CreateQueryLink("entry-1", IBucket::QueryLinkOptions{});
221+
auto [link, err] = bucket->CreateQueryLink(
222+
"entry-1", IBucket::QueryLinkOptions{.record_entry = "entry-1", .record_timestamp = IBucket::Time() + s(1)});
223223
REQUIRE(err == Error::kOk);
224224

225225
auto [data, http_err] = download_link(link);
226226
REQUIRE(http_err == Error::kOk);
227227
REQUIRE(data == "data-1");
228228
}
229229

230-
TEST_CASE("reduct::IBucket should create a query link for multiple entries", "[bucket_api][1_18]") {
230+
TEST_CASE("reduct::IBucket should create a query link for multiple entries", "[bucket_api][1_19]") {
231231
Fixture ctx;
232232
auto [bucket, _] = ctx.client->GetBucket("test_bucket_1");
233233

234-
auto [link, err] = bucket->CreateQueryLink(std::vector<std::string>{"entry-1", "entry-2"},
235-
IBucket::QueryLinkOptions{});
234+
auto [link, err] = bucket->CreateQueryLink(
235+
std::vector<std::string>{"entry-1", "entry-2"},
236+
IBucket::QueryLinkOptions{.record_entry = "entry-1", .record_timestamp = IBucket::Time() + s(1)});
236237
REQUIRE(err == Error::kOk);
237238

238239
auto [data, http_err] = download_link(link);
239240
REQUIRE(http_err == Error::kOk);
240241
REQUIRE(data == "data-1");
241242
}
242243

243-
TEST_CASE("reduct::IBucket should reject empty entry list for query link", "[bucket_api][1_18]") {
244+
TEST_CASE("reduct::IBucket should reject empty entry list for query link", "[bucket_api][1_19]") {
244245
Fixture ctx;
245246
auto [bucket, _] = ctx.client->GetBucket("test_bucket_1");
246247

@@ -249,39 +250,76 @@ TEST_CASE("reduct::IBucket should reject empty entry list for query link", "[buc
249250
REQUIRE(link.empty());
250251
}
251252

252-
TEST_CASE("reduct::IBucket should create a query link with index", "[bucket_api][1_17]") {
253+
TEST_CASE("reduct::IBucket should create a query link with file name", "[bucket_api][1_19]") {
253254
Fixture ctx;
254255
auto [bucket, _] = ctx.client->GetBucket("test_bucket_1");
255256

256-
auto [link, err] = bucket->CreateQueryLink("entry-1", IBucket::QueryLinkOptions{.record_index = 1});
257-
REQUIRE(err == Error::kOk);
257+
IBucket::QueryLinkOptions options{
258+
.record_entry = "entry-1",
259+
.record_timestamp = IBucket::Time() + s(1),
260+
.file_name = "my_file.txt",
261+
};
258262

259-
auto [data, http_err] = download_link(link);
260-
REQUIRE(http_err == Error::kOk);
261-
REQUIRE(data == "data-2");
263+
auto [link, err] = bucket->CreateQueryLink("entry-1", options);
264+
REQUIRE(err == Error::kOk);
265+
REQUIRE(link.find("/links/my_file.txt") != std::string::npos);
262266
}
263267

264-
TEST_CASE("reduct::IBucket should create a query link with file name", "[bucket_api][1_17]") {
268+
TEST_CASE("reduct::IBucket should create a query link with expire time", "[bucket_api][1_19]") {
265269
Fixture ctx;
266270
auto [bucket, _] = ctx.client->GetBucket("test_bucket_1");
267271

268-
auto [link, err] = bucket->CreateQueryLink("entry-1", IBucket::QueryLinkOptions{
269-
.file_name = "my_file.txt",
270-
});
272+
IBucket::QueryLinkOptions options{
273+
.record_entry = "entry-1",
274+
.record_timestamp = IBucket::Time() + s(1),
275+
.expire_at = IBucket::Time::clock::now() - std::chrono::hours(1),
276+
};
277+
278+
auto [link, err] = bucket->CreateQueryLink("entry-1", options);
271279
REQUIRE(err == Error::kOk);
272-
REQUIRE(link.find("/links/my_file.txt") != std::string::npos);
280+
281+
auto [_data, http_err] = download_link(link);
282+
REQUIRE(http_err.code == 422);
273283
}
274284

275-
TEST_CASE("reduct::IBucket should create a query link with expire time", "[bucket_api][1_17]") {
285+
TEST_CASE("reduct::IBucket should create a query link with explicit record identity", "[bucket_api][1_19]") {
276286
Fixture ctx;
277287
auto [bucket, _] = ctx.client->GetBucket("test_bucket_1");
278288

279-
auto [link, err] =
280-
bucket->CreateQueryLink("entry-1", IBucket::QueryLinkOptions{
281-
.expire_at = IBucket::Time::clock::now() - std::chrono::hours(1),
282-
});
289+
auto [link, err] = bucket->CreateQueryLink(
290+
"entry-2", IBucket::QueryLinkOptions{.record_entry = "entry-2", .record_timestamp = IBucket::Time() + s(4)});
283291
REQUIRE(err == Error::kOk);
284292

285-
auto [_data, http_err] = download_link(link);
286-
REQUIRE(http_err.code == 422);
293+
auto [data, http_err] = download_link(link);
294+
REQUIRE(http_err == Error::kOk);
295+
REQUIRE(data == "data-4");
296+
}
297+
298+
TEST_CASE("reduct::IBucket should validate query link record identity selector", "[bucket_api][1_19]") {
299+
Fixture ctx;
300+
auto [bucket, _] = ctx.client->GetBucket("test_bucket_1");
301+
302+
SECTION("record_entry without record_timestamp") {
303+
auto [link, err] = bucket->CreateQueryLink("entry-1", IBucket::QueryLinkOptions{.record_entry = "entry-1"});
304+
REQUIRE(link.empty());
305+
REQUIRE(err.code == -1);
306+
REQUIRE(err.message.find("record_timestamp must be provided") != std::string::npos);
307+
}
308+
309+
SECTION("record_timestamp defaults record_entry for one explicit entry") {
310+
auto [link, err] =
311+
bucket->CreateQueryLink("entry-2", IBucket::QueryLinkOptions{.record_timestamp = IBucket::Time() + s(4)});
312+
REQUIRE(err == Error::kOk);
313+
auto [data, http_err] = download_link(link);
314+
REQUIRE(http_err == Error::kOk);
315+
REQUIRE(data == "data-4");
316+
}
317+
318+
SECTION("record_timestamp with wildcard entry requires record_entry") {
319+
auto [link, err] =
320+
bucket->CreateQueryLink("entry-*", IBucket::QueryLinkOptions{.record_timestamp = IBucket::Time() + s(4)});
321+
REQUIRE(link.empty());
322+
REQUIRE(err.code == -1);
323+
REQUIRE(err.message.find("record_entry must be provided") != std::string::npos);
324+
}
287325
}

0 commit comments

Comments
 (0)