@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.
- package/dist/cjs/OINONoSqlAzureTable.js +348 -0
- package/dist/cjs/index.js +5 -0
- package/dist/esm/OINONoSqlAzureTable.js +344 -0
- package/dist/esm/index.js +1 -0
- package/dist/types/OINONoSqlAzureTable.d.ts +130 -0
- package/dist/types/index.d.ts +1 -0
- package/package.json +39 -0
- package/src/OINONoSqlAzureTable.ts +375 -0
- package/src/index.ts +1 -0
|
@@ -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"
|