mirror of
https://github.com/lancedb/lancedb.git
synced 2026-08-31 10:38:31 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d28ea445e7 | |||
| d6809a80f2 | |||
| e5a7a092bb | |||
| 2adbf791fc | |||
| 7357d63e87 | |||
| 624a75edf7 |
@@ -707,6 +707,9 @@ class LanceDBConnection(DBConnection):
|
||||
self._namespace_client_properties = namespace_client_properties
|
||||
if _inner is not None:
|
||||
self._conn = _inner
|
||||
# Native-derived wrappers resolve this in their async reconstruction
|
||||
# path so construction never synchronously re-enters LOOP.
|
||||
self._read_consistency_interval = read_consistency_interval
|
||||
self._cached_namespace_client = None
|
||||
return
|
||||
|
||||
@@ -756,11 +759,14 @@ class LanceDBConnection(DBConnection):
|
||||
# storage_options. Also, this class really shouldn't be holding any state
|
||||
# beyond _conn.
|
||||
self._conn = AsyncConnection(LOOP.run(do_connect()))
|
||||
# Keep property access synchronous so debugger introspection cannot wait on
|
||||
# the background loop while that thread is suspended at a breakpoint.
|
||||
self._read_consistency_interval = read_consistency_interval
|
||||
self._cached_namespace_client: Optional[LanceNamespace] = None
|
||||
|
||||
@property
|
||||
def read_consistency_interval(self) -> Optional[timedelta]:
|
||||
return LOOP.run(self._conn.get_read_consistency_interval())
|
||||
return self._read_consistency_interval
|
||||
|
||||
@property
|
||||
def session(self) -> Optional[Session]:
|
||||
@@ -771,8 +777,16 @@ class LanceDBConnection(DBConnection):
|
||||
return self._conn.uri
|
||||
|
||||
@classmethod
|
||||
def from_inner(cls, inner: LanceDbConnection):
|
||||
return cls(None, _inner=inner)
|
||||
def from_inner(
|
||||
cls,
|
||||
inner: LanceDbConnection,
|
||||
read_consistency_interval: Optional[timedelta],
|
||||
):
|
||||
return cls(
|
||||
None,
|
||||
read_consistency_interval=read_consistency_interval,
|
||||
_inner=inner,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}(uri={self._conn.uri!r})"
|
||||
|
||||
@@ -226,7 +226,7 @@ class PermutationBuilder:
|
||||
|
||||
async def do_execute():
|
||||
inner_tbl = await self._async.execute()
|
||||
return LanceTable.from_inner(inner_tbl)
|
||||
return await LanceTable.from_inner(inner_tbl)
|
||||
|
||||
return LOOP.run(do_execute())
|
||||
|
||||
|
||||
@@ -2182,11 +2182,15 @@ class LanceTable(Table):
|
||||
return self.name
|
||||
|
||||
@classmethod
|
||||
def from_inner(cls, tbl: LanceDBTable):
|
||||
from .db import LanceDBConnection
|
||||
async def from_inner(cls, tbl: LanceDBTable):
|
||||
from .db import AsyncConnection, LanceDBConnection
|
||||
|
||||
async_tbl = AsyncTable(tbl)
|
||||
conn = LanceDBConnection.from_inner(tbl.database())
|
||||
inner_conn = tbl.database()
|
||||
read_consistency_interval = await AsyncConnection(
|
||||
inner_conn
|
||||
).get_read_consistency_interval()
|
||||
conn = LanceDBConnection.from_inner(inner_conn, read_consistency_interval)
|
||||
return cls(
|
||||
conn,
|
||||
async_tbl.name,
|
||||
|
||||
@@ -77,6 +77,23 @@ def test_sync_repr_does_not_use_background_loop(tmp_path, monkeypatch):
|
||||
assert repr(table) == f"LanceTable(name='test', _conn={db!r})"
|
||||
|
||||
|
||||
def test_read_consistency_interval_does_not_use_background_loop(tmp_path, monkeypatch):
|
||||
from lancedb.background_loop import LOOP
|
||||
from lancedb.db import LanceDBConnection
|
||||
|
||||
consistency_interval = timedelta(seconds=5)
|
||||
db = lancedb.connect(tmp_path, read_consistency_interval=consistency_interval)
|
||||
db_from_inner = LanceDBConnection.from_inner(db._inner, consistency_interval)
|
||||
|
||||
def fail_run(*args, **kwargs):
|
||||
raise AssertionError("properties should not use the Python background loop")
|
||||
|
||||
monkeypatch.setattr(LOOP, "run", fail_run)
|
||||
|
||||
assert db.read_consistency_interval == consistency_interval
|
||||
assert db_from_inner.read_consistency_interval == consistency_interval
|
||||
|
||||
|
||||
def test_ingest_pd(tmp_path):
|
||||
db = lancedb.connect(tmp_path)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import math
|
||||
import pytest
|
||||
|
||||
from lancedb import DBConnection, Table, connect
|
||||
from lancedb.background_loop import LOOP
|
||||
from lancedb.permutation import Permutation, Permutations, permutation_builder
|
||||
|
||||
|
||||
@@ -31,6 +32,25 @@ def test_split_random_ratios(mem_db):
|
||||
assert 65 <= split_1_count <= 75 # ~70% ± tolerance
|
||||
|
||||
|
||||
def test_execute_does_not_reenter_background_loop(tmp_path, monkeypatch):
|
||||
import threading
|
||||
|
||||
db = connect(tmp_path)
|
||||
tbl = db.create_table("test_table", pa.table({"x": range(10)}))
|
||||
original_run = LOOP.run
|
||||
|
||||
def fail_on_reentry(future):
|
||||
assert threading.current_thread() is not LOOP.thread
|
||||
return original_run(future)
|
||||
|
||||
monkeypatch.setattr(LOOP, "run", fail_on_reentry)
|
||||
|
||||
permutation_tbl = permutation_builder(tbl).execute()
|
||||
|
||||
assert permutation_tbl.count_rows() == 10
|
||||
assert permutation_tbl._conn.read_consistency_interval is None
|
||||
|
||||
|
||||
def test_split_random_counts(mem_db):
|
||||
"""Test random splitting with absolute counts."""
|
||||
tbl = mem_db.create_table(
|
||||
|
||||
@@ -6,6 +6,7 @@ import os
|
||||
import sys
|
||||
import threading
|
||||
import warnings
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import date, datetime, timedelta
|
||||
from time import sleep
|
||||
from typing import List
|
||||
@@ -2124,6 +2125,27 @@ def test_delete(mem_db: DBConnection):
|
||||
assert table.to_arrow()["id"].to_pylist() == [1]
|
||||
|
||||
|
||||
def test_concurrent_deletes_are_thread_safe(mem_db: DBConnection):
|
||||
num_workers = 8
|
||||
table = mem_db.create_table(
|
||||
"my_table", data=[{"id": row_id} for row_id in range(num_workers)]
|
||||
)
|
||||
barrier = threading.Barrier(num_workers)
|
||||
|
||||
def delete(row_id: int):
|
||||
barrier.wait()
|
||||
return table.delete(f"id = {row_id}")
|
||||
|
||||
with ThreadPoolExecutor(max_workers=num_workers) as pool:
|
||||
results = list(pool.map(delete, range(num_workers)))
|
||||
|
||||
assert all(result.num_deleted_rows == 1 for result in results)
|
||||
assert sorted(result.version for result in results) == list(
|
||||
range(2, num_workers + 2)
|
||||
)
|
||||
assert table.count_rows() == 0
|
||||
|
||||
|
||||
def test_delete_expr(mem_db: DBConnection):
|
||||
table = mem_db.create_table(
|
||||
"my_table",
|
||||
|
||||
@@ -745,6 +745,9 @@ impl Table {
|
||||
|
||||
#[allow(private_interfaces)]
|
||||
pub fn delete(self_: PyRef<'_, Self>, condition: PredicateArg) -> PyResult<Bound<'_, PyAny>> {
|
||||
// Do not hold the Python borrow across the await. The cloned Rust table
|
||||
// handle is thread-safe and allows deletes on the same Python table to
|
||||
// run concurrently without PyO3 reporting "Already borrowed".
|
||||
let inner = self_.inner_ref()?.clone();
|
||||
future_into_py(self_.py(), async move {
|
||||
let result = match &condition {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use arrow_schema::DataType;
|
||||
use lance::dataset::UpdateBuilder as LanceUpdateBuilder;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -84,10 +85,11 @@ pub(crate) async fn execute_update(
|
||||
let dataset = table.dataset.get().await?;
|
||||
|
||||
// 2. Initialize the Lance Core builder
|
||||
let mut builder = LanceUpdateBuilder::new(dataset);
|
||||
let mut builder = LanceUpdateBuilder::new(dataset.clone());
|
||||
|
||||
// 3. Apply the filter (WHERE clause)
|
||||
if let Some(predicate) = update.filter {
|
||||
let predicate = safe_update_filter(&predicate, dataset.as_ref());
|
||||
builder = builder.update_where(&predicate)?;
|
||||
}
|
||||
|
||||
@@ -109,9 +111,61 @@ pub(crate) async fn execute_update(
|
||||
})
|
||||
}
|
||||
|
||||
/// Keep vulnerable legacy updates on the early-materialization scan path.
|
||||
///
|
||||
/// Late materialization uses `TakeExec` to concatenate values read from multiple
|
||||
/// fragments. That can overflow a single 32-bit-offset array. Lance's update
|
||||
/// builder does not currently expose its scanner's materialization controls, so
|
||||
/// cast the predicate to an integer before comparing it with `1`. Lance's
|
||||
/// scalar-index extractor does not unwrap non-literal casts, keeping every
|
||||
/// supported predicate out of the vulnerable late-materialization plan.
|
||||
///
|
||||
/// Keep the original SQL verbatim instead of parsing and serializing it. Newlines
|
||||
/// isolate the generated syntax from a trailing line comment in the predicate.
|
||||
///
|
||||
/// This compatibility fallback is intentionally limited to legacy storage. V2
|
||||
/// readers do not use the affected materialization path and keep their original
|
||||
/// filter expression and indexed plan.
|
||||
fn safe_update_filter(predicate: &str, dataset: &lance::Dataset) -> String {
|
||||
let has_offset_columns = dataset
|
||||
.schema()
|
||||
.fields
|
||||
.iter()
|
||||
.any(|field| has_32_bit_offsets(&field.data_type()));
|
||||
|
||||
if !dataset.manifest().should_use_legacy_format() || !has_offset_columns {
|
||||
return predicate.to_owned();
|
||||
}
|
||||
|
||||
format!("CAST((\n{predicate}\n) AS INT) = 1")
|
||||
}
|
||||
|
||||
fn has_32_bit_offsets(data_type: &DataType) -> bool {
|
||||
match data_type {
|
||||
DataType::Binary
|
||||
| DataType::Utf8
|
||||
| DataType::List(_)
|
||||
| DataType::ListView(_)
|
||||
| DataType::Map(_, _)
|
||||
| DataType::Union(_, _) => true,
|
||||
DataType::FixedSizeList(field, _)
|
||||
| DataType::LargeList(field)
|
||||
| DataType::LargeListView(field) => has_32_bit_offsets(field.data_type()),
|
||||
DataType::Struct(fields) => fields
|
||||
.iter()
|
||||
.any(|field| has_32_bit_offsets(field.data_type())),
|
||||
DataType::Dictionary(_, values) => has_32_bit_offsets(values),
|
||||
DataType::RunEndEncoded(_, values) => has_32_bit_offsets(values.data_type()),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::connect;
|
||||
use crate::connection::LanceFileVersion;
|
||||
use crate::database::listing::{ListingDatabaseOptions, NewTableConfig};
|
||||
use crate::index::{Index, scalar::BTreeIndexBuilder};
|
||||
use crate::query::QueryBase;
|
||||
use crate::query::{ExecutableQuery, Select};
|
||||
use arrow_array::{
|
||||
@@ -122,9 +176,18 @@ mod tests {
|
||||
use arrow_data::ArrayDataBuilder;
|
||||
use arrow_schema::{ArrowError, DataType, Field, Schema, TimeUnit};
|
||||
use futures::TryStreamExt;
|
||||
use lance::io::exec::Planner;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
fn contains_take(plan: &dyn datafusion_physical_plan::ExecutionPlan) -> bool {
|
||||
plan.name() == "TakeExec"
|
||||
|| plan
|
||||
.children()
|
||||
.iter()
|
||||
.any(|child| contains_take(child.as_ref()))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_all_types() {
|
||||
let conn = connect("memory://")
|
||||
@@ -409,6 +472,301 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_materializes_offset_columns_before_filter() {
|
||||
let batch = record_batch!(
|
||||
("id", Int32, [0, 1, 2, 3]),
|
||||
(
|
||||
"split",
|
||||
Utf8,
|
||||
[Some("test"), None, Some("test"), Some("train")]
|
||||
),
|
||||
("payload", Utf8, ["a", "b", "c", "d"])
|
||||
)
|
||||
.unwrap();
|
||||
let conn = connect("memory://")
|
||||
.database_options(&ListingDatabaseOptions {
|
||||
new_table_config: NewTableConfig {
|
||||
data_storage_version: Some(LanceFileVersion::Legacy),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
let table = conn
|
||||
.create_table("offset_table", batch.clone())
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
table.add(batch).execute().await.unwrap();
|
||||
table
|
||||
.create_index(&["split"], Index::BTree(BTreeIndexBuilder::default()))
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let dataset = table.dataset().unwrap().get().await.unwrap();
|
||||
let planner = Planner::new(Arc::new(dataset.schema().into()));
|
||||
|
||||
let filter = planner.parse_filter("split = 'test'").unwrap();
|
||||
let filter = planner.optimize_expr(filter).unwrap();
|
||||
let mut scanner = dataset.scan();
|
||||
scanner.with_row_id().filter_expr(filter);
|
||||
let explanation = scanner.explain_plan(false).await.unwrap();
|
||||
let plan = scanner.create_plan().await.unwrap();
|
||||
assert!(
|
||||
contains_take(plan.as_ref()),
|
||||
"test setup must late-materialize payload:\n{explanation}"
|
||||
);
|
||||
|
||||
let guarded_filter = super::safe_update_filter("split = 'test'", dataset.as_ref());
|
||||
let filter = planner.parse_filter(&guarded_filter).unwrap();
|
||||
let filter = planner.optimize_expr(filter).unwrap();
|
||||
let mut scanner = dataset.scan();
|
||||
scanner.with_row_id().filter_expr(filter);
|
||||
let explanation = scanner.explain_plan(false).await.unwrap();
|
||||
let plan = scanner.create_plan().await.unwrap();
|
||||
|
||||
// Regression test for #1291: the payload must be read by the scan, not
|
||||
// concatenated across fragments by a late-materializing TakeExec.
|
||||
assert!(
|
||||
!contains_take(plan.as_ref()),
|
||||
"unexpected late materialization:\n{explanation}"
|
||||
);
|
||||
|
||||
let result = table
|
||||
.update()
|
||||
.only_if("split = 'test'")
|
||||
.column("split", "'TEST'")
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.rows_updated, 4);
|
||||
assert_eq!(
|
||||
table
|
||||
.count_rows(Some("split = 'TEST'".to_string()))
|
||||
.await
|
||||
.unwrap(),
|
||||
4
|
||||
);
|
||||
assert_eq!(
|
||||
table
|
||||
.count_rows(Some("payload IN ('a', 'b', 'c', 'd')".to_string()))
|
||||
.await
|
||||
.unwrap(),
|
||||
8
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_v2_keeps_indexed_plan() {
|
||||
let batch = record_batch!(
|
||||
("id", Int32, [0, 1, 2, 3]),
|
||||
("split", Utf8, ["test", "train", "test", "train"]),
|
||||
("payload", Utf8, ["a", "b", "c", "d"])
|
||||
)
|
||||
.unwrap();
|
||||
let conn = connect("memory://")
|
||||
.database_options(&ListingDatabaseOptions {
|
||||
new_table_config: NewTableConfig {
|
||||
data_storage_version: Some(LanceFileVersion::V2_0),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
let table = conn
|
||||
.create_table("v2_offset_table", batch.clone())
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
table.add(batch).execute().await.unwrap();
|
||||
table
|
||||
.create_index(&["split"], Index::BTree(BTreeIndexBuilder::default()))
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let dataset = table.dataset().unwrap().get().await.unwrap();
|
||||
let predicate = "split = 'test'";
|
||||
let update_filter = super::safe_update_filter(predicate, dataset.as_ref());
|
||||
assert_eq!(update_filter, predicate);
|
||||
|
||||
let planner = Planner::new(Arc::new(dataset.schema().into()));
|
||||
let filter = planner.parse_filter(&update_filter).unwrap();
|
||||
let filter = planner.optimize_expr(filter).unwrap();
|
||||
let mut scanner = dataset.scan();
|
||||
scanner.with_row_id().filter_expr(filter);
|
||||
let explanation = scanner.explain_plan(false).await.unwrap();
|
||||
let plan = scanner.create_plan().await.unwrap();
|
||||
assert!(
|
||||
explanation.contains("ScalarIndexQuery"),
|
||||
"v2 plan unexpectedly lost its scalar index:\n{explanation}"
|
||||
);
|
||||
assert!(
|
||||
!contains_take(plan.as_ref()),
|
||||
"v2 plan unexpectedly used the legacy TakeExec path:\n{explanation}"
|
||||
);
|
||||
|
||||
let result = table
|
||||
.update()
|
||||
.only_if(predicate)
|
||||
.column("split", "'TEST'")
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.rows_updated, 4);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_accepts_trailing_comment_filter() {
|
||||
let conn = connect("memory://")
|
||||
.database_options(&ListingDatabaseOptions {
|
||||
new_table_config: NewTableConfig {
|
||||
data_storage_version: Some(LanceFileVersion::Legacy),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
let batch = record_batch!(("id", Int32, [1, 2]), ("payload", Utf8, ["a", "b"])).unwrap();
|
||||
let table = conn
|
||||
.create_table("trailing_comment", batch)
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let predicate = "id = 1 -- valid trailing comment";
|
||||
assert_eq!(table.count_rows(Some(predicate.into())).await.unwrap(), 1);
|
||||
let result = table
|
||||
.update()
|
||||
.only_if(predicate)
|
||||
.column("payload", "'updated'")
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.rows_updated, 1);
|
||||
assert_eq!(
|
||||
table
|
||||
.count_rows(Some("payload = 'updated'".into()))
|
||||
.await
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_boolean_index_uses_early_materialization() {
|
||||
let conn = connect("memory://")
|
||||
.database_options(&ListingDatabaseOptions {
|
||||
new_table_config: NewTableConfig {
|
||||
data_storage_version: Some(LanceFileVersion::Legacy),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
let batch = record_batch!(
|
||||
("flag", Boolean, [true, false]),
|
||||
("payload", Utf8, ["a", "b"])
|
||||
)
|
||||
.unwrap();
|
||||
let table = conn
|
||||
.create_table("boolean_index", batch.clone())
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
table.add(batch).execute().await.unwrap();
|
||||
table
|
||||
.create_index(&["flag"], Index::BTree(BTreeIndexBuilder::default()))
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let dataset = table.dataset().unwrap().get().await.unwrap();
|
||||
let guarded_filter = super::safe_update_filter("flag", dataset.as_ref());
|
||||
let mut scanner = dataset.scan();
|
||||
scanner.with_row_id().filter(&guarded_filter).unwrap();
|
||||
let explanation = scanner.explain_plan(false).await.unwrap();
|
||||
let plan = scanner.create_plan().await.unwrap();
|
||||
assert!(
|
||||
!contains_take(plan.as_ref()),
|
||||
"Boolean predicate retained late materialization:\n{explanation}"
|
||||
);
|
||||
assert!(
|
||||
!explanation.contains("MaterializeIndex"),
|
||||
"Boolean predicate retained scalar-index extraction:\n{explanation}"
|
||||
);
|
||||
|
||||
let result = table
|
||||
.update()
|
||||
.only_if("flag")
|
||||
.column("payload", "'updated'")
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.rows_updated, 2);
|
||||
assert_eq!(
|
||||
table
|
||||
.count_rows(Some("payload = 'updated'".into()))
|
||||
.await
|
||||
.unwrap(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_accepts_quoted_reserved_identifier() {
|
||||
let conn = connect("memory://")
|
||||
.database_options(&ListingDatabaseOptions {
|
||||
new_table_config: NewTableConfig {
|
||||
data_storage_version: Some(LanceFileVersion::Legacy),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
let batch =
|
||||
record_batch!(("select", Int32, [1, 2]), ("payload", Utf8, ["a", "b"])).unwrap();
|
||||
let table = conn
|
||||
.create_table("reserved_identifier", batch.clone())
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
table.add(batch).execute().await.unwrap();
|
||||
|
||||
let predicate = "`select` = 1";
|
||||
assert_eq!(table.count_rows(Some(predicate.into())).await.unwrap(), 2);
|
||||
let result = table
|
||||
.update()
|
||||
.only_if(predicate)
|
||||
.column("payload", "'updated'")
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.rows_updated, 2);
|
||||
assert_eq!(
|
||||
table
|
||||
.count_rows(Some("payload = 'updated'".into()))
|
||||
.await
|
||||
.unwrap(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_via_expr() {
|
||||
let conn = connect("memory://")
|
||||
|
||||
Reference in New Issue
Block a user