mirror of
https://github.com/asg017/sqlite-vec.git
synced 2026-04-25 08:46:49 +02:00
text knn GT/GE fixes
This commit is contained in:
parent
1ec1b89f60
commit
df29e31ddc
3 changed files with 318 additions and 30 deletions
49
sqlite-vec.c
49
sqlite-vec.c
|
|
@ -5968,19 +5968,19 @@ int vec0_metadata_filter_text(vec0_vtab * p, sqlite3_value * value, const void *
|
||||||
view = &((u8*) buffer)[i * VEC0_METADATA_TEXT_VIEW_BUFFER_LENGTH];
|
view = &((u8*) buffer)[i * VEC0_METADATA_TEXT_VIEW_BUFFER_LENGTH];
|
||||||
nPrefix = ((int*) view)[0];
|
nPrefix = ((int*) view)[0];
|
||||||
sPrefix = (char *) &view[4];
|
sPrefix = (char *) &view[4];
|
||||||
int cmpPrefix = strncmp(sPrefix, sTarget, min(nPrefix, VEC0_METADATA_TEXT_VIEW_DATA_LENGTH));
|
int cmpPrefix = strncmp(sPrefix, sTarget, min(min(nPrefix, VEC0_METADATA_TEXT_VIEW_DATA_LENGTH), nTarget));
|
||||||
|
|
||||||
// for short strings, use the prefix comparison direclty
|
if(nPrefix < VEC0_METADATA_TEXT_VIEW_DATA_LENGTH) {
|
||||||
if(nPrefix <= VEC0_METADATA_TEXT_VIEW_DATA_LENGTH) {
|
// if prefix match, check which is longer
|
||||||
bitmap_set(b, i, cmpPrefix > 0);
|
if(cmpPrefix == 0) {
|
||||||
continue;
|
bitmap_set(b, i, nPrefix > nTarget);
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
// for GT, only need to consult full string if EQ
|
|
||||||
if(cmpPrefix != 0) {
|
|
||||||
bitmap_set(b, i, cmpPrefix > 0);
|
bitmap_set(b, i, cmpPrefix > 0);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// TODO(perf): may not need to compare full text in some cases
|
||||||
|
|
||||||
rc = vec0_get_metadata_text_long_value(p, &stmt, metadata_idx, rowids[i], &nFull, &sFull);
|
rc = vec0_get_metadata_text_long_value(p, &stmt, metadata_idx, rowids[i], &nFull, &sFull);
|
||||||
if(rc != SQLITE_OK) {
|
if(rc != SQLITE_OK) {
|
||||||
|
|
@ -5996,11 +5996,32 @@ int vec0_metadata_filter_text(vec0_vtab * p, sqlite3_value * value, const void *
|
||||||
}
|
}
|
||||||
case VEC0_METADATA_OPERATOR_GE: {
|
case VEC0_METADATA_OPERATOR_GE: {
|
||||||
for(int i = 0; i < size; i++) {
|
for(int i = 0; i < size; i++) {
|
||||||
u8 * view = &((u8*) buffer)[i * VEC0_METADATA_TEXT_VIEW_BUFFER_LENGTH];
|
view = &((u8*) buffer)[i * VEC0_METADATA_TEXT_VIEW_BUFFER_LENGTH];
|
||||||
int n = ((int*) view)[0];
|
nPrefix = ((int*) view)[0];
|
||||||
char * s = (char *) &view[4];
|
sPrefix = (char *) &view[4];
|
||||||
if(n > VEC0_METADATA_TEXT_VIEW_DATA_LENGTH) {rc = SQLITE_ERROR;goto done;} /* TODO */
|
int cmpPrefix = strncmp(sPrefix, sTarget, min(min(nPrefix, VEC0_METADATA_TEXT_VIEW_DATA_LENGTH), nTarget));
|
||||||
bitmap_set(b, i, strncmp(s, sTarget, n) >= 0);
|
|
||||||
|
if(nPrefix < VEC0_METADATA_TEXT_VIEW_DATA_LENGTH) {
|
||||||
|
// if prefix match, check which is longer
|
||||||
|
if(cmpPrefix == 0) {
|
||||||
|
bitmap_set(b, i, nPrefix >= nTarget);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
bitmap_set(b, i, cmpPrefix >= 0);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// TODO(perf): may not need to compare full text in some cases
|
||||||
|
|
||||||
|
rc = vec0_get_metadata_text_long_value(p, &stmt, metadata_idx, rowids[i], &nFull, &sFull);
|
||||||
|
if(rc != SQLITE_OK) {
|
||||||
|
goto done;
|
||||||
|
}
|
||||||
|
if(nPrefix != nFull) {
|
||||||
|
rc = SQLITE_ERROR;
|
||||||
|
goto done;
|
||||||
|
}
|
||||||
|
bitmap_set(b, i, strncmp(sFull, sTarget, nFull) >= 0);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -617,6 +617,13 @@
|
||||||
]),
|
]),
|
||||||
})
|
})
|
||||||
# ---
|
# ---
|
||||||
|
# name: test_long_text_knn[eq-bb]
|
||||||
|
OrderedDict({
|
||||||
|
'sql': "select rowid, name, distance from v where vector match '[100]' and k = 5 and name = ?",
|
||||||
|
'rows': list([
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
# ---
|
||||||
# name: test_long_text_knn[eq-bbbb]
|
# name: test_long_text_knn[eq-bbbb]
|
||||||
OrderedDict({
|
OrderedDict({
|
||||||
'sql': "select rowid, name, distance from v where vector match '[100]' and k = 5 and name = ?",
|
'sql': "select rowid, name, distance from v where vector match '[100]' and k = 5 and name = ?",
|
||||||
|
|
@ -629,6 +636,13 @@
|
||||||
]),
|
]),
|
||||||
})
|
})
|
||||||
# ---
|
# ---
|
||||||
|
# name: test_long_text_knn[eq-bbbbbb]
|
||||||
|
OrderedDict({
|
||||||
|
'sql': "select rowid, name, distance from v where vector match '[100]' and k = 5 and name = ?",
|
||||||
|
'rows': list([
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
# ---
|
||||||
# name: test_long_text_knn[eq-bbbbbbbbbbbb_aaa]
|
# name: test_long_text_knn[eq-bbbbbbbbbbbb_aaa]
|
||||||
OrderedDict({
|
OrderedDict({
|
||||||
'sql': "select rowid, name, distance from v where vector match '[100]' and k = 5 and name = ?",
|
'sql': "select rowid, name, distance from v where vector match '[100]' and k = 5 and name = ?",
|
||||||
|
|
@ -662,34 +676,175 @@
|
||||||
]),
|
]),
|
||||||
})
|
})
|
||||||
# ---
|
# ---
|
||||||
|
# name: test_long_text_knn[ge-bb]
|
||||||
|
OrderedDict({
|
||||||
|
'sql': "select rowid, name, distance from v where vector match '[100]' and k = 5 and name >= ?",
|
||||||
|
'rows': list([
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 6,
|
||||||
|
'name': 'cccccccccccc_ccc',
|
||||||
|
'distance': 94.0,
|
||||||
|
}),
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 5,
|
||||||
|
'name': 'cccc',
|
||||||
|
'distance': 95.0,
|
||||||
|
}),
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 4,
|
||||||
|
'name': 'bbbbbbbbbbbb_bbb',
|
||||||
|
'distance': 96.0,
|
||||||
|
}),
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 3,
|
||||||
|
'name': 'bbbb',
|
||||||
|
'distance': 97.0,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
# ---
|
||||||
# name: test_long_text_knn[ge-bbbb]
|
# name: test_long_text_knn[ge-bbbb]
|
||||||
dict({
|
OrderedDict({
|
||||||
'error': 'OperationalError',
|
'sql': "select rowid, name, distance from v where vector match '[100]' and k = 5 and name >= ?",
|
||||||
'message': 'Could not filter metadata fields',
|
'rows': list([
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 6,
|
||||||
|
'name': 'cccccccccccc_ccc',
|
||||||
|
'distance': 94.0,
|
||||||
|
}),
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 5,
|
||||||
|
'name': 'cccc',
|
||||||
|
'distance': 95.0,
|
||||||
|
}),
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 4,
|
||||||
|
'name': 'bbbbbbbbbbbb_bbb',
|
||||||
|
'distance': 96.0,
|
||||||
|
}),
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 3,
|
||||||
|
'name': 'bbbb',
|
||||||
|
'distance': 97.0,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# name: test_long_text_knn[ge-bbbbbb]
|
||||||
|
OrderedDict({
|
||||||
|
'sql': "select rowid, name, distance from v where vector match '[100]' and k = 5 and name >= ?",
|
||||||
|
'rows': list([
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 6,
|
||||||
|
'name': 'cccccccccccc_ccc',
|
||||||
|
'distance': 94.0,
|
||||||
|
}),
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 5,
|
||||||
|
'name': 'cccc',
|
||||||
|
'distance': 95.0,
|
||||||
|
}),
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 4,
|
||||||
|
'name': 'bbbbbbbbbbbb_bbb',
|
||||||
|
'distance': 96.0,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
})
|
})
|
||||||
# ---
|
# ---
|
||||||
# name: test_long_text_knn[ge-bbbbbbbbbbbb_aaa]
|
# name: test_long_text_knn[ge-bbbbbbbbbbbb_aaa]
|
||||||
dict({
|
OrderedDict({
|
||||||
'error': 'OperationalError',
|
'sql': "select rowid, name, distance from v where vector match '[100]' and k = 5 and name >= ?",
|
||||||
'message': 'Could not filter metadata fields',
|
'rows': list([
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 6,
|
||||||
|
'name': 'cccccccccccc_ccc',
|
||||||
|
'distance': 94.0,
|
||||||
|
}),
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 5,
|
||||||
|
'name': 'cccc',
|
||||||
|
'distance': 95.0,
|
||||||
|
}),
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 4,
|
||||||
|
'name': 'bbbbbbbbbbbb_bbb',
|
||||||
|
'distance': 96.0,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
})
|
})
|
||||||
# ---
|
# ---
|
||||||
# name: test_long_text_knn[ge-bbbbbbbbbbbb_bbb]
|
# name: test_long_text_knn[ge-bbbbbbbbbbbb_bbb]
|
||||||
dict({
|
OrderedDict({
|
||||||
'error': 'OperationalError',
|
'sql': "select rowid, name, distance from v where vector match '[100]' and k = 5 and name >= ?",
|
||||||
'message': 'Could not filter metadata fields',
|
'rows': list([
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 6,
|
||||||
|
'name': 'cccccccccccc_ccc',
|
||||||
|
'distance': 94.0,
|
||||||
|
}),
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 5,
|
||||||
|
'name': 'cccc',
|
||||||
|
'distance': 95.0,
|
||||||
|
}),
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 4,
|
||||||
|
'name': 'bbbbbbbbbbbb_bbb',
|
||||||
|
'distance': 96.0,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
})
|
})
|
||||||
# ---
|
# ---
|
||||||
# name: test_long_text_knn[ge-bbbbbbbbbbbb_ccc]
|
# name: test_long_text_knn[ge-bbbbbbbbbbbb_ccc]
|
||||||
dict({
|
OrderedDict({
|
||||||
'error': 'OperationalError',
|
'sql': "select rowid, name, distance from v where vector match '[100]' and k = 5 and name >= ?",
|
||||||
'message': 'Could not filter metadata fields',
|
'rows': list([
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 6,
|
||||||
|
'name': 'cccccccccccc_ccc',
|
||||||
|
'distance': 94.0,
|
||||||
|
}),
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 5,
|
||||||
|
'name': 'cccc',
|
||||||
|
'distance': 95.0,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
})
|
})
|
||||||
# ---
|
# ---
|
||||||
# name: test_long_text_knn[ge-longlonglonglonglonglonglong]
|
# name: test_long_text_knn[ge-longlonglonglonglonglonglong]
|
||||||
dict({
|
OrderedDict({
|
||||||
'error': 'OperationalError',
|
'sql': "select rowid, name, distance from v where vector match '[100]' and k = 5 and name >= ?",
|
||||||
'message': 'Could not filter metadata fields',
|
'rows': list([
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# name: test_long_text_knn[gt-bb]
|
||||||
|
OrderedDict({
|
||||||
|
'sql': "select rowid, name, distance from v where vector match '[100]' and k = 5 and name > ?",
|
||||||
|
'rows': list([
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 6,
|
||||||
|
'name': 'cccccccccccc_ccc',
|
||||||
|
'distance': 94.0,
|
||||||
|
}),
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 5,
|
||||||
|
'name': 'cccc',
|
||||||
|
'distance': 95.0,
|
||||||
|
}),
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 4,
|
||||||
|
'name': 'bbbbbbbbbbbb_bbb',
|
||||||
|
'distance': 96.0,
|
||||||
|
}),
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 3,
|
||||||
|
'name': 'bbbb',
|
||||||
|
'distance': 97.0,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
})
|
})
|
||||||
# ---
|
# ---
|
||||||
# name: test_long_text_knn[gt-bbbb]
|
# name: test_long_text_knn[gt-bbbb]
|
||||||
|
|
@ -714,6 +869,28 @@
|
||||||
]),
|
]),
|
||||||
})
|
})
|
||||||
# ---
|
# ---
|
||||||
|
# name: test_long_text_knn[gt-bbbbbb]
|
||||||
|
OrderedDict({
|
||||||
|
'sql': "select rowid, name, distance from v where vector match '[100]' and k = 5 and name > ?",
|
||||||
|
'rows': list([
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 6,
|
||||||
|
'name': 'cccccccccccc_ccc',
|
||||||
|
'distance': 94.0,
|
||||||
|
}),
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 5,
|
||||||
|
'name': 'cccc',
|
||||||
|
'distance': 95.0,
|
||||||
|
}),
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 4,
|
||||||
|
'name': 'bbbbbbbbbbbb_bbb',
|
||||||
|
'distance': 96.0,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
# ---
|
||||||
# name: test_long_text_knn[gt-bbbbbbbbbbbb_aaa]
|
# name: test_long_text_knn[gt-bbbbbbbbbbbb_aaa]
|
||||||
OrderedDict({
|
OrderedDict({
|
||||||
'sql': "select rowid, name, distance from v where vector match '[100]' and k = 5 and name > ?",
|
'sql': "select rowid, name, distance from v where vector match '[100]' and k = 5 and name > ?",
|
||||||
|
|
@ -777,12 +954,24 @@
|
||||||
]),
|
]),
|
||||||
})
|
})
|
||||||
# ---
|
# ---
|
||||||
|
# name: test_long_text_knn[le-bb]
|
||||||
|
dict({
|
||||||
|
'error': 'OperationalError',
|
||||||
|
'message': 'Could not filter metadata fields',
|
||||||
|
})
|
||||||
|
# ---
|
||||||
# name: test_long_text_knn[le-bbbb]
|
# name: test_long_text_knn[le-bbbb]
|
||||||
dict({
|
dict({
|
||||||
'error': 'OperationalError',
|
'error': 'OperationalError',
|
||||||
'message': 'Could not filter metadata fields',
|
'message': 'Could not filter metadata fields',
|
||||||
})
|
})
|
||||||
# ---
|
# ---
|
||||||
|
# name: test_long_text_knn[le-bbbbbb]
|
||||||
|
dict({
|
||||||
|
'error': 'OperationalError',
|
||||||
|
'message': 'Could not filter metadata fields',
|
||||||
|
})
|
||||||
|
# ---
|
||||||
# name: test_long_text_knn[le-bbbbbbbbbbbb_aaa]
|
# name: test_long_text_knn[le-bbbbbbbbbbbb_aaa]
|
||||||
dict({
|
dict({
|
||||||
'error': 'OperationalError',
|
'error': 'OperationalError',
|
||||||
|
|
@ -807,12 +996,24 @@
|
||||||
'message': 'Could not filter metadata fields',
|
'message': 'Could not filter metadata fields',
|
||||||
})
|
})
|
||||||
# ---
|
# ---
|
||||||
|
# name: test_long_text_knn[lt-bb]
|
||||||
|
dict({
|
||||||
|
'error': 'OperationalError',
|
||||||
|
'message': 'Could not filter metadata fields',
|
||||||
|
})
|
||||||
|
# ---
|
||||||
# name: test_long_text_knn[lt-bbbb]
|
# name: test_long_text_knn[lt-bbbb]
|
||||||
dict({
|
dict({
|
||||||
'error': 'OperationalError',
|
'error': 'OperationalError',
|
||||||
'message': 'Could not filter metadata fields',
|
'message': 'Could not filter metadata fields',
|
||||||
})
|
})
|
||||||
# ---
|
# ---
|
||||||
|
# name: test_long_text_knn[lt-bbbbbb]
|
||||||
|
dict({
|
||||||
|
'error': 'OperationalError',
|
||||||
|
'message': 'Could not filter metadata fields',
|
||||||
|
})
|
||||||
|
# ---
|
||||||
# name: test_long_text_knn[lt-bbbbbbbbbbbb_aaa]
|
# name: test_long_text_knn[lt-bbbbbbbbbbbb_aaa]
|
||||||
dict({
|
dict({
|
||||||
'error': 'OperationalError',
|
'error': 'OperationalError',
|
||||||
|
|
@ -837,6 +1038,38 @@
|
||||||
'message': 'Could not filter metadata fields',
|
'message': 'Could not filter metadata fields',
|
||||||
})
|
})
|
||||||
# ---
|
# ---
|
||||||
|
# name: test_long_text_knn[ne-bb]
|
||||||
|
OrderedDict({
|
||||||
|
'sql': "select rowid, name, distance from v where vector match '[100]' and k = 5 and name != ?",
|
||||||
|
'rows': list([
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 6,
|
||||||
|
'name': 'cccccccccccc_ccc',
|
||||||
|
'distance': 94.0,
|
||||||
|
}),
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 5,
|
||||||
|
'name': 'cccc',
|
||||||
|
'distance': 95.0,
|
||||||
|
}),
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 4,
|
||||||
|
'name': 'bbbbbbbbbbbb_bbb',
|
||||||
|
'distance': 96.0,
|
||||||
|
}),
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 3,
|
||||||
|
'name': 'bbbb',
|
||||||
|
'distance': 97.0,
|
||||||
|
}),
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 2,
|
||||||
|
'name': 'aaaaaaaaaaaa_aaa',
|
||||||
|
'distance': 98.0,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
# ---
|
||||||
# name: test_long_text_knn[ne-bbbb]
|
# name: test_long_text_knn[ne-bbbb]
|
||||||
OrderedDict({
|
OrderedDict({
|
||||||
'sql': "select rowid, name, distance from v where vector match '[100]' and k = 5 and name != ?",
|
'sql': "select rowid, name, distance from v where vector match '[100]' and k = 5 and name != ?",
|
||||||
|
|
@ -869,6 +1102,38 @@
|
||||||
]),
|
]),
|
||||||
})
|
})
|
||||||
# ---
|
# ---
|
||||||
|
# name: test_long_text_knn[ne-bbbbbb]
|
||||||
|
OrderedDict({
|
||||||
|
'sql': "select rowid, name, distance from v where vector match '[100]' and k = 5 and name != ?",
|
||||||
|
'rows': list([
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 6,
|
||||||
|
'name': 'cccccccccccc_ccc',
|
||||||
|
'distance': 94.0,
|
||||||
|
}),
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 5,
|
||||||
|
'name': 'cccc',
|
||||||
|
'distance': 95.0,
|
||||||
|
}),
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 4,
|
||||||
|
'name': 'bbbbbbbbbbbb_bbb',
|
||||||
|
'distance': 96.0,
|
||||||
|
}),
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 3,
|
||||||
|
'name': 'bbbb',
|
||||||
|
'distance': 97.0,
|
||||||
|
}),
|
||||||
|
OrderedDict({
|
||||||
|
'rowid': 2,
|
||||||
|
'name': 'aaaaaaaaaaaa_aaa',
|
||||||
|
'distance': 98.0,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
# ---
|
||||||
# name: test_long_text_knn[ne-bbbbbbbbbbbb_aaa]
|
# name: test_long_text_knn[ne-bbbbbbbbbbbb_aaa]
|
||||||
OrderedDict({
|
OrderedDict({
|
||||||
'sql': "select rowid, name, distance from v where vector match '[100]' and k = 5 and name != ?",
|
'sql': "select rowid, name, distance from v where vector match '[100]' and k = 5 and name != ?",
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,8 @@ def test_long_text_knn(db, snapshot):
|
||||||
|
|
||||||
tests = [
|
tests = [
|
||||||
"bbbb",
|
"bbbb",
|
||||||
|
"bb",
|
||||||
|
"bbbbbb",
|
||||||
"bbbbbbbbbbbb_bbb",
|
"bbbbbbbbbbbb_bbb",
|
||||||
"bbbbbbbbbbbb_aaa",
|
"bbbbbbbbbbbb_aaa",
|
||||||
"bbbbbbbbbbbb_ccc",
|
"bbbbbbbbbbbb_ccc",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue