@an5/adapters 0.2.6 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -2
- package/dist/an5Adapter.d.ts +57 -0
- package/dist/an5Adapter.js +262 -4
- package/dist/base/types.d.ts +6 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +9 -1
- package/dist/nbase/client.d.ts +92 -0
- package/dist/nbase/client.js +147 -0
- package/dist/nbase/index.d.ts +47 -0
- package/dist/nbase/index.js +80 -0
- package/dotnet/An5Adapter.cs +41 -18
- package/dotnet/Base/Core.cs +42 -2
- package/dotnet/Postgres/PostgresEngine.cs +58 -30
- package/dotnet/Sqlite/SqliteEngine.cs +214 -0
- package/package.json +15 -8
- package/python/an5_adapter.py +74 -9
- package/python/base/__init__.py +1 -1
- package/python/base/metadata.py +25 -1
- package/python/base/sql.py +44 -14
- package/python/sqlite/__init__.py +3 -0
- package/python/sqlite/provider.py +83 -0
- package/python/table_client.py +53 -36
- package/rust/Cargo.toml +31 -0
- package/rust/README.md +71 -0
- package/rust/src/adapter.rs +1312 -0
- package/rust/src/base/dialect.rs +172 -0
- package/rust/src/base/metadata.rs +134 -0
- package/rust/src/base/mod.rs +17 -0
- package/rust/src/base/where_builder.rs +405 -0
- package/rust/src/lib.rs +75 -0
- package/typescript/src/an5Adapter.ts +299 -4
- package/typescript/src/base/types.ts +6 -0
- package/typescript/src/index.ts +17 -0
- package/typescript/src/nbase/client.ts +214 -0
- package/typescript/src/nbase/index.ts +104 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* NBase — Neural Vector Database client.
|
|
4
|
+
*
|
|
5
|
+
* NBase is a vector database with HNSW/LSH/KNN indexing exposed over a REST
|
|
6
|
+
* API. an5 uses it to move similarity search out of the database process: the
|
|
7
|
+
* vectors live in NBase, the rows stay in the relational table, and the search
|
|
8
|
+
* results are hydrated from the table by the id kept in vector metadata.
|
|
9
|
+
*
|
|
10
|
+
* The client only uses the global `fetch`, so it works in Node 18+ and in the
|
|
11
|
+
* browser without pulling in a dependency.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.NBaseVectorClient = exports.NBaseError = void 0;
|
|
15
|
+
exports.createNBaseVectorClient = createNBaseVectorClient;
|
|
16
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
17
|
+
class NBaseError extends Error {
|
|
18
|
+
constructor(message, status, body) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.status = status;
|
|
21
|
+
this.body = body;
|
|
22
|
+
this.name = 'NBaseError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
exports.NBaseError = NBaseError;
|
|
26
|
+
class NBaseVectorClient {
|
|
27
|
+
constructor(config) {
|
|
28
|
+
this.config = config;
|
|
29
|
+
this.baseUrl = String(config.url).replace(/\/+$/, '');
|
|
30
|
+
const impl = config.fetchImpl ?? globalThis.fetch;
|
|
31
|
+
if (!impl) {
|
|
32
|
+
throw new NBaseError('No fetch implementation available. Node 18+ or a fetch polyfill is required.');
|
|
33
|
+
}
|
|
34
|
+
this.fetchImpl = impl;
|
|
35
|
+
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
36
|
+
}
|
|
37
|
+
async request(path, init) {
|
|
38
|
+
const controller = new AbortController();
|
|
39
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
40
|
+
try {
|
|
41
|
+
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
42
|
+
...init,
|
|
43
|
+
signal: controller.signal,
|
|
44
|
+
headers: {
|
|
45
|
+
'content-type': 'application/json',
|
|
46
|
+
...(this.config.token ? { authorization: `Bearer ${this.config.token}` } : {}),
|
|
47
|
+
...init?.headers,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
const text = await response.text();
|
|
51
|
+
let body;
|
|
52
|
+
try {
|
|
53
|
+
body = text ? JSON.parse(text) : undefined;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
body = text;
|
|
57
|
+
}
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
const message = (body && typeof body === 'object' && 'error' in body
|
|
60
|
+
? String(body.error)
|
|
61
|
+
: undefined) ?? `HTTP ${response.status}`;
|
|
62
|
+
throw new NBaseError(`NBase request ${path} failed: ${message}`, response.status, body);
|
|
63
|
+
}
|
|
64
|
+
return body;
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
if (err instanceof NBaseError)
|
|
68
|
+
throw err;
|
|
69
|
+
if (err?.name === 'AbortError') {
|
|
70
|
+
throw new NBaseError(`NBase request ${path} timed out after ${this.timeoutMs}ms`);
|
|
71
|
+
}
|
|
72
|
+
throw new NBaseError(`Could not reach NBase at ${this.baseUrl}: ${err instanceof Error ? err.message : String(err)}`);
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Liveness probe. NBase mounts its status routes at the server root. */
|
|
79
|
+
async health() {
|
|
80
|
+
return this.request('/health');
|
|
81
|
+
}
|
|
82
|
+
/** Vector count, memory use and other database statistics. */
|
|
83
|
+
async stats() {
|
|
84
|
+
return this.request('/stats');
|
|
85
|
+
}
|
|
86
|
+
/** Adds or replaces vectors. NBase accepts a single vector or a bulk array. */
|
|
87
|
+
async addVectors(vectors) {
|
|
88
|
+
if (vectors.length === 0)
|
|
89
|
+
return { count: 0 };
|
|
90
|
+
const result = await this.request('/api/vectors', { method: 'POST', body: JSON.stringify({ vectors: vectors.length === 1 ? vectors[0] : vectors }) });
|
|
91
|
+
return { count: result.count ?? vectors.length, ...(result.addedIds ? { ids: result.addedIds } : {}) };
|
|
92
|
+
}
|
|
93
|
+
/** Nearest-neighbour search. Results are ordered by ascending `dist`. */
|
|
94
|
+
async search(vector, options = {}) {
|
|
95
|
+
const body = await this.request('/api/search', {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
body: JSON.stringify({
|
|
98
|
+
query: vector,
|
|
99
|
+
k: options.k ?? 10,
|
|
100
|
+
...(options.distanceMetric ? { distanceMetric: options.distanceMetric } : {}),
|
|
101
|
+
...(options.method ? { method: options.method } : {}),
|
|
102
|
+
...(options.efSearch !== undefined ? { efSearch: options.efSearch } : {}),
|
|
103
|
+
...(options.filters ? { filters: options.filters } : {}),
|
|
104
|
+
...(options.includeMetadata !== undefined ? { includeMetadata: options.includeMetadata } : {}),
|
|
105
|
+
...(options.includeVectors !== undefined ? { includeVectors: options.includeVectors } : {}),
|
|
106
|
+
...(options.skipCache !== undefined ? { skipCache: options.skipCache } : {}),
|
|
107
|
+
...(options.searchTimeoutMs !== undefined ? { searchTimeoutMs: options.searchTimeoutMs } : {}),
|
|
108
|
+
...(options.rerank !== undefined ? { rerank: options.rerank } : {}),
|
|
109
|
+
...(options.rerankingMethod ? { rerankingMethod: options.rerankingMethod } : {}),
|
|
110
|
+
...(options.rerankLambda !== undefined ? { rerankLambda: options.rerankLambda } : {}),
|
|
111
|
+
...(options.partitionIds ? { partitionIds: options.partitionIds } : {}),
|
|
112
|
+
}),
|
|
113
|
+
});
|
|
114
|
+
return body.results ?? [];
|
|
115
|
+
}
|
|
116
|
+
/** Reads a single vector. Returns undefined when the id is unknown. */
|
|
117
|
+
async get(id) {
|
|
118
|
+
try {
|
|
119
|
+
return await this.request(`/api/vectors/${encodeURIComponent(String(id))}`);
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
if (err instanceof NBaseError && err.status === 404)
|
|
123
|
+
return undefined;
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async exists(id) {
|
|
128
|
+
const result = await this.request(`/api/vectors/${encodeURIComponent(String(id))}/exists`);
|
|
129
|
+
return result.exists === true;
|
|
130
|
+
}
|
|
131
|
+
async delete(id) {
|
|
132
|
+
await this.request(`/api/vectors/${encodeURIComponent(String(id))}`, { method: 'DELETE' });
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
async updateMetadata(id, metadata) {
|
|
136
|
+
await this.request(`/api/vectors/${encodeURIComponent(String(id))}/metadata`, {
|
|
137
|
+
method: 'PATCH',
|
|
138
|
+
body: JSON.stringify(metadata),
|
|
139
|
+
});
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
exports.NBaseVectorClient = NBaseVectorClient;
|
|
144
|
+
/** Creates an NBase vector client. */
|
|
145
|
+
function createNBaseVectorClient(config) {
|
|
146
|
+
return new NBaseVectorClient(config);
|
|
147
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NBase connection-string and adapter configuration.
|
|
3
|
+
*
|
|
4
|
+
* NBase is configured through the connection string alone:
|
|
5
|
+
*
|
|
6
|
+
* createAn5Adapter({ connectionString: 'nbase://localhost:1307' })
|
|
7
|
+
*
|
|
8
|
+
* Accepted forms are `nbase://host:port`, `nbase:host:port` and an explicit
|
|
9
|
+
* scheme, with optional `?token=…&timeoutMs=…&method=…` options. An adapter
|
|
10
|
+
* created this way is vector-only: it has no relational database, so a search
|
|
11
|
+
* returns the NBase hits instead of table rows.
|
|
12
|
+
*/
|
|
13
|
+
import { NBaseVectorClient, NBaseError, createNBaseVectorClient, type NBaseClientConfig, type NBaseVector, type NBaseSearchOptions, type NBaseSearchResult, type NBaseStats } from './client';
|
|
14
|
+
export interface NBaseAdapterConfig extends NBaseClientConfig {
|
|
15
|
+
url: string;
|
|
16
|
+
/**
|
|
17
|
+
* Metadata key that holds the relational row id. `an5Id` by default, which
|
|
18
|
+
* is what `indexTableVectors` writes.
|
|
19
|
+
*/
|
|
20
|
+
idField?: string;
|
|
21
|
+
/** Metadata key holding the model name, used to keep collections apart. */
|
|
22
|
+
modelField?: string;
|
|
23
|
+
/** Force the search method instead of letting NBase choose. */
|
|
24
|
+
method?: 'hnsw' | 'clustered';
|
|
25
|
+
}
|
|
26
|
+
/** True when the whole connection string points at NBase. */
|
|
27
|
+
export declare function isNBaseConnectionString(connectionString: string): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Parses an NBase connection string.
|
|
30
|
+
*
|
|
31
|
+
* Accepts `nbase:http://host:port`, `nbase://host:port` and
|
|
32
|
+
* `nbase:host:port`, with optional `?token=…&timeoutMs=…` options.
|
|
33
|
+
*/
|
|
34
|
+
export declare function parseNBaseConnectionString(connectionString: string): NBaseAdapterConfig;
|
|
35
|
+
/** Builds the vector id used to link a NBase vector back to a relational row. */
|
|
36
|
+
export declare function buildVectorId(model: string, id: string | number): string;
|
|
37
|
+
/** Splits a vector id produced by {@link buildVectorId}. */
|
|
38
|
+
export declare function parseVectorId(vectorId: string | number): {
|
|
39
|
+
model: string;
|
|
40
|
+
id: string;
|
|
41
|
+
};
|
|
42
|
+
export declare function createNBaseClient(config: NBaseAdapterConfig | string): {
|
|
43
|
+
client: NBaseVectorClient;
|
|
44
|
+
options: NBaseAdapterConfig;
|
|
45
|
+
};
|
|
46
|
+
export { NBaseVectorClient, NBaseError, createNBaseVectorClient };
|
|
47
|
+
export type { NBaseClientConfig, NBaseVector, NBaseSearchOptions, NBaseSearchResult, NBaseStats };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createNBaseVectorClient = exports.NBaseError = exports.NBaseVectorClient = void 0;
|
|
4
|
+
exports.isNBaseConnectionString = isNBaseConnectionString;
|
|
5
|
+
exports.parseNBaseConnectionString = parseNBaseConnectionString;
|
|
6
|
+
exports.buildVectorId = buildVectorId;
|
|
7
|
+
exports.parseVectorId = parseVectorId;
|
|
8
|
+
exports.createNBaseClient = createNBaseClient;
|
|
9
|
+
/**
|
|
10
|
+
* NBase connection-string and adapter configuration.
|
|
11
|
+
*
|
|
12
|
+
* NBase is configured through the connection string alone:
|
|
13
|
+
*
|
|
14
|
+
* createAn5Adapter({ connectionString: 'nbase://localhost:1307' })
|
|
15
|
+
*
|
|
16
|
+
* Accepted forms are `nbase://host:port`, `nbase:host:port` and an explicit
|
|
17
|
+
* scheme, with optional `?token=…&timeoutMs=…&method=…` options. An adapter
|
|
18
|
+
* created this way is vector-only: it has no relational database, so a search
|
|
19
|
+
* returns the NBase hits instead of table rows.
|
|
20
|
+
*/
|
|
21
|
+
const client_1 = require("./client");
|
|
22
|
+
Object.defineProperty(exports, "NBaseVectorClient", { enumerable: true, get: function () { return client_1.NBaseVectorClient; } });
|
|
23
|
+
Object.defineProperty(exports, "NBaseError", { enumerable: true, get: function () { return client_1.NBaseError; } });
|
|
24
|
+
Object.defineProperty(exports, "createNBaseVectorClient", { enumerable: true, get: function () { return client_1.createNBaseVectorClient; } });
|
|
25
|
+
/** Matches `nbase:`, `nbase://` and `nbase=http(s)://` at the start of a value. */
|
|
26
|
+
const NBASE_PREFIX = /^nbase:(?:\/\/)?/i;
|
|
27
|
+
/** True when the whole connection string points at NBase. */
|
|
28
|
+
function isNBaseConnectionString(connectionString) {
|
|
29
|
+
return NBASE_PREFIX.test(connectionString.trim());
|
|
30
|
+
}
|
|
31
|
+
/** Builds options from a URL and an optional query string. */
|
|
32
|
+
function buildOptions(url, query) {
|
|
33
|
+
const params = new URLSearchParams(query ?? '');
|
|
34
|
+
const trimmed = url.trim().replace(/\/+$/, '');
|
|
35
|
+
// `nbase://host:port` is shorthand for the same host over HTTP; an explicit
|
|
36
|
+
// scheme is used as-is so https deployments work.
|
|
37
|
+
const withScheme = !trimmed
|
|
38
|
+
? 'http://localhost:1307'
|
|
39
|
+
: /^[a-z]+:\/\//i.test(trimmed)
|
|
40
|
+
? trimmed
|
|
41
|
+
: `http://${trimmed}`;
|
|
42
|
+
return {
|
|
43
|
+
url: withScheme,
|
|
44
|
+
...(params.get('token') ? { token: params.get('token') } : {}),
|
|
45
|
+
...(params.get('timeoutMs') ? { timeoutMs: Number(params.get('timeoutMs')) } : {}),
|
|
46
|
+
...(params.get('idField') ? { idField: params.get('idField') } : {}),
|
|
47
|
+
...(params.get('modelField') ? { modelField: params.get('modelField') } : {}),
|
|
48
|
+
...(params.get('method') ? { method: params.get('method') } : {}),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Parses an NBase connection string.
|
|
53
|
+
*
|
|
54
|
+
* Accepts `nbase:http://host:port`, `nbase://host:port` and
|
|
55
|
+
* `nbase:host:port`, with optional `?token=…&timeoutMs=…` options.
|
|
56
|
+
*/
|
|
57
|
+
function parseNBaseConnectionString(connectionString) {
|
|
58
|
+
const raw = connectionString.trim();
|
|
59
|
+
if (!isNBaseConnectionString(raw)) {
|
|
60
|
+
throw new Error(`Not an NBase connection string: ${raw}`);
|
|
61
|
+
}
|
|
62
|
+
const [url, query] = raw.replace(NBASE_PREFIX, '').split('?');
|
|
63
|
+
return buildOptions(url ?? '', query);
|
|
64
|
+
}
|
|
65
|
+
/** Builds the vector id used to link a NBase vector back to a relational row. */
|
|
66
|
+
function buildVectorId(model, id) {
|
|
67
|
+
return `${model}:${id}`;
|
|
68
|
+
}
|
|
69
|
+
/** Splits a vector id produced by {@link buildVectorId}. */
|
|
70
|
+
function parseVectorId(vectorId) {
|
|
71
|
+
const raw = String(vectorId);
|
|
72
|
+
const separator = raw.indexOf(':');
|
|
73
|
+
if (separator === -1)
|
|
74
|
+
return { model: '', id: raw };
|
|
75
|
+
return { model: raw.slice(0, separator), id: raw.slice(separator + 1) };
|
|
76
|
+
}
|
|
77
|
+
function createNBaseClient(config) {
|
|
78
|
+
const options = typeof config === 'string' ? parseNBaseConnectionString(config) : config;
|
|
79
|
+
return { client: (0, client_1.createNBaseVectorClient)(options), options };
|
|
80
|
+
}
|
package/dotnet/An5Adapter.cs
CHANGED
|
@@ -25,6 +25,7 @@ namespace An5Orm
|
|
|
25
25
|
_engine = DialectDetector.Detect(connectionString) switch
|
|
26
26
|
{
|
|
27
27
|
Dialect.Postgres => new PostgresEngine(connectionString, commandTimeout),
|
|
28
|
+
Dialect.Sqlite => new SqliteEngine(connectionString, commandTimeout),
|
|
28
29
|
_ => new MssqlEngine(connectionString, commandTimeout)
|
|
29
30
|
};
|
|
30
31
|
}
|
|
@@ -44,8 +45,16 @@ namespace An5Orm
|
|
|
44
45
|
|
|
45
46
|
// ── Stored Procedure Execution ────────────────────────────────────────
|
|
46
47
|
|
|
48
|
+
private void ThrowIfNoStoredProcedures()
|
|
49
|
+
{
|
|
50
|
+
if (Dialect == Dialect.Sqlite)
|
|
51
|
+
throw new NotSupportedException(
|
|
52
|
+
"SQLite has no stored procedures. Run the statements directly with QueryRaw or ExecuteRaw.");
|
|
53
|
+
}
|
|
54
|
+
|
|
47
55
|
public List<T> QueryProc<T>(string procName, Dictionary<string, object> parameters = null) where T : new()
|
|
48
56
|
{
|
|
57
|
+
ThrowIfNoStoredProcedures();
|
|
49
58
|
var paramList = new List<string>();
|
|
50
59
|
if (parameters != null)
|
|
51
60
|
{
|
|
@@ -62,6 +71,7 @@ namespace An5Orm
|
|
|
62
71
|
|
|
63
72
|
public int ExecuteProc(string procName, Dictionary<string, object> parameters = null)
|
|
64
73
|
{
|
|
74
|
+
ThrowIfNoStoredProcedures();
|
|
65
75
|
var paramList = new List<string>();
|
|
66
76
|
if (parameters != null)
|
|
67
77
|
{
|
|
@@ -418,12 +428,27 @@ namespace An5Orm
|
|
|
418
428
|
|
|
419
429
|
private string Nolock => _dialect == Dialect.Mssql ? " WITH (NOLOCK)" : "";
|
|
420
430
|
|
|
431
|
+
/// <summary>
|
|
432
|
+
/// True for the dialects that paginate with LIMIT/OFFSET rather than
|
|
433
|
+
/// OFFSET ... FETCH NEXT, and have no SELECT TOP.
|
|
434
|
+
/// </summary>
|
|
435
|
+
private bool UsesLimitOffset => _dialect == Dialect.Postgres || _dialect == Dialect.Sqlite;
|
|
436
|
+
|
|
421
437
|
private string BuildPagination(string orderBy, int? skip, int? take)
|
|
422
438
|
{
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
439
|
+
// A skip on its own still has to skip. OFFSET without FETCH NEXT is
|
|
440
|
+
// valid on MSSQL, and on the LIMIT/OFFSET dialects an unbounded page
|
|
441
|
+
// is LIMIT -1; without this, `skip` was silently discarded whenever
|
|
442
|
+
// `take` was null.
|
|
443
|
+
if (take == null && skip == null) return "";
|
|
444
|
+
if (UsesLimitOffset)
|
|
445
|
+
{
|
|
446
|
+
if (take == null) return $" LIMIT -1 OFFSET {skip.Value}";
|
|
447
|
+
if (skip == null) return $" LIMIT {take.Value}";
|
|
448
|
+
return $" LIMIT {take.Value} OFFSET {skip.Value}";
|
|
449
|
+
}
|
|
426
450
|
var o = !string.IsNullOrEmpty(orderBy) ? $" ORDER BY {orderBy}" : " ORDER BY (SELECT NULL)";
|
|
451
|
+
if (take == null) return $"{o} OFFSET {skip.Value} ROWS";
|
|
427
452
|
return $"{o} OFFSET {skip ?? 0} ROWS FETCH NEXT {take.Value} ROWS ONLY";
|
|
428
453
|
}
|
|
429
454
|
|
|
@@ -432,17 +457,11 @@ namespace An5Orm
|
|
|
432
457
|
{
|
|
433
458
|
var sb = new StringBuilder($"SELECT * FROM {_tableName}{Nolock}");
|
|
434
459
|
if (!string.IsNullOrEmpty(whereClause)) sb.Append($" WHERE {whereClause}");
|
|
435
|
-
if (
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
}
|
|
441
|
-
else
|
|
442
|
-
{
|
|
443
|
-
if (!string.IsNullOrEmpty(orderBy)) sb.Append($" ORDER BY {orderBy}");
|
|
444
|
-
sb.Append(BuildPagination(orderBy, skip, take));
|
|
445
|
-
}
|
|
460
|
+
if (!string.IsNullOrEmpty(orderBy)) sb.Append($" ORDER BY {orderBy}");
|
|
461
|
+
// LIMIT precedes OFFSET, and neither carries MSSQL's `ROWS` keyword.
|
|
462
|
+
// Postgres tolerates either order, SQLite does not: `OFFSET n LIMIT m`
|
|
463
|
+
// is a syntax error there.
|
|
464
|
+
sb.Append(BuildPagination(orderBy, skip, take));
|
|
446
465
|
return _adapter.QueryRaw<T>(sb.ToString(), parameters);
|
|
447
466
|
}
|
|
448
467
|
|
|
@@ -457,7 +476,7 @@ namespace An5Orm
|
|
|
457
476
|
|
|
458
477
|
var cols = "*";
|
|
459
478
|
var keys = ArgsHelper.SelectedFields(args?.Select);
|
|
460
|
-
var
|
|
479
|
+
var usesLimitOffset = UsesLimitOffset;
|
|
461
480
|
|
|
462
481
|
if (keys.Count > 0 && !ArgsHelper.HasRelationSelect(args?.Select, An5Metadata.GetRelationsForModel(_modelName)))
|
|
463
482
|
{
|
|
@@ -468,7 +487,7 @@ namespace An5Orm
|
|
|
468
487
|
string query;
|
|
469
488
|
if (take != null && !hasSkip)
|
|
470
489
|
{
|
|
471
|
-
if (!
|
|
490
|
+
if (!usesLimitOffset)
|
|
472
491
|
{
|
|
473
492
|
query = $"SELECT TOP ({take.Value}) {cols} FROM {_tableName}{Nolock}";
|
|
474
493
|
if (!string.IsNullOrEmpty(whereSql)) query += $" WHERE {whereSql}";
|
|
@@ -484,7 +503,7 @@ namespace An5Orm
|
|
|
484
503
|
}
|
|
485
504
|
else if (hasSkip)
|
|
486
505
|
{
|
|
487
|
-
if (!
|
|
506
|
+
if (!usesLimitOffset)
|
|
488
507
|
{
|
|
489
508
|
query = $"SELECT {cols} FROM {_tableName}{Nolock}";
|
|
490
509
|
if (!string.IsNullOrEmpty(whereSql)) query += $" WHERE {whereSql}";
|
|
@@ -498,7 +517,7 @@ namespace An5Orm
|
|
|
498
517
|
if (!string.IsNullOrEmpty(whereSql)) query += $" WHERE {whereSql}";
|
|
499
518
|
if (!string.IsNullOrEmpty(orderSql)) query += $" {orderSql}";
|
|
500
519
|
if (take != null) query += $" LIMIT {take.Value}";
|
|
501
|
-
else query += " LIMIT ALL";
|
|
520
|
+
else query += _dialect == Dialect.Sqlite ? " LIMIT -1" : " LIMIT ALL";
|
|
502
521
|
query += $" OFFSET {skip.Value}";
|
|
503
522
|
}
|
|
504
523
|
}
|
|
@@ -988,6 +1007,10 @@ namespace An5Orm
|
|
|
988
1007
|
string vectorField = "Embedding", string distanceMetric = "cosine")
|
|
989
1008
|
{
|
|
990
1009
|
// 1. Primary path: Native database SQL vector query execution (MSSQL VECTOR_DISTANCE / Postgres pgvector)
|
|
1010
|
+
// SQLite has no vector operator, so it is skipped outright: building the
|
|
1011
|
+
// statement only to have it rejected costs a round trip and lands on the
|
|
1012
|
+
// in-memory path below anyway.
|
|
1013
|
+
if (_dialect != Dialect.Sqlite)
|
|
991
1014
|
try
|
|
992
1015
|
{
|
|
993
1016
|
var dim = vector.Count;
|
package/dotnet/Base/Core.cs
CHANGED
|
@@ -7,7 +7,8 @@ namespace An5Orm
|
|
|
7
7
|
public enum Dialect
|
|
8
8
|
{
|
|
9
9
|
Mssql,
|
|
10
|
-
Postgres
|
|
10
|
+
Postgres,
|
|
11
|
+
Sqlite
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
// ─── Config ────────────────────────────────────────────────────────────────
|
|
@@ -39,6 +40,38 @@ public enum Dialect
|
|
|
39
40
|
public abstract void Dispose();
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
// ─── Connection scope ──────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/// <summary>
|
|
46
|
+
/// A connection borrowed for the length of one operation, and whether the
|
|
47
|
+
/// caller still owns it.
|
|
48
|
+
///
|
|
49
|
+
/// A statement issued while a transaction is open has to run on that
|
|
50
|
+
/// transaction's connection. Opening a second one is not merely wasteful: on
|
|
51
|
+
/// SQLite the open transaction holds a write lock, so the second connection
|
|
52
|
+
/// is refused with "database is locked", and on the server engines the write
|
|
53
|
+
/// silently lands outside the transaction, so commit and rollback do not
|
|
54
|
+
/// cover it. Each engine keeps the transaction connection and hands it back
|
|
55
|
+
/// through this scope while it is set.
|
|
56
|
+
/// </summary>
|
|
57
|
+
internal readonly struct ConnectionScope<T> : IDisposable where T : class
|
|
58
|
+
{
|
|
59
|
+
public T Connection { get; }
|
|
60
|
+
|
|
61
|
+
private readonly bool _owned;
|
|
62
|
+
|
|
63
|
+
internal ConnectionScope(T connection, bool owned)
|
|
64
|
+
{
|
|
65
|
+
Connection = connection;
|
|
66
|
+
_owned = owned;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
public void Dispose()
|
|
70
|
+
{
|
|
71
|
+
if (_owned && Connection is IDisposable disposable) disposable.Dispose();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
42
75
|
// ─── Dialect detection ─────────────────────────────────────────────────────
|
|
43
76
|
|
|
44
77
|
internal static class DialectDetector
|
|
@@ -46,6 +79,13 @@ public enum Dialect
|
|
|
46
79
|
public static Dialect Detect(string connectionString)
|
|
47
80
|
{
|
|
48
81
|
var cs = (connectionString ?? "").Trim().ToLowerInvariant();
|
|
82
|
+
// SQLite first: `Data Source=app.db` and a bare file path would
|
|
83
|
+
// otherwise fall through, and a Postgres string never looks like
|
|
84
|
+
// any of these.
|
|
85
|
+
if (cs.StartsWith("sqlite:") || cs.StartsWith("file:") || cs == ":memory:"
|
|
86
|
+
|| cs.Contains("data source=") || cs.Contains("datasource=")
|
|
87
|
+
|| cs.EndsWith(".db") || cs.EndsWith(".sqlite") || cs.EndsWith(".sqlite3"))
|
|
88
|
+
return Dialect.Sqlite;
|
|
49
89
|
if (cs.StartsWith("postgres://") || cs.StartsWith("postgresql://") || cs.Contains("host="))
|
|
50
90
|
return Dialect.Postgres;
|
|
51
91
|
return Dialect.Mssql;
|
|
@@ -66,7 +106,7 @@ public enum Dialect
|
|
|
66
106
|
public static string QuoteName(string name, Dialect dialect)
|
|
67
107
|
{
|
|
68
108
|
var raw = name ?? "";
|
|
69
|
-
if (dialect == Dialect.Postgres)
|
|
109
|
+
if (dialect == Dialect.Postgres || dialect == Dialect.Sqlite)
|
|
70
110
|
{
|
|
71
111
|
var unwrapped = StripWrapping(StripWrapping(raw, "[", "]"), "\"", "\"");
|
|
72
112
|
return "\"" + unwrapped.Replace("\"", "\"\"") + "\"";
|
|
@@ -13,14 +13,22 @@ namespace An5Orm
|
|
|
13
13
|
private readonly string _connectionString;
|
|
14
14
|
private readonly int _commandTimeout;
|
|
15
15
|
|
|
16
|
+
// Same shape as MssqlEngine: while a transaction is open, statements
|
|
17
|
+
// have to run on its connection and carry its transaction, or they land
|
|
18
|
+
// outside it and commit/rollback do not cover them.
|
|
19
|
+
[ThreadStatic] private static Npgsql.NpgsqlConnection _txConn;
|
|
20
|
+
[ThreadStatic] private static Npgsql.NpgsqlTransaction _tx;
|
|
21
|
+
|
|
16
22
|
public PostgresEngine(string connectionString, int commandTimeout)
|
|
17
23
|
{
|
|
18
24
|
_connectionString = connectionString;
|
|
19
25
|
_commandTimeout = commandTimeout;
|
|
20
26
|
}
|
|
21
27
|
|
|
22
|
-
private Npgsql.NpgsqlConnection OpenConnection()
|
|
28
|
+
private Npgsql.NpgsqlConnection OpenConnection(out bool isInTransaction)
|
|
23
29
|
{
|
|
30
|
+
if (_txConn != null) { isInTransaction = true; return _txConn; }
|
|
31
|
+
isInTransaction = false;
|
|
24
32
|
var conn = new Npgsql.NpgsqlConnection(_connectionString);
|
|
25
33
|
conn.Open();
|
|
26
34
|
return conn;
|
|
@@ -30,6 +38,7 @@ namespace An5Orm
|
|
|
30
38
|
Npgsql.NpgsqlConnection conn, string sql, Dictionary<string, object> parameters)
|
|
31
39
|
{
|
|
32
40
|
var cmd = new Npgsql.NpgsqlCommand(sql, conn) { CommandTimeout = _commandTimeout };
|
|
41
|
+
if (_tx != null) cmd.Transaction = _tx;
|
|
33
42
|
if (parameters != null)
|
|
34
43
|
{
|
|
35
44
|
foreach (var kv in parameters)
|
|
@@ -44,48 +53,60 @@ namespace An5Orm
|
|
|
44
53
|
public List<Dictionary<string, object>> QueryRaw(string sql, Dictionary<string, object> parameters)
|
|
45
54
|
{
|
|
46
55
|
var results = new List<Dictionary<string, object>>();
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
using var reader = cmd.ExecuteReader();
|
|
50
|
-
while (reader.Read())
|
|
56
|
+
var conn = OpenConnection(out bool isTx);
|
|
57
|
+
try
|
|
51
58
|
{
|
|
52
|
-
var
|
|
53
|
-
|
|
59
|
+
using var cmd = BuildCommand(conn, sql, parameters);
|
|
60
|
+
using var reader = cmd.ExecuteReader();
|
|
61
|
+
while (reader.Read())
|
|
54
62
|
{
|
|
55
|
-
row
|
|
63
|
+
var row = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
|
|
64
|
+
for (int i = 0; i < reader.FieldCount; i++)
|
|
65
|
+
{
|
|
66
|
+
row[reader.GetName(i)] = reader.IsDBNull(i) ? null : reader.GetValue(i);
|
|
67
|
+
}
|
|
68
|
+
results.Add(row);
|
|
56
69
|
}
|
|
57
|
-
results.Add(row);
|
|
58
70
|
}
|
|
71
|
+
finally { if (!isTx) conn.Dispose(); }
|
|
59
72
|
return results;
|
|
60
73
|
}
|
|
61
74
|
|
|
62
75
|
public List<T> QueryRaw<T>(string sql, Dictionary<string, object> parameters) where T : new()
|
|
63
76
|
{
|
|
64
77
|
var results = new List<T>();
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
using var reader = cmd.ExecuteReader();
|
|
68
|
-
var props = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
|
|
69
|
-
while (reader.Read())
|
|
78
|
+
var conn = OpenConnection(out bool isTx);
|
|
79
|
+
try
|
|
70
80
|
{
|
|
71
|
-
var
|
|
72
|
-
|
|
81
|
+
using var cmd = BuildCommand(conn, sql, parameters);
|
|
82
|
+
using var reader = cmd.ExecuteReader();
|
|
83
|
+
var props = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
|
|
84
|
+
while (reader.Read())
|
|
73
85
|
{
|
|
74
|
-
|
|
75
|
-
var
|
|
76
|
-
|
|
77
|
-
|
|
86
|
+
var item = new T();
|
|
87
|
+
foreach (var prop in props)
|
|
88
|
+
{
|
|
89
|
+
if (!HasColumn(reader, prop.Name)) continue;
|
|
90
|
+
var val = reader[prop.Name];
|
|
91
|
+
if (val == DBNull.Value) continue;
|
|
92
|
+
try { prop.SetValue(item, Convert.ChangeType(val, prop.PropertyType)); } catch { }
|
|
93
|
+
}
|
|
94
|
+
results.Add(item);
|
|
78
95
|
}
|
|
79
|
-
results.Add(item);
|
|
80
96
|
}
|
|
97
|
+
finally { if (!isTx) conn.Dispose(); }
|
|
81
98
|
return results;
|
|
82
99
|
}
|
|
83
100
|
|
|
84
101
|
public int ExecuteRaw(string sql, Dictionary<string, object> parameters)
|
|
85
102
|
{
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
103
|
+
var conn = OpenConnection(out bool isTx);
|
|
104
|
+
try
|
|
105
|
+
{
|
|
106
|
+
using var cmd = BuildCommand(conn, sql, parameters);
|
|
107
|
+
return cmd.ExecuteNonQuery();
|
|
108
|
+
}
|
|
109
|
+
finally { if (!isTx) conn.Dispose(); }
|
|
89
110
|
}
|
|
90
111
|
|
|
91
112
|
private static bool HasColumn(Npgsql.NpgsqlDataReader reader, string name)
|
|
@@ -97,9 +118,14 @@ namespace An5Orm
|
|
|
97
118
|
|
|
98
119
|
public An5TransactionBase BeginTransaction()
|
|
99
120
|
{
|
|
100
|
-
|
|
121
|
+
if (_txConn != null)
|
|
122
|
+
throw new InvalidOperationException("A transaction is already open on this thread.");
|
|
123
|
+
var conn = new Npgsql.NpgsqlConnection(_connectionString);
|
|
124
|
+
conn.Open();
|
|
101
125
|
var tx = conn.BeginTransaction();
|
|
102
|
-
|
|
126
|
+
_txConn = conn;
|
|
127
|
+
_tx = tx;
|
|
128
|
+
return new PostgresTransaction(conn, tx, () => { _txConn = null; _tx = null; });
|
|
103
129
|
}
|
|
104
130
|
}
|
|
105
131
|
|
|
@@ -107,15 +133,17 @@ namespace An5Orm
|
|
|
107
133
|
{
|
|
108
134
|
private readonly Npgsql.NpgsqlConnection _conn;
|
|
109
135
|
private readonly Npgsql.NpgsqlTransaction _tx;
|
|
136
|
+
private readonly Action _cleanup;
|
|
110
137
|
|
|
111
|
-
public PostgresTransaction(Npgsql.NpgsqlConnection conn, Npgsql.NpgsqlTransaction tx)
|
|
138
|
+
public PostgresTransaction(Npgsql.NpgsqlConnection conn, Npgsql.NpgsqlTransaction tx, Action cleanup)
|
|
112
139
|
{
|
|
113
140
|
_conn = conn;
|
|
114
141
|
_tx = tx;
|
|
142
|
+
_cleanup = cleanup;
|
|
115
143
|
}
|
|
116
144
|
|
|
117
|
-
public override void Commit() { _tx.Commit(); }
|
|
118
|
-
public override void Rollback() { _tx.Rollback(); }
|
|
119
|
-
public override void Dispose() { _tx.Dispose(); _conn.Dispose(); }
|
|
145
|
+
public override void Commit() { _tx.Commit(); _cleanup(); }
|
|
146
|
+
public override void Rollback() { _tx.Rollback(); _cleanup(); }
|
|
147
|
+
public override void Dispose() { _tx.Dispose(); _conn.Dispose(); _cleanup(); }
|
|
120
148
|
}
|
|
121
149
|
}
|