@oino-ts/nosql-azure 1.0.0

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,348 @@
1
+ "use strict";
2
+ /*
3
+ * This Source Code Form is subject to the terms of the Mozilla Public
4
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
5
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.OINONoSqlAzureTable = void 0;
9
+ const data_tables_1 = require("@azure/data-tables");
10
+ const common_1 = require("@oino-ts/common");
11
+ const nosql_1 = require("@oino-ts/nosql");
12
+ /** Azure Table Storage OData field name mapping for system fields */
13
+ const ODATA_FIELD_MAP = {
14
+ partitionKey: "PartitionKey",
15
+ rowKey: "RowKey",
16
+ timestamp: "Timestamp"
17
+ };
18
+ /** Fields that can be translated to OData server-side filter expressions */
19
+ const ODATA_FILTERABLE_FIELDS = new Set(["partitionKey", "rowKey", "timestamp"]);
20
+ /**
21
+ * Azure Table Storage implementation of `OINONoSql`.
22
+ *
23
+ * Authenticates using an Azure Storage connection string. Connection parameters map as:
24
+ * - `params.url` → table service endpoint, e.g. `https://<account>.table.core.windows.net`
25
+ * - `params.table` → table name
26
+ * - `params.connectionStr` → Azure Storage connection string (e.g. `DefaultEndpointsProtocol=https;AccountName=...`)
27
+ *
28
+ * Register and use via the factory:
29
+ * ```ts
30
+ * import { OINONoSqlFactory } from "@oino-ts/nosql"
31
+ * import { OINONoSqlAzureTable } from "@oino-ts/nosql-azure"
32
+ *
33
+ * OINONoSqlFactory.registerNoSql("OINONoSqlAzureTable", OINONoSqlAzureTable)
34
+ *
35
+ * const nosql = await OINONoSqlFactory.createNoSql({
36
+ * type: "OINONoSqlAzureTable",
37
+ * url: "https://myaccount.table.core.windows.net",
38
+ * table: "myTable",
39
+ * connectionStr: process.env.AZURE_STORAGE_CONNECTION_STRING
40
+ * })
41
+ * const api = await OINONoSqlFactory.createApi(nosql, {
42
+ * apiName: "entities",
43
+ * tableName: "myTable"
44
+ * })
45
+ * ```
46
+ *
47
+ * ## Static partition key
48
+ *
49
+ * Set `staticPartitionKey` in the params to scope all operations to a fixed
50
+ * partition key. This lets multiple logical tables share one physical Azure
51
+ * Table Storage table:
52
+ * ```ts
53
+ * const nosql = await OINONoSqlFactory.createNoSql({
54
+ * type: "OINONoSqlAzureTable",
55
+ * url: "https://myaccount.table.core.windows.net",
56
+ * table: "sharedTable",
57
+ * connectionStr: process.env.AZURE_STORAGE_CONNECTION_STRING,
58
+ * staticPartitionKey: "myLogicalTable"
59
+ * })
60
+ * ```
61
+ *
62
+ * ## Filter support
63
+ *
64
+ * Filters on `partitionKey`, `rowKey`, and `timestamp` are translated to native
65
+ * Azure Table Storage OData query filter expressions and evaluated server-side.
66
+ * Filters on `etag` are evaluated in-memory after the listing.
67
+ *
68
+ * OData operators supported: `eq`, `ne`, `lt`, `le`, `gt`, `ge`, `and`, `or`, `not`.
69
+ * The `like` operator is not supported by OData and is evaluated in-memory.
70
+ */
71
+ class OINONoSqlAzureTable extends nosql_1.OINONoSql {
72
+ _tableClient = null;
73
+ // ── ODataFilter translation ───────────────────────────────────────────
74
+ /**
75
+ * Attempt to translate an `OINOQueryFilter` tree to an Azure Table Storage
76
+ * OData v3 filter expression string.
77
+ *
78
+ * Returns `undefined` for sub-trees that contain untranslatable predicates
79
+ * (e.g. filter on `etag`, or a `like` comparison). The caller falls back
80
+ * to in-memory evaluation for those cases.
81
+ *
82
+ * @param filter filter to translate
83
+ */
84
+ static filterToOData(filter) {
85
+ if (filter.isEmpty())
86
+ return undefined;
87
+ const op = filter.operator;
88
+ if (op === common_1.OINOQueryBooleanOperation.and) {
89
+ const left = OINONoSqlAzureTable.filterToOData(filter.leftSide);
90
+ const right = OINONoSqlAzureTable.filterToOData(filter.rightSide);
91
+ if (left && right)
92
+ return `(${left}) and (${right})`;
93
+ return left ?? right;
94
+ }
95
+ if (op === common_1.OINOQueryBooleanOperation.or) {
96
+ const left = OINONoSqlAzureTable.filterToOData(filter.leftSide);
97
+ const right = OINONoSqlAzureTable.filterToOData(filter.rightSide);
98
+ if (left && right)
99
+ return `(${left}) or (${right})`;
100
+ return undefined;
101
+ }
102
+ if (op === common_1.OINOQueryBooleanOperation.not) {
103
+ const inner = OINONoSqlAzureTable.filterToOData(filter.rightSide);
104
+ if (inner)
105
+ return `not (${inner})`;
106
+ return undefined;
107
+ }
108
+ const field_name = filter.leftSide;
109
+ if (!ODATA_FILTERABLE_FIELDS.has(field_name))
110
+ return undefined;
111
+ const odata_field = ODATA_FIELD_MAP[field_name];
112
+ const compare_value = filter.rightSide;
113
+ if (op === common_1.OINOQueryNullCheck.isnull)
114
+ return `${odata_field} eq null`;
115
+ if (op === common_1.OINOQueryNullCheck.isNotNull)
116
+ return `${odata_field} ne null`;
117
+ if (op === common_1.OINOQueryComparison.like)
118
+ return undefined;
119
+ const odata_op = op;
120
+ if (field_name === "timestamp") {
121
+ const iso_date = new Date(compare_value).toISOString();
122
+ return `${odata_field} ${odata_op} datetime'${iso_date}'`;
123
+ }
124
+ const escaped = compare_value.replace(/'/g, "''");
125
+ return `${odata_field} ${odata_op} '${escaped}'`;
126
+ }
127
+ // ── OINODataSource lifecycle ──────────────────────────────────────────
128
+ /**
129
+ * Initialise the Azure SDK table client. Does not perform any network call.
130
+ */
131
+ async connect() {
132
+ try {
133
+ if (this.nosqlParams.connectionStr) {
134
+ this._tableClient = data_tables_1.TableClient.fromConnectionString(this.nosqlParams.connectionStr, this.nosqlParams.table);
135
+ }
136
+ else {
137
+ return new common_1.OINOResult({
138
+ success: false,
139
+ status: 400,
140
+ statusText: "OINONoSqlAzureTable: params.connectionStr is required"
141
+ });
142
+ }
143
+ this.isConnected = true;
144
+ }
145
+ catch (e) {
146
+ return new common_1.OINOResult({ success: false, status: 500, statusText: "OINONoSqlAzureTable connect failed: " + e.message });
147
+ }
148
+ return new common_1.OINOResult();
149
+ }
150
+ /**
151
+ * Verify that the target table exists and is accessible.
152
+ */
153
+ async validate() {
154
+ if (!this._tableClient) {
155
+ return new common_1.OINOResult({ success: false, status: 500, statusText: "OINONoSqlAzureTable: not connected" });
156
+ }
157
+ try {
158
+ if (this.nosqlParams.connectionStr) {
159
+ const service_client = data_tables_1.TableServiceClient.fromConnectionString(this.nosqlParams.connectionStr);
160
+ const tables = service_client.listTables({ queryOptions: { filter: `TableName eq '${this.nosqlParams.table}'` } });
161
+ let found = false;
162
+ for await (const _t of tables) {
163
+ found = true;
164
+ break;
165
+ }
166
+ if (!found) {
167
+ return new common_1.OINOResult({
168
+ success: false,
169
+ status: 404,
170
+ statusText: "OINONoSqlAzureTable: table '" + this.nosqlParams.table + "' not found"
171
+ });
172
+ }
173
+ }
174
+ this.isValidated = true;
175
+ }
176
+ catch (e) {
177
+ return new common_1.OINOResult({ success: false, status: 500, statusText: "OINONoSqlAzureTable validate failed: " + e.message });
178
+ }
179
+ return new common_1.OINOResult();
180
+ }
181
+ /**
182
+ * Release the client reference.
183
+ */
184
+ async disconnect() {
185
+ this._tableClient = null;
186
+ this.isConnected = false;
187
+ this.isValidated = false;
188
+ }
189
+ // ── OINONoSql operations ──────────────────────────────────────────────
190
+ /**
191
+ * List entities from the table, applying native OData filtering for
192
+ * `partitionKey`, `rowKey`, and `timestamp` predicates server-side, and
193
+ * performing in-memory evaluation for the remaining predicates.
194
+ *
195
+ * @param filter optional query filter to apply
196
+ */
197
+ async listEntries(filter) {
198
+ if (!this._tableClient) {
199
+ throw new Error("OINONoSqlAzureTable: not connected");
200
+ }
201
+ const odata_filter = (filter && !filter.isEmpty())
202
+ ? OINONoSqlAzureTable.filterToOData(filter)
203
+ : undefined;
204
+ let final_odata_filter = odata_filter;
205
+ if (this.nosqlParams.staticPartitionKey) {
206
+ const pk_filter = `PartitionKey eq '${this.nosqlParams.staticPartitionKey.replace(/'/g, "''")}'`;
207
+ final_odata_filter = final_odata_filter ? `(${pk_filter}) and (${final_odata_filter})` : pk_filter;
208
+ }
209
+ const entries = [];
210
+ const list_options = final_odata_filter ? { queryOptions: { filter: final_odata_filter } } : {};
211
+ for await (const entity of this._tableClient.listEntities(list_options)) {
212
+ entries.push(OINONoSqlAzureTable.entityToEntry(entity));
213
+ }
214
+ if (!filter || filter.isEmpty()) {
215
+ return entries;
216
+ }
217
+ return entries.filter(e => nosql_1.OINONoSql.matchesEntry(e, filter));
218
+ }
219
+ /**
220
+ * Fetch a single entity by its primary key values.
221
+ *
222
+ * @param primaryKey [partitionKey, rowKey]
223
+ */
224
+ async getEntry(primaryKey) {
225
+ if (!this._tableClient) {
226
+ throw new Error("OINONoSqlAzureTable: not connected");
227
+ }
228
+ const pk = this.nosqlParams.staticPartitionKey ?? primaryKey[0] ?? "";
229
+ try {
230
+ const entity = await this._tableClient.getEntity(pk, primaryKey[1] ?? "");
231
+ return OINONoSqlAzureTable.entityToEntry(entity);
232
+ }
233
+ catch (e) {
234
+ if (e?.statusCode === 404 || e?.status === 404)
235
+ return null;
236
+ throw e;
237
+ }
238
+ }
239
+ /**
240
+ * Upsert (insert or replace) an entity.
241
+ *
242
+ * All fields in `entry.properties` are written as top-level entity
243
+ * properties in Azure Table Storage.
244
+ *
245
+ * @param entry entity to upsert
246
+ */
247
+ async upsertEntry(entry) {
248
+ if (!this._tableClient) {
249
+ throw new Error("OINONoSqlAzureTable: not connected");
250
+ }
251
+ const entity = {
252
+ partitionKey: this.nosqlParams.staticPartitionKey ?? entry.primaryKey[0] ?? "",
253
+ rowKey: entry.primaryKey[1] ?? "",
254
+ ...entry.properties
255
+ };
256
+ await this._tableClient.upsertEntity(entity, "Replace");
257
+ }
258
+ /**
259
+ * Batch-upsert using Azure Table Storage transactions. Each transaction
260
+ * is limited to 100 entities that share the same partition key. Entries
261
+ * are grouped by partition key first, then chunked to satisfy the limit.
262
+ */
263
+ async upsertEntries(entries) {
264
+ if (!this._tableClient) {
265
+ throw new Error("OINONoSqlAzureTable: not connected");
266
+ }
267
+ // Group by resolved partition key
268
+ const by_partition = new Map();
269
+ for (const entry of entries) {
270
+ const pk = this.nosqlParams.staticPartitionKey ?? entry.primaryKey[0] ?? "";
271
+ const entity = {
272
+ partitionKey: pk,
273
+ rowKey: entry.primaryKey[1] ?? "",
274
+ ...entry.properties
275
+ };
276
+ const bucket = by_partition.get(pk);
277
+ if (bucket) {
278
+ bucket.push(entity);
279
+ }
280
+ else {
281
+ by_partition.set(pk, [entity]);
282
+ }
283
+ }
284
+ // Submit one transaction per partition key, chunked to 100
285
+ for (const [, entities] of by_partition) {
286
+ for (let i = 0; i < entities.length; i += 100) {
287
+ const chunk = entities.slice(i, i + 100);
288
+ const actions = chunk.map(e => ["upsert", e, "Replace"]);
289
+ await this._tableClient.submitTransaction(actions);
290
+ }
291
+ }
292
+ }
293
+ /**
294
+ * Delete an entity.
295
+ *
296
+ * @param primaryKey [partitionKey, rowKey]
297
+ */
298
+ async deleteEntry(primaryKey) {
299
+ if (!this._tableClient) {
300
+ throw new Error("OINONoSqlAzureTable: not connected");
301
+ }
302
+ const pk = this.nosqlParams.staticPartitionKey ?? primaryKey[0] ?? "";
303
+ await this._tableClient.deleteEntity(pk, primaryKey[1] ?? "");
304
+ }
305
+ // ── OINODataSource datamodel initialisation ───────────────────────────
306
+ /**
307
+ * Attach a static `OINONoSqlDataModel` to the given API, adding all five
308
+ * standard fields.
309
+ *
310
+ * @param api the `OINONoSqlApi` whose data model is to be initialised
311
+ */
312
+ async initializeApiDatamodel(api) {
313
+ const no_sql_api = api;
314
+ const datamodel = new nosql_1.OINONoSqlDataModel(no_sql_api);
315
+ const ds = this;
316
+ const FIELD = { isPrimaryKey: false, isForeignKey: false, isAutoInc: false, isNotNull: false };
317
+ const PK = { isPrimaryKey: true, isForeignKey: false, isAutoInc: false, isNotNull: true };
318
+ datamodel.addField(new common_1.OINOStringDataField(ds, "partitionKey", "TEXT", PK, 1024));
319
+ datamodel.addField(new common_1.OINOStringDataField(ds, "rowKey", "TEXT", PK, 1024));
320
+ datamodel.addField(new common_1.OINODatetimeDataField(ds, "timestamp", "DATETIME", FIELD));
321
+ datamodel.addField(new common_1.OINOStringDataField(ds, "etag", "TEXT", FIELD, 256));
322
+ datamodel.addField(new common_1.OINOStringDataField(ds, "properties", "TEXT", FIELD, 65536));
323
+ no_sql_api.initializeDatamodel(datamodel);
324
+ }
325
+ // ── Private helpers ───────────────────────────────────────────────────
326
+ /**
327
+ * Convert an Azure Table Storage entity to an `OINONoSqlEntry`.
328
+ * System fields (`partitionKey`, `rowKey`, `timestamp`, `etag`) are
329
+ * extracted; all remaining properties are collected into `properties`.
330
+ *
331
+ * @param entity raw entity from the Azure SDK
332
+ */
333
+ static entityToEntry(entity) {
334
+ const { partitionKey, rowKey, timestamp, etag, ...rest } = entity;
335
+ const properties = {};
336
+ for (const key of Object.keys(rest)) {
337
+ if (!key.startsWith("odata."))
338
+ properties[key] = rest[key];
339
+ }
340
+ return {
341
+ primaryKey: [String(partitionKey ?? ""), String(rowKey ?? "")],
342
+ timestamp: timestamp instanceof Date ? timestamp : new Date(String(timestamp ?? "")),
343
+ etag: String(etag ?? ""),
344
+ properties
345
+ };
346
+ }
347
+ }
348
+ exports.OINONoSqlAzureTable = OINONoSqlAzureTable;
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OINONoSqlAzureTable = void 0;
4
+ var OINONoSqlAzureTable_js_1 = require("./OINONoSqlAzureTable.js");
5
+ Object.defineProperty(exports, "OINONoSqlAzureTable", { enumerable: true, get: function () { return OINONoSqlAzureTable_js_1.OINONoSqlAzureTable; } });
@@ -0,0 +1,344 @@
1
+ /*
2
+ * This Source Code Form is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5
+ */
6
+ import { TableClient, TableServiceClient } from "@azure/data-tables";
7
+ import { OINOResult, OINOQueryBooleanOperation, OINOQueryComparison, OINOQueryNullCheck, OINOStringDataField, OINODatetimeDataField } from "@oino-ts/common";
8
+ import { OINONoSql, OINONoSqlDataModel } from "@oino-ts/nosql";
9
+ /** Azure Table Storage OData field name mapping for system fields */
10
+ const ODATA_FIELD_MAP = {
11
+ partitionKey: "PartitionKey",
12
+ rowKey: "RowKey",
13
+ timestamp: "Timestamp"
14
+ };
15
+ /** Fields that can be translated to OData server-side filter expressions */
16
+ const ODATA_FILTERABLE_FIELDS = new Set(["partitionKey", "rowKey", "timestamp"]);
17
+ /**
18
+ * Azure Table Storage implementation of `OINONoSql`.
19
+ *
20
+ * Authenticates using an Azure Storage connection string. Connection parameters map as:
21
+ * - `params.url` → table service endpoint, e.g. `https://<account>.table.core.windows.net`
22
+ * - `params.table` → table name
23
+ * - `params.connectionStr` → Azure Storage connection string (e.g. `DefaultEndpointsProtocol=https;AccountName=...`)
24
+ *
25
+ * Register and use via the factory:
26
+ * ```ts
27
+ * import { OINONoSqlFactory } from "@oino-ts/nosql"
28
+ * import { OINONoSqlAzureTable } from "@oino-ts/nosql-azure"
29
+ *
30
+ * OINONoSqlFactory.registerNoSql("OINONoSqlAzureTable", OINONoSqlAzureTable)
31
+ *
32
+ * const nosql = await OINONoSqlFactory.createNoSql({
33
+ * type: "OINONoSqlAzureTable",
34
+ * url: "https://myaccount.table.core.windows.net",
35
+ * table: "myTable",
36
+ * connectionStr: process.env.AZURE_STORAGE_CONNECTION_STRING
37
+ * })
38
+ * const api = await OINONoSqlFactory.createApi(nosql, {
39
+ * apiName: "entities",
40
+ * tableName: "myTable"
41
+ * })
42
+ * ```
43
+ *
44
+ * ## Static partition key
45
+ *
46
+ * Set `staticPartitionKey` in the params to scope all operations to a fixed
47
+ * partition key. This lets multiple logical tables share one physical Azure
48
+ * Table Storage table:
49
+ * ```ts
50
+ * const nosql = await OINONoSqlFactory.createNoSql({
51
+ * type: "OINONoSqlAzureTable",
52
+ * url: "https://myaccount.table.core.windows.net",
53
+ * table: "sharedTable",
54
+ * connectionStr: process.env.AZURE_STORAGE_CONNECTION_STRING,
55
+ * staticPartitionKey: "myLogicalTable"
56
+ * })
57
+ * ```
58
+ *
59
+ * ## Filter support
60
+ *
61
+ * Filters on `partitionKey`, `rowKey`, and `timestamp` are translated to native
62
+ * Azure Table Storage OData query filter expressions and evaluated server-side.
63
+ * Filters on `etag` are evaluated in-memory after the listing.
64
+ *
65
+ * OData operators supported: `eq`, `ne`, `lt`, `le`, `gt`, `ge`, `and`, `or`, `not`.
66
+ * The `like` operator is not supported by OData and is evaluated in-memory.
67
+ */
68
+ export class OINONoSqlAzureTable extends OINONoSql {
69
+ _tableClient = null;
70
+ // ── ODataFilter translation ───────────────────────────────────────────
71
+ /**
72
+ * Attempt to translate an `OINOQueryFilter` tree to an Azure Table Storage
73
+ * OData v3 filter expression string.
74
+ *
75
+ * Returns `undefined` for sub-trees that contain untranslatable predicates
76
+ * (e.g. filter on `etag`, or a `like` comparison). The caller falls back
77
+ * to in-memory evaluation for those cases.
78
+ *
79
+ * @param filter filter to translate
80
+ */
81
+ static filterToOData(filter) {
82
+ if (filter.isEmpty())
83
+ return undefined;
84
+ const op = filter.operator;
85
+ if (op === OINOQueryBooleanOperation.and) {
86
+ const left = OINONoSqlAzureTable.filterToOData(filter.leftSide);
87
+ const right = OINONoSqlAzureTable.filterToOData(filter.rightSide);
88
+ if (left && right)
89
+ return `(${left}) and (${right})`;
90
+ return left ?? right;
91
+ }
92
+ if (op === OINOQueryBooleanOperation.or) {
93
+ const left = OINONoSqlAzureTable.filterToOData(filter.leftSide);
94
+ const right = OINONoSqlAzureTable.filterToOData(filter.rightSide);
95
+ if (left && right)
96
+ return `(${left}) or (${right})`;
97
+ return undefined;
98
+ }
99
+ if (op === OINOQueryBooleanOperation.not) {
100
+ const inner = OINONoSqlAzureTable.filterToOData(filter.rightSide);
101
+ if (inner)
102
+ return `not (${inner})`;
103
+ return undefined;
104
+ }
105
+ const field_name = filter.leftSide;
106
+ if (!ODATA_FILTERABLE_FIELDS.has(field_name))
107
+ return undefined;
108
+ const odata_field = ODATA_FIELD_MAP[field_name];
109
+ const compare_value = filter.rightSide;
110
+ if (op === OINOQueryNullCheck.isnull)
111
+ return `${odata_field} eq null`;
112
+ if (op === OINOQueryNullCheck.isNotNull)
113
+ return `${odata_field} ne null`;
114
+ if (op === OINOQueryComparison.like)
115
+ return undefined;
116
+ const odata_op = op;
117
+ if (field_name === "timestamp") {
118
+ const iso_date = new Date(compare_value).toISOString();
119
+ return `${odata_field} ${odata_op} datetime'${iso_date}'`;
120
+ }
121
+ const escaped = compare_value.replace(/'/g, "''");
122
+ return `${odata_field} ${odata_op} '${escaped}'`;
123
+ }
124
+ // ── OINODataSource lifecycle ──────────────────────────────────────────
125
+ /**
126
+ * Initialise the Azure SDK table client. Does not perform any network call.
127
+ */
128
+ async connect() {
129
+ try {
130
+ if (this.nosqlParams.connectionStr) {
131
+ this._tableClient = TableClient.fromConnectionString(this.nosqlParams.connectionStr, this.nosqlParams.table);
132
+ }
133
+ else {
134
+ return new OINOResult({
135
+ success: false,
136
+ status: 400,
137
+ statusText: "OINONoSqlAzureTable: params.connectionStr is required"
138
+ });
139
+ }
140
+ this.isConnected = true;
141
+ }
142
+ catch (e) {
143
+ return new OINOResult({ success: false, status: 500, statusText: "OINONoSqlAzureTable connect failed: " + e.message });
144
+ }
145
+ return new OINOResult();
146
+ }
147
+ /**
148
+ * Verify that the target table exists and is accessible.
149
+ */
150
+ async validate() {
151
+ if (!this._tableClient) {
152
+ return new OINOResult({ success: false, status: 500, statusText: "OINONoSqlAzureTable: not connected" });
153
+ }
154
+ try {
155
+ if (this.nosqlParams.connectionStr) {
156
+ const service_client = TableServiceClient.fromConnectionString(this.nosqlParams.connectionStr);
157
+ const tables = service_client.listTables({ queryOptions: { filter: `TableName eq '${this.nosqlParams.table}'` } });
158
+ let found = false;
159
+ for await (const _t of tables) {
160
+ found = true;
161
+ break;
162
+ }
163
+ if (!found) {
164
+ return new OINOResult({
165
+ success: false,
166
+ status: 404,
167
+ statusText: "OINONoSqlAzureTable: table '" + this.nosqlParams.table + "' not found"
168
+ });
169
+ }
170
+ }
171
+ this.isValidated = true;
172
+ }
173
+ catch (e) {
174
+ return new OINOResult({ success: false, status: 500, statusText: "OINONoSqlAzureTable validate failed: " + e.message });
175
+ }
176
+ return new OINOResult();
177
+ }
178
+ /**
179
+ * Release the client reference.
180
+ */
181
+ async disconnect() {
182
+ this._tableClient = null;
183
+ this.isConnected = false;
184
+ this.isValidated = false;
185
+ }
186
+ // ── OINONoSql operations ──────────────────────────────────────────────
187
+ /**
188
+ * List entities from the table, applying native OData filtering for
189
+ * `partitionKey`, `rowKey`, and `timestamp` predicates server-side, and
190
+ * performing in-memory evaluation for the remaining predicates.
191
+ *
192
+ * @param filter optional query filter to apply
193
+ */
194
+ async listEntries(filter) {
195
+ if (!this._tableClient) {
196
+ throw new Error("OINONoSqlAzureTable: not connected");
197
+ }
198
+ const odata_filter = (filter && !filter.isEmpty())
199
+ ? OINONoSqlAzureTable.filterToOData(filter)
200
+ : undefined;
201
+ let final_odata_filter = odata_filter;
202
+ if (this.nosqlParams.staticPartitionKey) {
203
+ const pk_filter = `PartitionKey eq '${this.nosqlParams.staticPartitionKey.replace(/'/g, "''")}'`;
204
+ final_odata_filter = final_odata_filter ? `(${pk_filter}) and (${final_odata_filter})` : pk_filter;
205
+ }
206
+ const entries = [];
207
+ const list_options = final_odata_filter ? { queryOptions: { filter: final_odata_filter } } : {};
208
+ for await (const entity of this._tableClient.listEntities(list_options)) {
209
+ entries.push(OINONoSqlAzureTable.entityToEntry(entity));
210
+ }
211
+ if (!filter || filter.isEmpty()) {
212
+ return entries;
213
+ }
214
+ return entries.filter(e => OINONoSql.matchesEntry(e, filter));
215
+ }
216
+ /**
217
+ * Fetch a single entity by its primary key values.
218
+ *
219
+ * @param primaryKey [partitionKey, rowKey]
220
+ */
221
+ async getEntry(primaryKey) {
222
+ if (!this._tableClient) {
223
+ throw new Error("OINONoSqlAzureTable: not connected");
224
+ }
225
+ const pk = this.nosqlParams.staticPartitionKey ?? primaryKey[0] ?? "";
226
+ try {
227
+ const entity = await this._tableClient.getEntity(pk, primaryKey[1] ?? "");
228
+ return OINONoSqlAzureTable.entityToEntry(entity);
229
+ }
230
+ catch (e) {
231
+ if (e?.statusCode === 404 || e?.status === 404)
232
+ return null;
233
+ throw e;
234
+ }
235
+ }
236
+ /**
237
+ * Upsert (insert or replace) an entity.
238
+ *
239
+ * All fields in `entry.properties` are written as top-level entity
240
+ * properties in Azure Table Storage.
241
+ *
242
+ * @param entry entity to upsert
243
+ */
244
+ async upsertEntry(entry) {
245
+ if (!this._tableClient) {
246
+ throw new Error("OINONoSqlAzureTable: not connected");
247
+ }
248
+ const entity = {
249
+ partitionKey: this.nosqlParams.staticPartitionKey ?? entry.primaryKey[0] ?? "",
250
+ rowKey: entry.primaryKey[1] ?? "",
251
+ ...entry.properties
252
+ };
253
+ await this._tableClient.upsertEntity(entity, "Replace");
254
+ }
255
+ /**
256
+ * Batch-upsert using Azure Table Storage transactions. Each transaction
257
+ * is limited to 100 entities that share the same partition key. Entries
258
+ * are grouped by partition key first, then chunked to satisfy the limit.
259
+ */
260
+ async upsertEntries(entries) {
261
+ if (!this._tableClient) {
262
+ throw new Error("OINONoSqlAzureTable: not connected");
263
+ }
264
+ // Group by resolved partition key
265
+ const by_partition = new Map();
266
+ for (const entry of entries) {
267
+ const pk = this.nosqlParams.staticPartitionKey ?? entry.primaryKey[0] ?? "";
268
+ const entity = {
269
+ partitionKey: pk,
270
+ rowKey: entry.primaryKey[1] ?? "",
271
+ ...entry.properties
272
+ };
273
+ const bucket = by_partition.get(pk);
274
+ if (bucket) {
275
+ bucket.push(entity);
276
+ }
277
+ else {
278
+ by_partition.set(pk, [entity]);
279
+ }
280
+ }
281
+ // Submit one transaction per partition key, chunked to 100
282
+ for (const [, entities] of by_partition) {
283
+ for (let i = 0; i < entities.length; i += 100) {
284
+ const chunk = entities.slice(i, i + 100);
285
+ const actions = chunk.map(e => ["upsert", e, "Replace"]);
286
+ await this._tableClient.submitTransaction(actions);
287
+ }
288
+ }
289
+ }
290
+ /**
291
+ * Delete an entity.
292
+ *
293
+ * @param primaryKey [partitionKey, rowKey]
294
+ */
295
+ async deleteEntry(primaryKey) {
296
+ if (!this._tableClient) {
297
+ throw new Error("OINONoSqlAzureTable: not connected");
298
+ }
299
+ const pk = this.nosqlParams.staticPartitionKey ?? primaryKey[0] ?? "";
300
+ await this._tableClient.deleteEntity(pk, primaryKey[1] ?? "");
301
+ }
302
+ // ── OINODataSource datamodel initialisation ───────────────────────────
303
+ /**
304
+ * Attach a static `OINONoSqlDataModel` to the given API, adding all five
305
+ * standard fields.
306
+ *
307
+ * @param api the `OINONoSqlApi` whose data model is to be initialised
308
+ */
309
+ async initializeApiDatamodel(api) {
310
+ const no_sql_api = api;
311
+ const datamodel = new OINONoSqlDataModel(no_sql_api);
312
+ const ds = this;
313
+ const FIELD = { isPrimaryKey: false, isForeignKey: false, isAutoInc: false, isNotNull: false };
314
+ const PK = { isPrimaryKey: true, isForeignKey: false, isAutoInc: false, isNotNull: true };
315
+ datamodel.addField(new OINOStringDataField(ds, "partitionKey", "TEXT", PK, 1024));
316
+ datamodel.addField(new OINOStringDataField(ds, "rowKey", "TEXT", PK, 1024));
317
+ datamodel.addField(new OINODatetimeDataField(ds, "timestamp", "DATETIME", FIELD));
318
+ datamodel.addField(new OINOStringDataField(ds, "etag", "TEXT", FIELD, 256));
319
+ datamodel.addField(new OINOStringDataField(ds, "properties", "TEXT", FIELD, 65536));
320
+ no_sql_api.initializeDatamodel(datamodel);
321
+ }
322
+ // ── Private helpers ───────────────────────────────────────────────────
323
+ /**
324
+ * Convert an Azure Table Storage entity to an `OINONoSqlEntry`.
325
+ * System fields (`partitionKey`, `rowKey`, `timestamp`, `etag`) are
326
+ * extracted; all remaining properties are collected into `properties`.
327
+ *
328
+ * @param entity raw entity from the Azure SDK
329
+ */
330
+ static entityToEntry(entity) {
331
+ const { partitionKey, rowKey, timestamp, etag, ...rest } = entity;
332
+ const properties = {};
333
+ for (const key of Object.keys(rest)) {
334
+ if (!key.startsWith("odata."))
335
+ properties[key] = rest[key];
336
+ }
337
+ return {
338
+ primaryKey: [String(partitionKey ?? ""), String(rowKey ?? "")],
339
+ timestamp: timestamp instanceof Date ? timestamp : new Date(String(timestamp ?? "")),
340
+ etag: String(etag ?? ""),
341
+ properties
342
+ };
343
+ }
344
+ }
@@ -0,0 +1 @@
1
+ export { OINONoSqlAzureTable } from "./OINONoSqlAzureTable.js";
@@ -0,0 +1,130 @@
1
+ import { OINOApi, OINOResult, OINOQueryFilter } from "@oino-ts/common";
2
+ import { OINONoSql } from "@oino-ts/nosql";
3
+ import { type OINONoSqlEntry } from "@oino-ts/nosql";
4
+ /**
5
+ * Azure Table Storage implementation of `OINONoSql`.
6
+ *
7
+ * Authenticates using an Azure Storage connection string. Connection parameters map as:
8
+ * - `params.url` → table service endpoint, e.g. `https://<account>.table.core.windows.net`
9
+ * - `params.table` → table name
10
+ * - `params.connectionStr` → Azure Storage connection string (e.g. `DefaultEndpointsProtocol=https;AccountName=...`)
11
+ *
12
+ * Register and use via the factory:
13
+ * ```ts
14
+ * import { OINONoSqlFactory } from "@oino-ts/nosql"
15
+ * import { OINONoSqlAzureTable } from "@oino-ts/nosql-azure"
16
+ *
17
+ * OINONoSqlFactory.registerNoSql("OINONoSqlAzureTable", OINONoSqlAzureTable)
18
+ *
19
+ * const nosql = await OINONoSqlFactory.createNoSql({
20
+ * type: "OINONoSqlAzureTable",
21
+ * url: "https://myaccount.table.core.windows.net",
22
+ * table: "myTable",
23
+ * connectionStr: process.env.AZURE_STORAGE_CONNECTION_STRING
24
+ * })
25
+ * const api = await OINONoSqlFactory.createApi(nosql, {
26
+ * apiName: "entities",
27
+ * tableName: "myTable"
28
+ * })
29
+ * ```
30
+ *
31
+ * ## Static partition key
32
+ *
33
+ * Set `staticPartitionKey` in the params to scope all operations to a fixed
34
+ * partition key. This lets multiple logical tables share one physical Azure
35
+ * Table Storage table:
36
+ * ```ts
37
+ * const nosql = await OINONoSqlFactory.createNoSql({
38
+ * type: "OINONoSqlAzureTable",
39
+ * url: "https://myaccount.table.core.windows.net",
40
+ * table: "sharedTable",
41
+ * connectionStr: process.env.AZURE_STORAGE_CONNECTION_STRING,
42
+ * staticPartitionKey: "myLogicalTable"
43
+ * })
44
+ * ```
45
+ *
46
+ * ## Filter support
47
+ *
48
+ * Filters on `partitionKey`, `rowKey`, and `timestamp` are translated to native
49
+ * Azure Table Storage OData query filter expressions and evaluated server-side.
50
+ * Filters on `etag` are evaluated in-memory after the listing.
51
+ *
52
+ * OData operators supported: `eq`, `ne`, `lt`, `le`, `gt`, `ge`, `and`, `or`, `not`.
53
+ * The `like` operator is not supported by OData and is evaluated in-memory.
54
+ */
55
+ export declare class OINONoSqlAzureTable extends OINONoSql {
56
+ private _tableClient;
57
+ /**
58
+ * Attempt to translate an `OINOQueryFilter` tree to an Azure Table Storage
59
+ * OData v3 filter expression string.
60
+ *
61
+ * Returns `undefined` for sub-trees that contain untranslatable predicates
62
+ * (e.g. filter on `etag`, or a `like` comparison). The caller falls back
63
+ * to in-memory evaluation for those cases.
64
+ *
65
+ * @param filter filter to translate
66
+ */
67
+ static filterToOData(filter: OINOQueryFilter): string | undefined;
68
+ /**
69
+ * Initialise the Azure SDK table client. Does not perform any network call.
70
+ */
71
+ connect(): Promise<OINOResult>;
72
+ /**
73
+ * Verify that the target table exists and is accessible.
74
+ */
75
+ validate(): Promise<OINOResult>;
76
+ /**
77
+ * Release the client reference.
78
+ */
79
+ disconnect(): Promise<void>;
80
+ /**
81
+ * List entities from the table, applying native OData filtering for
82
+ * `partitionKey`, `rowKey`, and `timestamp` predicates server-side, and
83
+ * performing in-memory evaluation for the remaining predicates.
84
+ *
85
+ * @param filter optional query filter to apply
86
+ */
87
+ listEntries(filter?: OINOQueryFilter): Promise<OINONoSqlEntry[]>;
88
+ /**
89
+ * Fetch a single entity by its primary key values.
90
+ *
91
+ * @param primaryKey [partitionKey, rowKey]
92
+ */
93
+ getEntry(primaryKey: string[]): Promise<OINONoSqlEntry | null>;
94
+ /**
95
+ * Upsert (insert or replace) an entity.
96
+ *
97
+ * All fields in `entry.properties` are written as top-level entity
98
+ * properties in Azure Table Storage.
99
+ *
100
+ * @param entry entity to upsert
101
+ */
102
+ upsertEntry(entry: OINONoSqlEntry): Promise<void>;
103
+ /**
104
+ * Batch-upsert using Azure Table Storage transactions. Each transaction
105
+ * is limited to 100 entities that share the same partition key. Entries
106
+ * are grouped by partition key first, then chunked to satisfy the limit.
107
+ */
108
+ upsertEntries(entries: OINONoSqlEntry[]): Promise<void>;
109
+ /**
110
+ * Delete an entity.
111
+ *
112
+ * @param primaryKey [partitionKey, rowKey]
113
+ */
114
+ deleteEntry(primaryKey: string[]): Promise<void>;
115
+ /**
116
+ * Attach a static `OINONoSqlDataModel` to the given API, adding all five
117
+ * standard fields.
118
+ *
119
+ * @param api the `OINONoSqlApi` whose data model is to be initialised
120
+ */
121
+ initializeApiDatamodel(api: OINOApi): Promise<void>;
122
+ /**
123
+ * Convert an Azure Table Storage entity to an `OINONoSqlEntry`.
124
+ * System fields (`partitionKey`, `rowKey`, `timestamp`, `etag`) are
125
+ * extracted; all remaining properties are collected into `properties`.
126
+ *
127
+ * @param entity raw entity from the Azure SDK
128
+ */
129
+ private static entityToEntry;
130
+ }
@@ -0,0 +1 @@
1
+ export { OINONoSqlAzureTable } from "./OINONoSqlAzureTable.js";
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@oino-ts/nosql-azure",
3
+ "version": "1.0.0",
4
+ "description": "OINO TS package for using Azure Table Storage as a REST API.",
5
+ "author": "Matias Kiviniemi (pragmatta)",
6
+ "license": "MPL-2.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/pragmatta/oino-ts.git"
10
+ },
11
+ "keywords": [
12
+ "nosql",
13
+ "storage",
14
+ "rest-api",
15
+ "typescript",
16
+ "library",
17
+ "azure"
18
+ ],
19
+ "main": "./dist/cjs/index.js",
20
+ "module": "./dist/esm/index.js",
21
+ "types": "./dist/types/index.d.ts",
22
+ "dependencies": {
23
+ "@azure/data-tables": "^13.0.0",
24
+ "@oino-ts/nosql": "1.0.0",
25
+ "@oino-ts/common": "1.0.0"
26
+ },
27
+ "devDependencies": {
28
+ "@oino-ts/types": "1.0.0",
29
+ "@types/bun": "^1.1.14",
30
+ "@types/node": "^22.0.00",
31
+ "typescript": "~5.9.0"
32
+ },
33
+ "files": [
34
+ "src/*.ts",
35
+ "dist/cjs/*.js",
36
+ "dist/esm/*.js",
37
+ "dist/types/*.d.ts"
38
+ ]
39
+ }
@@ -0,0 +1,375 @@
1
+ /*
2
+ * This Source Code Form is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5
+ */
6
+
7
+ import {
8
+ TableClient,
9
+ TableServiceClient,
10
+ type TableEntity,
11
+ type TransactionAction
12
+ } from "@azure/data-tables"
13
+
14
+ import { OINOApi, OINOResult, OINOQueryFilter, OINOQueryBooleanOperation, OINOQueryComparison, OINOQueryNullCheck, OINOStringDataField, OINODatetimeDataField, type OINODataFieldParams } from "@oino-ts/common"
15
+ import { OINONoSql, OINONoSqlDataModel, OINONoSqlApi } from "@oino-ts/nosql"
16
+ import { type OINONoSqlEntry } from "@oino-ts/nosql"
17
+
18
+ /** Azure Table Storage OData field name mapping for system fields */
19
+ const ODATA_FIELD_MAP: Record<string, string> = {
20
+ partitionKey: "PartitionKey",
21
+ rowKey: "RowKey",
22
+ timestamp: "Timestamp"
23
+ }
24
+
25
+ /** Fields that can be translated to OData server-side filter expressions */
26
+ const ODATA_FILTERABLE_FIELDS = new Set(["partitionKey", "rowKey", "timestamp"])
27
+
28
+ /**
29
+ * Azure Table Storage implementation of `OINONoSql`.
30
+ *
31
+ * Authenticates using an Azure Storage connection string. Connection parameters map as:
32
+ * - `params.url` → table service endpoint, e.g. `https://<account>.table.core.windows.net`
33
+ * - `params.table` → table name
34
+ * - `params.connectionStr` → Azure Storage connection string (e.g. `DefaultEndpointsProtocol=https;AccountName=...`)
35
+ *
36
+ * Register and use via the factory:
37
+ * ```ts
38
+ * import { OINONoSqlFactory } from "@oino-ts/nosql"
39
+ * import { OINONoSqlAzureTable } from "@oino-ts/nosql-azure"
40
+ *
41
+ * OINONoSqlFactory.registerNoSql("OINONoSqlAzureTable", OINONoSqlAzureTable)
42
+ *
43
+ * const nosql = await OINONoSqlFactory.createNoSql({
44
+ * type: "OINONoSqlAzureTable",
45
+ * url: "https://myaccount.table.core.windows.net",
46
+ * table: "myTable",
47
+ * connectionStr: process.env.AZURE_STORAGE_CONNECTION_STRING
48
+ * })
49
+ * const api = await OINONoSqlFactory.createApi(nosql, {
50
+ * apiName: "entities",
51
+ * tableName: "myTable"
52
+ * })
53
+ * ```
54
+ *
55
+ * ## Static partition key
56
+ *
57
+ * Set `staticPartitionKey` in the params to scope all operations to a fixed
58
+ * partition key. This lets multiple logical tables share one physical Azure
59
+ * Table Storage table:
60
+ * ```ts
61
+ * const nosql = await OINONoSqlFactory.createNoSql({
62
+ * type: "OINONoSqlAzureTable",
63
+ * url: "https://myaccount.table.core.windows.net",
64
+ * table: "sharedTable",
65
+ * connectionStr: process.env.AZURE_STORAGE_CONNECTION_STRING,
66
+ * staticPartitionKey: "myLogicalTable"
67
+ * })
68
+ * ```
69
+ *
70
+ * ## Filter support
71
+ *
72
+ * Filters on `partitionKey`, `rowKey`, and `timestamp` are translated to native
73
+ * Azure Table Storage OData query filter expressions and evaluated server-side.
74
+ * Filters on `etag` are evaluated in-memory after the listing.
75
+ *
76
+ * OData operators supported: `eq`, `ne`, `lt`, `le`, `gt`, `ge`, `and`, `or`, `not`.
77
+ * The `like` operator is not supported by OData and is evaluated in-memory.
78
+ */
79
+ export class OINONoSqlAzureTable extends OINONoSql {
80
+ private _tableClient: TableClient | null = null
81
+
82
+ // ── ODataFilter translation ───────────────────────────────────────────
83
+
84
+ /**
85
+ * Attempt to translate an `OINOQueryFilter` tree to an Azure Table Storage
86
+ * OData v3 filter expression string.
87
+ *
88
+ * Returns `undefined` for sub-trees that contain untranslatable predicates
89
+ * (e.g. filter on `etag`, or a `like` comparison). The caller falls back
90
+ * to in-memory evaluation for those cases.
91
+ *
92
+ * @param filter filter to translate
93
+ */
94
+ static filterToOData(filter: OINOQueryFilter): string | undefined {
95
+ if (filter.isEmpty()) return undefined
96
+
97
+ const op = filter.operator
98
+
99
+ if (op === OINOQueryBooleanOperation.and) {
100
+ const left = OINONoSqlAzureTable.filterToOData(filter.leftSide as OINOQueryFilter)
101
+ const right = OINONoSqlAzureTable.filterToOData(filter.rightSide as OINOQueryFilter)
102
+ if (left && right) return `(${left}) and (${right})`
103
+ return left ?? right
104
+ }
105
+
106
+ if (op === OINOQueryBooleanOperation.or) {
107
+ const left = OINONoSqlAzureTable.filterToOData(filter.leftSide as OINOQueryFilter)
108
+ const right = OINONoSqlAzureTable.filterToOData(filter.rightSide as OINOQueryFilter)
109
+ if (left && right) return `(${left}) or (${right})`
110
+ return undefined
111
+ }
112
+
113
+ if (op === OINOQueryBooleanOperation.not) {
114
+ const inner = OINONoSqlAzureTable.filterToOData(filter.rightSide as OINOQueryFilter)
115
+ if (inner) return `not (${inner})`
116
+ return undefined
117
+ }
118
+
119
+ const field_name = filter.leftSide as string
120
+ if (!ODATA_FILTERABLE_FIELDS.has(field_name)) return undefined
121
+
122
+ const odata_field = ODATA_FIELD_MAP[field_name]
123
+ const compare_value = filter.rightSide as string
124
+
125
+ if (op === OINOQueryNullCheck.isnull) return `${odata_field} eq null`
126
+ if (op === OINOQueryNullCheck.isNotNull) return `${odata_field} ne null`
127
+
128
+ if (op === OINOQueryComparison.like) return undefined
129
+
130
+ const odata_op = op as string
131
+
132
+ if (field_name === "timestamp") {
133
+ const iso_date = new Date(compare_value).toISOString()
134
+ return `${odata_field} ${odata_op} datetime'${iso_date}'`
135
+ }
136
+
137
+ const escaped = compare_value.replace(/'/g, "''")
138
+ return `${odata_field} ${odata_op} '${escaped}'`
139
+ }
140
+
141
+ // ── OINODataSource lifecycle ──────────────────────────────────────────
142
+
143
+ /**
144
+ * Initialise the Azure SDK table client. Does not perform any network call.
145
+ */
146
+ async connect(): Promise<OINOResult> {
147
+ try {
148
+ if (this.nosqlParams.connectionStr) {
149
+ this._tableClient = TableClient.fromConnectionString(
150
+ this.nosqlParams.connectionStr,
151
+ this.nosqlParams.table
152
+ )
153
+ } else {
154
+ return new OINOResult({
155
+ success: false,
156
+ status: 400,
157
+ statusText: "OINONoSqlAzureTable: params.connectionStr is required"
158
+ })
159
+ }
160
+ this.isConnected = true
161
+ } catch (e: any) {
162
+ return new OINOResult({ success: false, status: 500, statusText: "OINONoSqlAzureTable connect failed: " + e.message })
163
+ }
164
+ return new OINOResult()
165
+ }
166
+
167
+ /**
168
+ * Verify that the target table exists and is accessible.
169
+ */
170
+ async validate(): Promise<OINOResult> {
171
+ if (!this._tableClient) {
172
+ return new OINOResult({ success: false, status: 500, statusText: "OINONoSqlAzureTable: not connected" })
173
+ }
174
+ try {
175
+ if (this.nosqlParams.connectionStr) {
176
+ const service_client = TableServiceClient.fromConnectionString(this.nosqlParams.connectionStr)
177
+ const tables = service_client.listTables({ queryOptions: { filter: `TableName eq '${this.nosqlParams.table}'` } })
178
+ let found = false
179
+ for await (const _t of tables) {
180
+ found = true
181
+ break
182
+ }
183
+ if (!found) {
184
+ return new OINOResult({
185
+ success: false,
186
+ status: 404,
187
+ statusText: "OINONoSqlAzureTable: table '" + this.nosqlParams.table + "' not found"
188
+ })
189
+ }
190
+ }
191
+ this.isValidated = true
192
+ } catch (e: any) {
193
+ return new OINOResult({ success: false, status: 500, statusText: "OINONoSqlAzureTable validate failed: " + e.message })
194
+ }
195
+ return new OINOResult()
196
+ }
197
+
198
+ /**
199
+ * Release the client reference.
200
+ */
201
+ async disconnect(): Promise<void> {
202
+ this._tableClient = null
203
+ this.isConnected = false
204
+ this.isValidated = false
205
+ }
206
+
207
+ // ── OINONoSql operations ──────────────────────────────────────────────
208
+
209
+ /**
210
+ * List entities from the table, applying native OData filtering for
211
+ * `partitionKey`, `rowKey`, and `timestamp` predicates server-side, and
212
+ * performing in-memory evaluation for the remaining predicates.
213
+ *
214
+ * @param filter optional query filter to apply
215
+ */
216
+ async listEntries(filter?: OINOQueryFilter): Promise<OINONoSqlEntry[]> {
217
+ if (!this._tableClient) {
218
+ throw new Error("OINONoSqlAzureTable: not connected")
219
+ }
220
+
221
+ const odata_filter = (filter && !filter.isEmpty())
222
+ ? OINONoSqlAzureTable.filterToOData(filter)
223
+ : undefined
224
+
225
+ let final_odata_filter = odata_filter
226
+ if (this.nosqlParams.staticPartitionKey) {
227
+ const pk_filter = `PartitionKey eq '${this.nosqlParams.staticPartitionKey.replace(/'/g, "''")}'`
228
+ final_odata_filter = final_odata_filter ? `(${pk_filter}) and (${final_odata_filter})` : pk_filter
229
+ }
230
+
231
+ const entries: OINONoSqlEntry[] = []
232
+ const list_options = final_odata_filter ? { queryOptions: { filter: final_odata_filter } } : {}
233
+
234
+ for await (const entity of this._tableClient.listEntities<TableEntity<Record<string, unknown>>>(list_options)) {
235
+ entries.push(OINONoSqlAzureTable.entityToEntry(entity))
236
+ }
237
+
238
+ if (!filter || filter.isEmpty()) {
239
+ return entries
240
+ }
241
+ return entries.filter(e => OINONoSql.matchesEntry(e, filter))
242
+ }
243
+
244
+ /**
245
+ * Fetch a single entity by its primary key values.
246
+ *
247
+ * @param primaryKey [partitionKey, rowKey]
248
+ */
249
+ async getEntry(primaryKey: string[]): Promise<OINONoSqlEntry | null> {
250
+ if (!this._tableClient) {
251
+ throw new Error("OINONoSqlAzureTable: not connected")
252
+ }
253
+ const pk = this.nosqlParams.staticPartitionKey ?? primaryKey[0] ?? ""
254
+ try {
255
+ const entity = await this._tableClient.getEntity<TableEntity<Record<string, unknown>>>(pk, primaryKey[1] ?? "")
256
+ return OINONoSqlAzureTable.entityToEntry(entity)
257
+ } catch (e: any) {
258
+ if (e?.statusCode === 404 || e?.status === 404) return null
259
+ throw e
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Upsert (insert or replace) an entity.
265
+ *
266
+ * All fields in `entry.properties` are written as top-level entity
267
+ * properties in Azure Table Storage.
268
+ *
269
+ * @param entry entity to upsert
270
+ */
271
+ async upsertEntry(entry: OINONoSqlEntry): Promise<void> {
272
+ if (!this._tableClient) {
273
+ throw new Error("OINONoSqlAzureTable: not connected")
274
+ }
275
+ const entity: TableEntity<Record<string, unknown>> = {
276
+ partitionKey: this.nosqlParams.staticPartitionKey ?? entry.primaryKey[0] ?? "",
277
+ rowKey: entry.primaryKey[1] ?? "",
278
+ ...entry.properties
279
+ }
280
+ await this._tableClient.upsertEntity(entity, "Replace")
281
+ }
282
+
283
+ /**
284
+ * Batch-upsert using Azure Table Storage transactions. Each transaction
285
+ * is limited to 100 entities that share the same partition key. Entries
286
+ * are grouped by partition key first, then chunked to satisfy the limit.
287
+ */
288
+ override async upsertEntries(entries: OINONoSqlEntry[]): Promise<void> {
289
+ if (!this._tableClient) {
290
+ throw new Error("OINONoSqlAzureTable: not connected")
291
+ }
292
+ // Group by resolved partition key
293
+ const by_partition = new Map<string, TableEntity<Record<string, unknown>>[]>()
294
+ for (const entry of entries) {
295
+ const pk = this.nosqlParams.staticPartitionKey ?? entry.primaryKey[0] ?? ""
296
+ const entity: TableEntity<Record<string, unknown>> = {
297
+ partitionKey: pk,
298
+ rowKey: entry.primaryKey[1] ?? "",
299
+ ...entry.properties
300
+ }
301
+ const bucket = by_partition.get(pk)
302
+ if (bucket) {
303
+ bucket.push(entity)
304
+ } else {
305
+ by_partition.set(pk, [entity])
306
+ }
307
+ }
308
+ // Submit one transaction per partition key, chunked to 100
309
+ for (const [, entities] of by_partition) {
310
+ for (let i = 0; i < entities.length; i += 100) {
311
+ const chunk = entities.slice(i, i + 100)
312
+ const actions: TransactionAction[] = chunk.map(e => ["upsert", e, "Replace"] as TransactionAction)
313
+ await this._tableClient.submitTransaction(actions)
314
+ }
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Delete an entity.
320
+ *
321
+ * @param primaryKey [partitionKey, rowKey]
322
+ */
323
+ async deleteEntry(primaryKey: string[]): Promise<void> {
324
+ if (!this._tableClient) {
325
+ throw new Error("OINONoSqlAzureTable: not connected")
326
+ }
327
+ const pk = this.nosqlParams.staticPartitionKey ?? primaryKey[0] ?? ""
328
+ await this._tableClient.deleteEntity(pk, primaryKey[1] ?? "")
329
+ }
330
+
331
+ // ── OINODataSource datamodel initialisation ───────────────────────────
332
+
333
+ /**
334
+ * Attach a static `OINONoSqlDataModel` to the given API, adding all five
335
+ * standard fields.
336
+ *
337
+ * @param api the `OINONoSqlApi` whose data model is to be initialised
338
+ */
339
+ async initializeApiDatamodel(api: OINOApi): Promise<void> {
340
+ const no_sql_api = api as OINONoSqlApi
341
+ const datamodel = new OINONoSqlDataModel(no_sql_api)
342
+ const ds = this
343
+ const FIELD: OINODataFieldParams = { isPrimaryKey: false, isForeignKey: false, isAutoInc: false, isNotNull: false }
344
+ const PK: OINODataFieldParams = { isPrimaryKey: true, isForeignKey: false, isAutoInc: false, isNotNull: true }
345
+ datamodel.addField(new OINOStringDataField(ds, "partitionKey", "TEXT", PK, 1024))
346
+ datamodel.addField(new OINOStringDataField(ds, "rowKey", "TEXT", PK, 1024))
347
+ datamodel.addField(new OINODatetimeDataField(ds, "timestamp", "DATETIME", FIELD))
348
+ datamodel.addField(new OINOStringDataField(ds, "etag", "TEXT", FIELD, 256))
349
+ datamodel.addField(new OINOStringDataField(ds, "properties", "TEXT", FIELD, 65536))
350
+ no_sql_api.initializeDatamodel(datamodel)
351
+ }
352
+
353
+ // ── Private helpers ───────────────────────────────────────────────────
354
+
355
+ /**
356
+ * Convert an Azure Table Storage entity to an `OINONoSqlEntry`.
357
+ * System fields (`partitionKey`, `rowKey`, `timestamp`, `etag`) are
358
+ * extracted; all remaining properties are collected into `properties`.
359
+ *
360
+ * @param entity raw entity from the Azure SDK
361
+ */
362
+ private static entityToEntry(entity: TableEntity<Record<string, unknown>>): OINONoSqlEntry {
363
+ const { partitionKey, rowKey, timestamp, etag, ...rest } = entity as Record<string, unknown>
364
+ const properties: Record<string, unknown> = {}
365
+ for (const key of Object.keys(rest)) {
366
+ if (!key.startsWith("odata.")) properties[key] = rest[key]
367
+ }
368
+ return {
369
+ primaryKey: [String(partitionKey ?? ""), String(rowKey ?? "")],
370
+ timestamp: timestamp instanceof Date ? timestamp : new Date(String(timestamp ?? "")),
371
+ etag: String(etag ?? ""),
372
+ properties
373
+ }
374
+ }
375
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { OINONoSqlAzureTable } from "./OINONoSqlAzureTable.js"