mirror of
https://github.com/trailbaseio/trailbase.git
synced 2026-02-22 18:58:48 -06:00
Breaking: nest records in output of RecordAPI.list to contain cursor now and potentially more in the future.
Also update all the client libraries to accept the new format.
This commit is contained in:
@@ -108,6 +108,17 @@ class Pagination {
|
||||
});
|
||||
}
|
||||
|
||||
class ListResponse {
|
||||
final String? cursor;
|
||||
final List<Map<String, dynamic>> records;
|
||||
|
||||
const ListResponse({this.cursor, required this.records});
|
||||
|
||||
ListResponse.fromJson(Map<String, dynamic> json)
|
||||
: cursor = json['cursor'],
|
||||
records = (json['records'] as List).cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
abstract class RecordId {
|
||||
@override
|
||||
String toString();
|
||||
@@ -278,7 +289,7 @@ class RecordApi {
|
||||
|
||||
const RecordApi(this._client, this._name);
|
||||
|
||||
Future<List<Map<String, dynamic>>> list({
|
||||
Future<ListResponse> list({
|
||||
Pagination? pagination,
|
||||
List<String>? order,
|
||||
List<String>? filters,
|
||||
@@ -310,7 +321,7 @@ class RecordApi {
|
||||
queryParams: params,
|
||||
);
|
||||
|
||||
return (response.data as List).cast<Map<String, dynamic>>();
|
||||
return ListResponse.fromJson(response.data);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> read(RecordId id) async {
|
||||
|
||||
@@ -159,25 +159,27 @@ Future<void> main() async {
|
||||
}
|
||||
|
||||
{
|
||||
final records = await api.list(
|
||||
final response = await api.list(
|
||||
filters: ['text_not_null=${messages[0]}'],
|
||||
);
|
||||
expect(records.length, 1);
|
||||
expect(records[0]['text_not_null'], messages[0]);
|
||||
expect(response.records.length, 1);
|
||||
expect(response.records[0]['text_not_null'], messages[0]);
|
||||
}
|
||||
|
||||
{
|
||||
final recordsAsc = await api.list(
|
||||
final recordsAsc = (await api.list(
|
||||
order: ['+text_not_null'],
|
||||
filters: ['text_not_null[like]=% =?&${now}'],
|
||||
);
|
||||
))
|
||||
.records;
|
||||
expect(recordsAsc.map((el) => el['text_not_null']),
|
||||
orderedEquals(messages));
|
||||
|
||||
final recordsDesc = await api.list(
|
||||
final recordsDesc = (await api.list(
|
||||
order: ['-text_not_null'],
|
||||
filters: ['text_not_null[like]=%${now}'],
|
||||
);
|
||||
))
|
||||
.records;
|
||||
expect(recordsDesc.map((el) => el['text_not_null']).toList().reversed,
|
||||
orderedEquals(messages));
|
||||
}
|
||||
|
||||
@@ -76,6 +76,26 @@ public class Pagination {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Representation of ListResponse JSON objects.
|
||||
/// </summary>
|
||||
// @JsonSerializable(explicitToJson: true)
|
||||
public class ListResponse<T> {
|
||||
/// <summary>List cursor for subsequent fetches.</summary>
|
||||
public string? cursor { get; }
|
||||
/// <summary>The actual records.</summary>
|
||||
public List<T> records { get; }
|
||||
|
||||
[JsonConstructor]
|
||||
public ListResponse(
|
||||
string? cursor,
|
||||
List<T>? records
|
||||
) {
|
||||
this.cursor = cursor;
|
||||
this.records = records ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Realtime event for change subscriptions.</summary>
|
||||
public abstract class Event {
|
||||
/// <summary>Get associated record value as JSON object.</summary>
|
||||
@@ -264,13 +284,13 @@ public class RecordApi {
|
||||
/// <param name="filters">Results filters, e.g. "col0[gte]=100".</param>
|
||||
[RequiresDynamicCode(DynamicCodeMessage)]
|
||||
[RequiresUnreferencedCode(UnreferencedCodeMessage)]
|
||||
public async Task<List<T>> List<T>(
|
||||
public async Task<ListResponse<T>> List<T>(
|
||||
Pagination? pagination,
|
||||
List<string>? order,
|
||||
List<string>? filters
|
||||
) {
|
||||
string json = await (await ListImpl(pagination, order, filters)).ReadAsStringAsync();
|
||||
return JsonSerializer.Deserialize<List<T>>(json) ?? [];
|
||||
return JsonSerializer.Deserialize<ListResponse<T>>(json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -280,14 +300,14 @@ public class RecordApi {
|
||||
/// <param name="order">Sort results by the given columns in ascending/descending order, e.g. "-col_name".</param>
|
||||
/// <param name="filters">Results filters, e.g. "col0[gte]=100".</param>
|
||||
/// <param name="jsonTypeInfo">Serialization type info for AOT mode.</param>
|
||||
public async Task<List<T>> List<T>(
|
||||
public async Task<ListResponse<T>> List<T>(
|
||||
Pagination? pagination,
|
||||
List<string>? order,
|
||||
List<string>? filters,
|
||||
JsonTypeInfo<List<T>> jsonTypeInfo
|
||||
JsonTypeInfo<ListResponse<T>> jsonTypeInfo
|
||||
) {
|
||||
string json = await (await ListImpl(pagination, order, filters)).ReadAsStringAsync();
|
||||
return JsonSerializer.Deserialize<List<T>>(json, jsonTypeInfo) ?? [];
|
||||
return JsonSerializer.Deserialize<ListResponse<T>>(json, jsonTypeInfo);
|
||||
}
|
||||
|
||||
private async Task<HttpContent> ListImpl(
|
||||
|
||||
@@ -28,7 +28,7 @@ class SimpleStrict {
|
||||
|
||||
[JsonSourceGenerationOptions(WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
[JsonSerializable(typeof(SimpleStrict))]
|
||||
[JsonSerializable(typeof(List<SimpleStrict>))]
|
||||
[JsonSerializable(typeof(ListResponse<SimpleStrict>))]
|
||||
internal partial class SerializeSimpleStrictContext : JsonSerializerContext {
|
||||
}
|
||||
|
||||
@@ -144,29 +144,31 @@ public class ClientTest : IClassFixture<ClientTestFixture> {
|
||||
}
|
||||
|
||||
{
|
||||
List<SimpleStrict> records = await api.List<SimpleStrict>(
|
||||
ListResponse<SimpleStrict> response = await api.List<SimpleStrict>(
|
||||
null,
|
||||
null,
|
||||
[$"text_not_null={messages[0]}"]
|
||||
)!;
|
||||
Assert.Single(records);
|
||||
Assert.Equal(messages[0], records[0].text_not_null);
|
||||
Assert.Single(response.records);
|
||||
Assert.Equal(messages[0], response.records[0].text_not_null);
|
||||
}
|
||||
|
||||
{
|
||||
var recordsAsc = await api.List<SimpleStrict>(
|
||||
var responseAsc = await api.List<SimpleStrict>(
|
||||
null,
|
||||
["+text_not_null"],
|
||||
[$"text_not_null[like]=% =?&{suffix}"]
|
||||
)!;
|
||||
var recordsAsc = responseAsc.records;
|
||||
Assert.Equal(messages.Count, recordsAsc.Count);
|
||||
Assert.Equal(messages, recordsAsc.ConvertAll((e) => e.text_not_null));
|
||||
|
||||
var recordsDesc = await api.List<SimpleStrict>(
|
||||
var responseDesc = await api.List<SimpleStrict>(
|
||||
null,
|
||||
["-text_not_null"],
|
||||
[$"text_not_null[like]=%{suffix}"]
|
||||
)!;
|
||||
var recordsDesc = responseDesc.records;
|
||||
Assert.Equal(messages.Count, recordsDesc.Count);
|
||||
recordsDesc.Reverse();
|
||||
Assert.Equal(messages, recordsDesc.ConvertAll((e) => e.text_not_null));
|
||||
@@ -197,13 +199,13 @@ public class ClientTest : IClassFixture<ClientTestFixture> {
|
||||
var id = ids[0];
|
||||
await api.Delete(id);
|
||||
|
||||
var records = await api.List<SimpleStrict>(
|
||||
var response = await api.List<SimpleStrict>(
|
||||
null,
|
||||
null,
|
||||
[$"text_not_null[like]=%{suffix}"]
|
||||
)!;
|
||||
|
||||
Assert.Single(records);
|
||||
Assert.Single(response.records);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,32 +235,34 @@ public class ClientTest : IClassFixture<ClientTestFixture> {
|
||||
}
|
||||
|
||||
{
|
||||
List<SimpleStrict> records = await api.List(
|
||||
ListResponse<SimpleStrict> response = await api.List(
|
||||
null,
|
||||
null,
|
||||
[$"text_not_null={messages[0]}"],
|
||||
SerializeSimpleStrictContext.Default.ListSimpleStrict
|
||||
SerializeSimpleStrictContext.Default.ListResponseSimpleStrict
|
||||
)!;
|
||||
Assert.Single(records);
|
||||
Assert.Equal(messages[0], records[0].text_not_null);
|
||||
Assert.Single(response.records);
|
||||
Assert.Equal(messages[0], response.records[0].text_not_null);
|
||||
}
|
||||
|
||||
{
|
||||
var recordsAsc = await api.List(
|
||||
var responseAsc = await api.List(
|
||||
null,
|
||||
["+text_not_null"],
|
||||
[$"text_not_null[like]=% =?&{suffix}"],
|
||||
SerializeSimpleStrictContext.Default.ListSimpleStrict
|
||||
SerializeSimpleStrictContext.Default.ListResponseSimpleStrict
|
||||
)!;
|
||||
var recordsAsc = responseAsc.records;
|
||||
Assert.Equal(messages.Count, recordsAsc.Count);
|
||||
Assert.Equal(messages, recordsAsc.ConvertAll((e) => e.text_not_null));
|
||||
|
||||
var recordsDesc = await api.List(
|
||||
var responseDesc = await api.List(
|
||||
null,
|
||||
["-text_not_null"],
|
||||
[$"text_not_null[like]=%{suffix}"],
|
||||
SerializeSimpleStrictContext.Default.ListSimpleStrict
|
||||
SerializeSimpleStrictContext.Default.ListResponseSimpleStrict
|
||||
)!;
|
||||
var recordsDesc = responseDesc.records;
|
||||
Assert.Equal(messages.Count, recordsDesc.Count);
|
||||
recordsDesc.Reverse();
|
||||
Assert.Equal(messages, recordsDesc.ConvertAll((e) => e.text_not_null));
|
||||
@@ -293,14 +297,14 @@ public class ClientTest : IClassFixture<ClientTestFixture> {
|
||||
var id = ids[0];
|
||||
await api.Delete(id);
|
||||
|
||||
var records = await api.List(
|
||||
var response = await api.List(
|
||||
null,
|
||||
null,
|
||||
[$"text_not_null[like]=%{suffix}"],
|
||||
SerializeSimpleStrictContext.Default.ListSimpleStrict
|
||||
SerializeSimpleStrictContext.Default.ListResponseSimpleStrict
|
||||
)!;
|
||||
|
||||
Assert.Single(records);
|
||||
Assert.Single(response.records);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -114,9 +114,10 @@ def test_records(trailbase: TrailBaseFixture):
|
||||
ids.append(api.create({"text_not_null": msg}))
|
||||
|
||||
if True:
|
||||
records = api.list(
|
||||
response = api.list(
|
||||
filters=[f"text_not_null={messages[0]}"],
|
||||
)
|
||||
records = response.records
|
||||
assert len(records) == 1
|
||||
assert records[0]["text_not_null"] == messages[0]
|
||||
|
||||
@@ -126,14 +127,14 @@ def test_records(trailbase: TrailBaseFixture):
|
||||
filters=[f"text_not_null[like]=% =?&{now}"],
|
||||
)
|
||||
|
||||
assert [el["text_not_null"] for el in recordsAsc] == messages
|
||||
assert [el["text_not_null"] for el in recordsAsc.records] == messages
|
||||
|
||||
recordsDesc = api.list(
|
||||
order=["-text_not_null"],
|
||||
filters=[f"text_not_null[like]=%{now}"],
|
||||
)
|
||||
|
||||
assert [el["text_not_null"] for el in recordsDesc] == list(reversed(messages))
|
||||
assert [el["text_not_null"] for el in recordsDesc.records] == list(reversed(messages))
|
||||
|
||||
if True:
|
||||
record = api.read(ids[0])
|
||||
|
||||
@@ -10,7 +10,7 @@ import json
|
||||
|
||||
from contextlib import contextmanager
|
||||
from time import time
|
||||
from typing import TypeAlias, Any
|
||||
from typing import TypeAlias, Any, cast
|
||||
|
||||
JSON: TypeAlias = dict[str, "JSON"] | list["JSON"] | str | int | float | bool | None
|
||||
|
||||
@@ -55,6 +55,24 @@ class User:
|
||||
}
|
||||
|
||||
|
||||
class ListResponse:
|
||||
cursor: str | None
|
||||
records: list[dict[str, object]]
|
||||
|
||||
def __init__(self, cursor: str | None, records: list[dict[str, object]]) -> None:
|
||||
self.cursor = cursor
|
||||
self.records = records
|
||||
|
||||
@staticmethod
|
||||
def fromJson(json: dict[str, "JSON"]) -> "ListResponse":
|
||||
cursor = json["cursor"]
|
||||
assert isinstance(cursor, str | None)
|
||||
records = json["records"]
|
||||
assert isinstance(records, list)
|
||||
|
||||
return ListResponse(cursor, cast(list[dict[str, object]], records))
|
||||
|
||||
|
||||
class Tokens:
|
||||
auth: str
|
||||
refresh: str | None
|
||||
@@ -390,7 +408,7 @@ class RecordApi:
|
||||
filters: list[str] | None = None,
|
||||
cursor: str | None = None,
|
||||
limit: int | None = None,
|
||||
) -> list[dict[str, object]]:
|
||||
) -> ListResponse:
|
||||
params: dict[str, str] = {}
|
||||
|
||||
if cursor != None:
|
||||
@@ -411,7 +429,7 @@ class RecordApi:
|
||||
params[nameOp] = value
|
||||
|
||||
response = self._client.fetch(f"{self._recordApi}/{self._name}", queryParams=params)
|
||||
return response.json()
|
||||
return ListResponse.fromJson(response.json())
|
||||
|
||||
def read(self, recordId: RecordId | str | int) -> dict[str, object]:
|
||||
id = repr(recordId) if isinstance(recordId, RecordId) else f"{recordId}"
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
//! A client library to connect to a TrailBase server via HTTP.
|
||||
//!
|
||||
//! TrailBase is a sub-millisecond, open-source application server with type-safe APIs, built-in
|
||||
//! JS/ES6/TS runtime, realtime, auth, and admin UI built on Rust, SQLite & V8.
|
||||
|
||||
#![allow(clippy::needless_return)]
|
||||
|
||||
use eventsource_stream::Eventsource;
|
||||
@@ -26,12 +31,16 @@ pub enum Error {
|
||||
Precondition(&'static str),
|
||||
}
|
||||
|
||||
/// Represents the currently logged-in user.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct User {
|
||||
pub sub: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
/// Holds the tokens minted by the server on login.
|
||||
///
|
||||
/// It is also the exact JSON serialization format.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Tokens {
|
||||
pub auth_token: String,
|
||||
@@ -53,6 +62,12 @@ pub enum DbEvent {
|
||||
Error(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ListResponse<T> {
|
||||
pub cursor: Option<String>,
|
||||
pub records: Vec<T>,
|
||||
}
|
||||
|
||||
pub trait RecordId<'a> {
|
||||
fn serialized_id(self) -> Cow<'a, str>;
|
||||
}
|
||||
@@ -151,7 +166,7 @@ impl RecordApi {
|
||||
pagination: Option<Pagination>,
|
||||
order: Option<&[&str]>,
|
||||
filters: Option<&[&str]>,
|
||||
) -> Result<Vec<T>, Error> {
|
||||
) -> Result<ListResponse<T>, Error> {
|
||||
let mut params: Vec<(Cow<'static, str>, Cow<'static, str>)> = vec![];
|
||||
if let Some(pagination) = pagination {
|
||||
if let Some(cursor) = pagination.cursor {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use futures::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use trailbase_client::{Client, DbEvent};
|
||||
use trailbase_client::{Client, DbEvent, Pagination};
|
||||
|
||||
struct Server {
|
||||
child: std::process::Child,
|
||||
@@ -122,12 +122,26 @@ async fn records_test() {
|
||||
// List one specific message.
|
||||
let filter = format!("text_not_null={}", messages[0]);
|
||||
let filters = vec![filter.as_str()];
|
||||
let records = api
|
||||
let response = api
|
||||
.list::<serde_json::Value>(None, None, Some(filters.as_slice()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(records.len(), 1);
|
||||
assert_eq!(response.records.len(), 1);
|
||||
|
||||
let second_response = api
|
||||
.list::<serde_json::Value>(
|
||||
Some(Pagination {
|
||||
cursor: response.cursor,
|
||||
limit: None,
|
||||
}),
|
||||
None,
|
||||
Some(filters.as_slice()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(second_response.records.len(), 0);
|
||||
}
|
||||
|
||||
{
|
||||
@@ -136,7 +150,8 @@ async fn records_test() {
|
||||
let records_ascending: Vec<SimpleStrict> = api
|
||||
.list(None, Some(&["+text_not_null"]), Some(&[&filter]))
|
||||
.await
|
||||
.unwrap();
|
||||
.unwrap()
|
||||
.records;
|
||||
|
||||
let messages_ascending: Vec<_> = records_ascending
|
||||
.into_iter()
|
||||
@@ -147,7 +162,8 @@ async fn records_test() {
|
||||
let records_descending: Vec<SimpleStrict> = api
|
||||
.list(None, Some(&["-text_not_null"]), Some(&[&filter]))
|
||||
.await
|
||||
.unwrap();
|
||||
.unwrap()
|
||||
.records;
|
||||
|
||||
let messages_descending: Vec<_> = records_descending
|
||||
.into_iter()
|
||||
|
||||
Reference in New Issue
Block a user