@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.
@@ -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
+ }
@@ -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
- if (take == null) return "";
424
- if (_dialect == Dialect.Postgres)
425
- return $" LIMIT {take.Value} OFFSET {skip ?? 0}";
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 (take != null && _dialect == Dialect.Postgres)
436
- {
437
- if (!string.IsNullOrEmpty(orderBy)) sb.Append($" ORDER BY {orderBy}");
438
- if (skip != null) sb.Append($" OFFSET {skip.Value} ROWS");
439
- sb.Append($" LIMIT {take.Value}");
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 isPostgres = _dialect == Dialect.Postgres;
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 (!isPostgres)
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 (!isPostgres)
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;
@@ -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
- using var conn = OpenConnection();
48
- using var cmd = BuildCommand(conn, sql, parameters);
49
- using var reader = cmd.ExecuteReader();
50
- while (reader.Read())
56
+ var conn = OpenConnection(out bool isTx);
57
+ try
51
58
  {
52
- var row = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
53
- for (int i = 0; i < reader.FieldCount; i++)
59
+ using var cmd = BuildCommand(conn, sql, parameters);
60
+ using var reader = cmd.ExecuteReader();
61
+ while (reader.Read())
54
62
  {
55
- row[reader.GetName(i)] = reader.IsDBNull(i) ? null : reader.GetValue(i);
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
- using var conn = OpenConnection();
66
- using var cmd = BuildCommand(conn, sql, parameters);
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 item = new T();
72
- foreach (var prop in props)
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
- if (!HasColumn(reader, prop.Name)) continue;
75
- var val = reader[prop.Name];
76
- if (val == DBNull.Value) continue;
77
- try { prop.SetValue(item, Convert.ChangeType(val, prop.PropertyType)); } catch { }
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
- using var conn = OpenConnection();
87
- using var cmd = BuildCommand(conn, sql, parameters);
88
- return cmd.ExecuteNonQuery();
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
- var conn = OpenConnection();
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
- return new PostgresTransaction(conn, tx);
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
  }