@lix-js/sdk 0.6.0-preview.3 → 0.6.0-preview.4
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/SKILL.md +42 -3
- package/dist/engine-wasm/wasm/lix_engine.d.ts +25 -1
- package/dist/engine-wasm/wasm/lix_engine.js +59 -1
- package/dist/engine-wasm/wasm/lix_engine.wasm +0 -0
- package/dist/engine-wasm/wasm/lix_engine.wasm.d.ts +5 -0
- package/dist/open-lix.d.ts +9 -2
- package/dist/open-lix.js +39 -0
- package/dist-engine-src/src/lib.rs +1 -1
- package/dist-engine-src/src/session/context.rs +47 -7
- package/dist-engine-src/src/session/execute.rs +73 -10
- package/dist-engine-src/src/session/mod.rs +6 -6
- package/dist-engine-src/src/session/switch_version.rs +1 -0
- package/dist-engine-src/src/session/transaction.rs +76 -0
- package/dist-engine-src/src/sql2/classify.rs +23 -31
- package/dist-engine-src/src/sql2/error.rs +3 -4
- package/dist-engine-src/src/sql2/execute.rs +115 -22
- package/dist-engine-src/src/sql2/mod.rs +5 -4
- package/dist-engine-src/src/sql2/public_bind/dml.rs +19 -13
- package/dist-engine-src/src/sql2/public_bind/mod.rs +4 -3
- package/dist-engine-src/src/sql2/udfs/mod.rs +1 -1
- package/dist-engine-src/src/sql2/udfs/public_call.rs +27 -0
- package/package.json +1 -1
package/SKILL.md
CHANGED
|
@@ -13,6 +13,7 @@ Current `@lix-js/sdk` capabilities:
|
|
|
13
13
|
|
|
14
14
|
- Register JSON schemas as tracked entity tables.
|
|
15
15
|
- Read and write entities through generated SQL tables.
|
|
16
|
+
- Group related writes in explicit transactions so they commit once.
|
|
16
17
|
- Create named versions of state and write/read across versions.
|
|
17
18
|
- Merge one version into the active version.
|
|
18
19
|
- Query `lix_change` for history, audit, activity feeds, and undo-style features.
|
|
@@ -34,6 +35,7 @@ Use this skill when you need to write or debug consumer code using `@lix-js/sdk`
|
|
|
34
35
|
- Opening a persistent `.lix` file.
|
|
35
36
|
- Registering schemas.
|
|
36
37
|
- Writing and reading generated SQL entity tables.
|
|
38
|
+
- Grouping imports, migrations, and batch writes into one transaction.
|
|
37
39
|
- Reading `execute()` results.
|
|
38
40
|
- Creating, switching, previewing, and merging versions.
|
|
39
41
|
- Querying history through `lix_change`.
|
|
@@ -47,9 +49,10 @@ Do not use this skill for raw SQLite access, private engine/wasm internals, SDK
|
|
|
47
49
|
2. Open with `createBetterSqlite3Backend({ path })`; do not open `.lix` with raw SQLite.
|
|
48
50
|
3. Register a schema with `x-lix-key`, `x-lix-primary-key`, and `additionalProperties: false`.
|
|
49
51
|
4. Write rows through the generated table named by `x-lix-key`.
|
|
50
|
-
5. Use
|
|
51
|
-
6.
|
|
52
|
-
7.
|
|
52
|
+
5. Use `beginTransaction()` for imports, migrations, and multi-row writes that should be one commit.
|
|
53
|
+
6. Use `<schema>_by_version` plus `lixcol_version_id` for side-by-side version reads/writes.
|
|
54
|
+
7. Query `lix_change` for audit/history instead of hand-rolling audit tables.
|
|
55
|
+
8. Wrap `mergeVersion()` in `try/catch` whenever conflicts are possible.
|
|
53
56
|
|
|
54
57
|
## Core Rules
|
|
55
58
|
|
|
@@ -58,6 +61,8 @@ Do not use this skill for raw SQLite access, private engine/wasm internals, SDK
|
|
|
58
61
|
- Use numbered SQL placeholders: `$1`, `$2`, `$3`; bare `?` is rejected.
|
|
59
62
|
- Use `lix_json($1)` when inserting JSON text into JSON-typed columns.
|
|
60
63
|
- Use scalar SQL functions `SELECT lix_uuid_v7()` and `SELECT lix_timestamp()` when consumer code needs Lix-generated UUID v7 ids or ISO timestamps. Do not call them as table functions with `SELECT * FROM ...`.
|
|
64
|
+
- Use `beginTransaction()` for batch writes. One `lix.execute()` write is one transaction and therefore one commit.
|
|
65
|
+
- Do not call `execute()` through the parent `lix` handle while a transaction is active; use the transaction handle for reads and writes until `commit()` or `rollback()`.
|
|
61
66
|
- Use stable, namespaced, lowercase schema keys like `acme_section`, not generic names like `task`.
|
|
62
67
|
- Always include `x-lix-primary-key` and `additionalProperties: false` on app schemas.
|
|
63
68
|
- Use version names from the user's vocabulary, such as `"Marketing edit"` or `"Q3 pricing draft"`.
|
|
@@ -203,6 +208,38 @@ Accessors return `undefined` when the cell kind does not match. Branch on `value
|
|
|
203
208
|
|
|
204
209
|
`Row` also has convenience methods when native JS values are enough: `get(name)`, `tryGet(name)`, `getAt(index)`, `toObject()`, and `toValueMap()`.
|
|
205
210
|
|
|
211
|
+
## Transactions
|
|
212
|
+
|
|
213
|
+
Use `beginTransaction()` whenever several writes belong to one logical operation: CSV imports, seed scripts, migrations, bulk updates, or multi-table changes. A write through `lix.execute()` opens and commits its own transaction, so thousands of separate `execute()` writes become thousands of commits. A transaction handle stages those writes and commits them together.
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
const tx = await lix.beginTransaction();
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
for (const note of notes) {
|
|
220
|
+
await tx.execute(
|
|
221
|
+
"INSERT INTO acme_note (id, title, done) VALUES ($1, $2, $3)",
|
|
222
|
+
[note.id, note.title, false],
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
await tx.commit();
|
|
227
|
+
} catch (error) {
|
|
228
|
+
await tx.rollback();
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Transaction rules:
|
|
234
|
+
|
|
235
|
+
- `tx.execute()` has the same result shape as `lix.execute()`.
|
|
236
|
+
- Writes are visible to later reads on the same transaction before commit.
|
|
237
|
+
- `commit()` makes the whole transaction durable as one commit.
|
|
238
|
+
- `rollback()` drops the staged writes.
|
|
239
|
+
- After `commit()` or `rollback()`, the transaction handle is closed and cannot be reused.
|
|
240
|
+
- A Lix handle allows one active transaction at a time. While it is active, use `tx.execute()` for reads and writes; parent-handle `lix.execute()` is rejected until the transaction closes.
|
|
241
|
+
- Do not use a callback-style transaction helper. The JS SDK mirrors the Rust SDK shape: explicitly `beginTransaction()`, then `commit()` or `rollback()`.
|
|
242
|
+
|
|
206
243
|
## Registering Schemas
|
|
207
244
|
|
|
208
245
|
Register app schemas by inserting JSON into `lix_registered_schema.value`:
|
|
@@ -453,6 +490,8 @@ Other UDFs, such as `lix_json_get`, `lix_uuid_v7`, `lix_text_encode`, and `lix_e
|
|
|
453
490
|
| Use public imports from `@lix-js/sdk` and `@lix-js/sdk/sqlite`. | Importing `engine-wasm` or private internals. |
|
|
454
491
|
| Use `$1`, `$2`, `$3` placeholders. | Bare `?` placeholders. |
|
|
455
492
|
| Use `lix_json($1)` for JSON parameters. | Inlining stringified JSON directly into SQL. |
|
|
493
|
+
| Use `beginTransaction()` for imports and batch writes that should be one commit. | Loops of standalone `lix.execute()` writes for bulk imports. |
|
|
494
|
+
| Use the transaction handle for reads and writes until it commits or rolls back. | Calling parent-handle `execute()` during an active transaction. |
|
|
456
495
|
| Use `_by_version` for cross-version reads/writes. | Switching versions just to render a side-by-side view. |
|
|
457
496
|
| Name versions in user vocabulary. | User-facing words like branch, branch-1, or generic Draft. |
|
|
458
497
|
| Model collaborative data as small rows. | One giant row when multiple reviewers edit different parts. |
|
|
@@ -12,6 +12,10 @@ export class Lix {
|
|
|
12
12
|
* @returns {Promise<string>}
|
|
13
13
|
*/
|
|
14
14
|
activeVersionId(): Promise<string>;
|
|
15
|
+
/**
|
|
16
|
+
* @returns {Promise<LixTransaction>}
|
|
17
|
+
*/
|
|
18
|
+
beginTransaction(): Promise<LixTransaction>;
|
|
15
19
|
/**
|
|
16
20
|
* @returns {Promise<void>}
|
|
17
21
|
*/
|
|
@@ -25,7 +29,7 @@ export class Lix {
|
|
|
25
29
|
* Executes one DataFusion SQL statement against this Lix session.
|
|
26
30
|
*
|
|
27
31
|
* The SQL dialect is DataFusion SQL, not SQLite SQL. Positional
|
|
28
|
-
* placeholders use `$1`, `$2`, and so on. SQLite-specific catalog
|
|
32
|
+
* placeholders use `?` or `$1`, `$2`, and so on. SQLite-specific catalog
|
|
29
33
|
* tables and transaction statements such as `sqlite_master`, `BEGIN`,
|
|
30
34
|
* and `COMMIT` are not part of this contract; use
|
|
31
35
|
* `information_schema` for catalog inspection.
|
|
@@ -50,6 +54,26 @@ export class Lix {
|
|
|
50
54
|
*/
|
|
51
55
|
switchVersion(args: any): Promise<any>;
|
|
52
56
|
}
|
|
57
|
+
export class LixTransaction {
|
|
58
|
+
static __wrap(ptr: any): any;
|
|
59
|
+
__destroy_into_raw(): number | undefined;
|
|
60
|
+
__wbg_ptr: number | undefined;
|
|
61
|
+
free(): void;
|
|
62
|
+
/**
|
|
63
|
+
* @returns {Promise<void>}
|
|
64
|
+
*/
|
|
65
|
+
commit(): Promise<void>;
|
|
66
|
+
/**
|
|
67
|
+
* @param {any} sql
|
|
68
|
+
* @param {any} params
|
|
69
|
+
* @returns {Promise<any>}
|
|
70
|
+
*/
|
|
71
|
+
execute(sql: any, params: any): Promise<any>;
|
|
72
|
+
/**
|
|
73
|
+
* @returns {Promise<void>}
|
|
74
|
+
*/
|
|
75
|
+
rollback(): Promise<void>;
|
|
76
|
+
}
|
|
53
77
|
export function initSync(module: any): any;
|
|
54
78
|
declare function __wbg_init(module_or_path: any): Promise<any>;
|
|
55
79
|
export { __wbg_init as default };
|
|
@@ -24,6 +24,13 @@ export class Lix {
|
|
|
24
24
|
const ret = wasm.lix_activeVersionId(this.__wbg_ptr);
|
|
25
25
|
return ret;
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* @returns {Promise<LixTransaction>}
|
|
29
|
+
*/
|
|
30
|
+
beginTransaction() {
|
|
31
|
+
const ret = wasm.lix_beginTransaction(this.__wbg_ptr);
|
|
32
|
+
return ret;
|
|
33
|
+
}
|
|
27
34
|
/**
|
|
28
35
|
* @returns {Promise<void>}
|
|
29
36
|
*/
|
|
@@ -43,7 +50,7 @@ export class Lix {
|
|
|
43
50
|
* Executes one DataFusion SQL statement against this Lix session.
|
|
44
51
|
*
|
|
45
52
|
* The SQL dialect is DataFusion SQL, not SQLite SQL. Positional
|
|
46
|
-
* placeholders use `$1`, `$2`, and so on. SQLite-specific catalog
|
|
53
|
+
* placeholders use `?` or `$1`, `$2`, and so on. SQLite-specific catalog
|
|
47
54
|
* tables and transaction statements such as `sqlite_master`, `BEGIN`,
|
|
48
55
|
* and `COMMIT` are not part of this contract; use
|
|
49
56
|
* `information_schema` for catalog inspection.
|
|
@@ -82,6 +89,50 @@ export class Lix {
|
|
|
82
89
|
}
|
|
83
90
|
if (Symbol.dispose)
|
|
84
91
|
Lix.prototype[Symbol.dispose] = Lix.prototype.free;
|
|
92
|
+
export class LixTransaction {
|
|
93
|
+
static __wrap(ptr) {
|
|
94
|
+
ptr = ptr >>> 0;
|
|
95
|
+
const obj = Object.create(LixTransaction.prototype);
|
|
96
|
+
obj.__wbg_ptr = ptr;
|
|
97
|
+
LixTransactionFinalization.register(obj, obj.__wbg_ptr, obj);
|
|
98
|
+
return obj;
|
|
99
|
+
}
|
|
100
|
+
__destroy_into_raw() {
|
|
101
|
+
const ptr = this.__wbg_ptr;
|
|
102
|
+
this.__wbg_ptr = 0;
|
|
103
|
+
LixTransactionFinalization.unregister(this);
|
|
104
|
+
return ptr;
|
|
105
|
+
}
|
|
106
|
+
free() {
|
|
107
|
+
const ptr = this.__destroy_into_raw();
|
|
108
|
+
wasm.__wbg_lixtransaction_free(ptr, 0);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* @returns {Promise<void>}
|
|
112
|
+
*/
|
|
113
|
+
commit() {
|
|
114
|
+
const ret = wasm.lixtransaction_commit(this.__wbg_ptr);
|
|
115
|
+
return ret;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* @param {any} sql
|
|
119
|
+
* @param {any} params
|
|
120
|
+
* @returns {Promise<any>}
|
|
121
|
+
*/
|
|
122
|
+
execute(sql, params) {
|
|
123
|
+
const ret = wasm.lixtransaction_execute(this.__wbg_ptr, sql, params);
|
|
124
|
+
return ret;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* @returns {Promise<void>}
|
|
128
|
+
*/
|
|
129
|
+
rollback() {
|
|
130
|
+
const ret = wasm.lixtransaction_rollback(this.__wbg_ptr);
|
|
131
|
+
return ret;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (Symbol.dispose)
|
|
135
|
+
LixTransaction.prototype[Symbol.dispose] = LixTransaction.prototype.free;
|
|
85
136
|
/**
|
|
86
137
|
* @param {any | null} [args]
|
|
87
138
|
* @returns {Promise<Lix>}
|
|
@@ -295,6 +346,10 @@ function __wbg_get_imports() {
|
|
|
295
346
|
const ret = Lix.__wrap(arg0);
|
|
296
347
|
return ret;
|
|
297
348
|
},
|
|
349
|
+
__wbg_lixtransaction_new: function (arg0) {
|
|
350
|
+
const ret = LixTransaction.__wrap(arg0);
|
|
351
|
+
return ret;
|
|
352
|
+
},
|
|
298
353
|
__wbg_new_0_73afc35eb544e539: function () {
|
|
299
354
|
const ret = new Date();
|
|
300
355
|
return ret;
|
|
@@ -482,6 +537,9 @@ function wasm_bindgen__convert__closures_____invoke__h14bc79029e3672a5(arg0, arg
|
|
|
482
537
|
const LixFinalization = (typeof FinalizationRegistry === 'undefined')
|
|
483
538
|
? { register: () => { }, unregister: () => { } }
|
|
484
539
|
: new FinalizationRegistry(ptr => wasm.__wbg_lix_free(ptr >>> 0, 1));
|
|
540
|
+
const LixTransactionFinalization = (typeof FinalizationRegistry === 'undefined')
|
|
541
|
+
? { register: () => { }, unregister: () => { } }
|
|
542
|
+
: new FinalizationRegistry(ptr => wasm.__wbg_lixtransaction_free(ptr >>> 0, 1));
|
|
485
543
|
function addToExternrefTable0(obj) {
|
|
486
544
|
const idx = wasm.__externref_table_alloc();
|
|
487
545
|
wasm.__wbindgen_externrefs.set(idx, obj);
|
|
Binary file
|
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
/* eslint-disable */
|
|
3
3
|
export const memory: WebAssembly.Memory;
|
|
4
4
|
export const __wbg_lix_free: (a: number, b: number) => void;
|
|
5
|
+
export const __wbg_lixtransaction_free: (a: number, b: number) => void;
|
|
5
6
|
export const lix_activeVersionId: (a: number) => any;
|
|
7
|
+
export const lix_beginTransaction: (a: number) => any;
|
|
6
8
|
export const lix_close: (a: number) => any;
|
|
7
9
|
export const lix_createVersion: (a: number, b: any) => any;
|
|
8
10
|
export const lix_execute: (a: number, b: any, c: any) => any;
|
|
9
11
|
export const lix_mergeVersion: (a: number, b: any) => any;
|
|
10
12
|
export const lix_mergeVersionPreview: (a: number, b: any) => any;
|
|
11
13
|
export const lix_switchVersion: (a: number, b: any) => any;
|
|
14
|
+
export const lixtransaction_commit: (a: number) => any;
|
|
15
|
+
export const lixtransaction_execute: (a: number, b: any, c: any) => any;
|
|
16
|
+
export const lixtransaction_rollback: (a: number) => any;
|
|
12
17
|
export const openLix: (a: number) => any;
|
|
13
18
|
export const wasm_bindgen__closure__destroy__h259bf120b5b35fc5: (a: number, b: number) => void;
|
|
14
19
|
export const wasm_bindgen__convert__closures_____invoke__h14bc79029e3672a5: (a: number, b: number, c: any, d: any) => void;
|
package/dist/open-lix.d.ts
CHANGED
|
@@ -185,11 +185,13 @@ export type Lix = {
|
|
|
185
185
|
* Executes one DataFusion SQL statement against this Lix session.
|
|
186
186
|
*
|
|
187
187
|
* This is not SQLite SQL. Use the DataFusion SQL dialect; positional
|
|
188
|
-
* placeholders are `$1`, `$2`, and so on. SQLite-specific catalog tables and
|
|
188
|
+
* placeholders are `?` or `$1`, `$2`, and so on. SQLite-specific catalog tables and
|
|
189
189
|
* transaction statements such as `sqlite_master`, `BEGIN`, and `COMMIT` are
|
|
190
|
-
* not available. Use `information_schema` for catalog inspection.
|
|
190
|
+
* not available. Use `information_schema` for catalog inspection. While a
|
|
191
|
+
* transaction is active, call `execute()` on the transaction handle instead.
|
|
191
192
|
*/
|
|
192
193
|
execute(sql: string, params?: ReadonlyArray<LixRuntimeValue>): Promise<ExecuteResult>;
|
|
194
|
+
beginTransaction(): Promise<LixTransaction>;
|
|
193
195
|
activeVersionId(): Promise<string>;
|
|
194
196
|
createVersion(options: CreateVersionOptions): Promise<CreateVersionResult>;
|
|
195
197
|
switchVersion(options: SwitchVersionOptions): Promise<SwitchVersionResult>;
|
|
@@ -197,4 +199,9 @@ export type Lix = {
|
|
|
197
199
|
mergeVersion(options: MergeVersionOptions): Promise<MergeVersionResult>;
|
|
198
200
|
close(): Promise<void>;
|
|
199
201
|
};
|
|
202
|
+
export type LixTransaction = {
|
|
203
|
+
execute(sql: string, params?: ReadonlyArray<LixRuntimeValue>): Promise<ExecuteResult>;
|
|
204
|
+
commit(): Promise<void>;
|
|
205
|
+
rollback(): Promise<void>;
|
|
206
|
+
};
|
|
200
207
|
export declare function openLix(options?: OpenLixOptions): Promise<Lix>;
|
package/dist/open-lix.js
CHANGED
|
@@ -125,6 +125,10 @@ function createLixHandle(wasmLix) {
|
|
|
125
125
|
const result = await runQueued(() => wasmLix.execute(sql, values));
|
|
126
126
|
return normalizeExecuteResult(result);
|
|
127
127
|
},
|
|
128
|
+
async beginTransaction() {
|
|
129
|
+
const wasmTransaction = await runQueued(() => wasmLix.beginTransaction());
|
|
130
|
+
return createLixTransactionHandle(wasmTransaction, runQueued);
|
|
131
|
+
},
|
|
128
132
|
async activeVersionId() {
|
|
129
133
|
return await runQueued(() => wasmLix.activeVersionId());
|
|
130
134
|
},
|
|
@@ -145,6 +149,41 @@ function createLixHandle(wasmLix) {
|
|
|
145
149
|
},
|
|
146
150
|
};
|
|
147
151
|
}
|
|
152
|
+
function createLixTransactionHandle(wasmTransaction, runQueued) {
|
|
153
|
+
let closed = false;
|
|
154
|
+
const ensureOpen = () => {
|
|
155
|
+
if (closed) {
|
|
156
|
+
throw createLixError("LIX_INVALID_TRANSACTION_STATE", "Lix transaction is closed");
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
return {
|
|
160
|
+
async execute(sql, params = []) {
|
|
161
|
+
ensureOpen();
|
|
162
|
+
validateExecuteArguments(sql, params);
|
|
163
|
+
const values = params.map((param, index) => valueFromExecuteParam(param, index));
|
|
164
|
+
const result = await runQueued(() => wasmTransaction.execute(sql, values));
|
|
165
|
+
return normalizeExecuteResult(result);
|
|
166
|
+
},
|
|
167
|
+
async commit() {
|
|
168
|
+
ensureOpen();
|
|
169
|
+
try {
|
|
170
|
+
await runQueued(() => wasmTransaction.commit());
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
closed = true;
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
async rollback() {
|
|
177
|
+
ensureOpen();
|
|
178
|
+
try {
|
|
179
|
+
await runQueued(() => wasmTransaction.rollback());
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
closed = true;
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
148
187
|
function validateExecuteArguments(sql, params) {
|
|
149
188
|
if (typeof sql !== "string") {
|
|
150
189
|
throw invalidArgumentError("execute", "sql", "string", sql);
|
|
@@ -55,7 +55,7 @@ pub use session::{
|
|
|
55
55
|
CreateVersionOptions, CreateVersionReceipt, MergeChangeStats, MergeConflict,
|
|
56
56
|
MergeConflictChangeKind, MergeConflictKind, MergeConflictSide, MergeVersionOptions,
|
|
57
57
|
MergeVersionOutcome, MergeVersionPreview, MergeVersionPreviewOptions, MergeVersionReceipt,
|
|
58
|
-
SessionContext, SwitchVersionOptions, SwitchVersionReceipt,
|
|
58
|
+
SessionContext, SessionTransaction, SwitchVersionOptions, SwitchVersionReceipt,
|
|
59
59
|
};
|
|
60
60
|
pub use session::{ExecuteResult, Row, RowRef, TryFromValue};
|
|
61
61
|
|
|
@@ -25,6 +25,8 @@ use crate::version::{
|
|
|
25
25
|
use crate::GLOBAL_VERSION_ID;
|
|
26
26
|
use crate::{LixError, NullableKeyFilter};
|
|
27
27
|
|
|
28
|
+
use super::transaction::transaction_state_error;
|
|
29
|
+
|
|
28
30
|
pub(crate) const WORKSPACE_VERSION_KEY: &str = "lix_workspace_version_id";
|
|
29
31
|
|
|
30
32
|
#[derive(Clone)]
|
|
@@ -36,13 +38,10 @@ pub(crate) enum SessionMode {
|
|
|
36
38
|
/// Session-context state for engine execution.
|
|
37
39
|
///
|
|
38
40
|
/// A session context pins the active version selector and shared execution
|
|
39
|
-
/// services.
|
|
40
|
-
///
|
|
41
|
-
///
|
|
42
|
-
///
|
|
43
|
-
/// through `SessionContext::with_write_transaction`. Reads that influence writes
|
|
44
|
-
/// are only available from that transaction capability, not from session-level
|
|
45
|
-
/// helpers.
|
|
41
|
+
/// services. Parent-handle `execute(...)` runs as an implicit single-statement
|
|
42
|
+
/// transaction. Explicit transactions hold the session execution lease until
|
|
43
|
+
/// commit or rollback, so all SQL during that window must run through the
|
|
44
|
+
/// transaction handle.
|
|
46
45
|
#[derive(Clone)]
|
|
47
46
|
pub struct SessionContext {
|
|
48
47
|
pub(super) mode: SessionMode,
|
|
@@ -54,6 +53,7 @@ pub struct SessionContext {
|
|
|
54
53
|
pub(super) version_ctx: Arc<VersionContext>,
|
|
55
54
|
pub(super) catalog_context: Arc<CatalogContext>,
|
|
56
55
|
closed: Arc<AtomicBool>,
|
|
56
|
+
active_transaction: Arc<AtomicBool>,
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
impl SessionContext {
|
|
@@ -124,6 +124,7 @@ impl SessionContext {
|
|
|
124
124
|
version_ctx,
|
|
125
125
|
catalog_context,
|
|
126
126
|
Arc::new(AtomicBool::new(false)),
|
|
127
|
+
Arc::new(AtomicBool::new(false)),
|
|
127
128
|
)
|
|
128
129
|
}
|
|
129
130
|
|
|
@@ -137,6 +138,7 @@ impl SessionContext {
|
|
|
137
138
|
version_ctx: Arc<VersionContext>,
|
|
138
139
|
catalog_context: Arc<CatalogContext>,
|
|
139
140
|
closed: Arc<AtomicBool>,
|
|
141
|
+
active_transaction: Arc<AtomicBool>,
|
|
140
142
|
) -> Self {
|
|
141
143
|
Self {
|
|
142
144
|
mode,
|
|
@@ -148,6 +150,7 @@ impl SessionContext {
|
|
|
148
150
|
version_ctx,
|
|
149
151
|
catalog_context,
|
|
150
152
|
closed,
|
|
153
|
+
active_transaction,
|
|
151
154
|
}
|
|
152
155
|
}
|
|
153
156
|
|
|
@@ -166,6 +169,10 @@ impl SessionContext {
|
|
|
166
169
|
Arc::clone(&self.closed)
|
|
167
170
|
}
|
|
168
171
|
|
|
172
|
+
pub(crate) fn active_transaction_flag(&self) -> Arc<AtomicBool> {
|
|
173
|
+
Arc::clone(&self.active_transaction)
|
|
174
|
+
}
|
|
175
|
+
|
|
169
176
|
pub(crate) fn ensure_open(&self) -> Result<(), LixError> {
|
|
170
177
|
if self.is_closed() {
|
|
171
178
|
return Err(closed_error());
|
|
@@ -277,6 +284,16 @@ impl SessionContext {
|
|
|
277
284
|
) -> Pin<Box<dyn Future<Output = Result<T, LixError>> + 'tx>>,
|
|
278
285
|
{
|
|
279
286
|
self.ensure_open()?;
|
|
287
|
+
let _transaction_guard = self.reserve_session_transaction()?;
|
|
288
|
+
self.with_write_transaction_reserved(f).await
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
pub(crate) async fn with_write_transaction_reserved<T, F>(&self, f: F) -> Result<T, LixError>
|
|
292
|
+
where
|
|
293
|
+
F: for<'tx> FnOnce(
|
|
294
|
+
&'tx mut Transaction,
|
|
295
|
+
) -> Pin<Box<dyn Future<Output = Result<T, LixError>> + 'tx>>,
|
|
296
|
+
{
|
|
280
297
|
let opened = open_transaction(
|
|
281
298
|
&self.mode,
|
|
282
299
|
self.storage.clone(),
|
|
@@ -302,6 +319,19 @@ impl SessionContext {
|
|
|
302
319
|
}
|
|
303
320
|
}
|
|
304
321
|
}
|
|
322
|
+
|
|
323
|
+
pub(super) fn reserve_session_transaction(&self) -> Result<SessionTransactionGuard, LixError> {
|
|
324
|
+
let active_transaction = self.active_transaction_flag();
|
|
325
|
+
if active_transaction
|
|
326
|
+
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
|
327
|
+
.is_err()
|
|
328
|
+
{
|
|
329
|
+
return Err(transaction_state_error(
|
|
330
|
+
"Lix handle has an active transaction; use the transaction handle for reads and writes until it is committed or rolled back",
|
|
331
|
+
));
|
|
332
|
+
}
|
|
333
|
+
Ok(SessionTransactionGuard { active_transaction })
|
|
334
|
+
}
|
|
305
335
|
}
|
|
306
336
|
|
|
307
337
|
fn closed_error() -> LixError {
|
|
@@ -309,6 +339,16 @@ fn closed_error() -> LixError {
|
|
|
309
339
|
.with_hint("Open a new Lix handle before calling this method.")
|
|
310
340
|
}
|
|
311
341
|
|
|
342
|
+
pub(super) struct SessionTransactionGuard {
|
|
343
|
+
active_transaction: Arc<AtomicBool>,
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
impl Drop for SessionTransactionGuard {
|
|
347
|
+
fn drop(&mut self) {
|
|
348
|
+
self.active_transaction.store(false, Ordering::SeqCst);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
312
352
|
/// Read-only SQL execution context derived from a session.
|
|
313
353
|
///
|
|
314
354
|
/// Write statements re-plan against `Transaction`; this context intentionally
|
|
@@ -6,6 +6,7 @@ use crate::storage::{StorageReadScope, StorageWriteSet};
|
|
|
6
6
|
use crate::{LixError, LixNotice, SqlQueryResult, Value};
|
|
7
7
|
|
|
8
8
|
use super::context::{SessionContext, SessionSqlExecutionContext};
|
|
9
|
+
use super::transaction::SessionTransaction;
|
|
9
10
|
|
|
10
11
|
/// Result of executing one SQL statement through engine.
|
|
11
12
|
///
|
|
@@ -290,24 +291,27 @@ impl SessionContext {
|
|
|
290
291
|
/// Executes one DataFusion SQL statement against this Lix session.
|
|
291
292
|
///
|
|
292
293
|
/// The SQL dialect is DataFusion SQL, not SQLite SQL. Positional
|
|
293
|
-
/// placeholders use `$1`, `$2`, and so on. SQLite-specific catalog tables
|
|
294
|
+
/// placeholders use `?` or `$1`, `$2`, and so on. SQLite-specific catalog tables
|
|
294
295
|
/// and transaction statements such as `sqlite_master`, `BEGIN`, and
|
|
295
296
|
/// `COMMIT` are not part of this contract; use `information_schema` for
|
|
296
297
|
/// catalog inspection. Lix owns transaction boundaries for each statement.
|
|
297
298
|
pub async fn execute(&self, sql: &str, params: &[Value]) -> Result<ExecuteResult, LixError> {
|
|
298
299
|
self.ensure_open()?;
|
|
299
|
-
let
|
|
300
|
+
let _transaction_guard = self.reserve_session_transaction()?;
|
|
301
|
+
let statement = sql2::parse_statement(sql)?;
|
|
302
|
+
let kind = sql2::classify_datafusion_statement(&statement);
|
|
300
303
|
if kind == sql2::SqlStatementKind::Write {
|
|
301
|
-
let
|
|
302
|
-
let sql_for_error = sql.clone();
|
|
304
|
+
let sql_for_error = sql.to_string();
|
|
303
305
|
let params = params.to_vec();
|
|
304
306
|
return self
|
|
305
|
-
.
|
|
307
|
+
.with_write_transaction_reserved(|transaction| {
|
|
306
308
|
Box::pin(async move {
|
|
307
309
|
// Re-plan against the transaction-backed write
|
|
308
310
|
// session so provider hooks read and stage through the
|
|
309
311
|
// transaction-owned SQL write context.
|
|
310
|
-
let tx_plan =
|
|
312
|
+
let tx_plan =
|
|
313
|
+
sql2::create_write_logical_plan_from_parsed(transaction, statement)
|
|
314
|
+
.await?;
|
|
311
315
|
let result = sql2::execute_logical_plan(tx_plan, ¶ms).await?;
|
|
312
316
|
let affected_rows = affected_rows_from_query_result(result)?;
|
|
313
317
|
Ok(ExecuteResult::from_rows_affected(affected_rows))
|
|
@@ -316,6 +320,12 @@ impl SessionContext {
|
|
|
316
320
|
.await
|
|
317
321
|
.map_err(|error| normalize_sql_surface_error(error, &sql_for_error));
|
|
318
322
|
}
|
|
323
|
+
if kind == sql2::SqlStatementKind::Other {
|
|
324
|
+
return Err(LixError::new(
|
|
325
|
+
LixError::CODE_UNSUPPORTED_SQL,
|
|
326
|
+
"SQL statement is not supported by Lix SQL",
|
|
327
|
+
));
|
|
328
|
+
}
|
|
319
329
|
|
|
320
330
|
let read_scope = StorageReadScope::new(self.storage.begin_read_transaction().await?);
|
|
321
331
|
let read_result = async {
|
|
@@ -340,7 +350,7 @@ impl SessionContext {
|
|
|
340
350
|
functions: functions.clone(),
|
|
341
351
|
};
|
|
342
352
|
|
|
343
|
-
let plan = sql2::
|
|
353
|
+
let plan = sql2::create_logical_plan_from_parsed(&ctx, sql, statement).await?;
|
|
344
354
|
let result = sql2::execute_logical_plan(plan, params).await?;
|
|
345
355
|
drop(ctx);
|
|
346
356
|
drop(live_state);
|
|
@@ -371,18 +381,71 @@ impl SessionContext {
|
|
|
371
381
|
&self,
|
|
372
382
|
runtime_functions: &FunctionContext,
|
|
373
383
|
) -> Result<(), LixError> {
|
|
374
|
-
let mut transaction = self.storage.begin_write_transaction().await?;
|
|
375
384
|
let mut writes = StorageWriteSet::new();
|
|
376
385
|
runtime_functions
|
|
377
386
|
.stage_persist_if_needed(&mut writes)
|
|
378
387
|
.await?;
|
|
379
|
-
if
|
|
380
|
-
|
|
388
|
+
if writes.is_empty() {
|
|
389
|
+
return Ok(());
|
|
381
390
|
}
|
|
391
|
+
let mut transaction = self.storage.begin_write_transaction().await?;
|
|
392
|
+
writes.apply(&mut transaction.as_mut()).await?;
|
|
382
393
|
transaction.commit().await
|
|
383
394
|
}
|
|
384
395
|
}
|
|
385
396
|
|
|
397
|
+
impl SessionTransaction {
|
|
398
|
+
/// Executes one SQL statement inside this transaction.
|
|
399
|
+
///
|
|
400
|
+
/// Write statements are staged until `commit()`. Read statements use the
|
|
401
|
+
/// transaction overlay, so they can observe writes staged by earlier calls
|
|
402
|
+
/// on this transaction handle.
|
|
403
|
+
pub async fn execute(
|
|
404
|
+
&mut self,
|
|
405
|
+
sql: &str,
|
|
406
|
+
params: &[Value],
|
|
407
|
+
) -> Result<ExecuteResult, LixError> {
|
|
408
|
+
let statement = sql2::parse_statement(sql)?;
|
|
409
|
+
let kind = sql2::classify_datafusion_statement(&statement);
|
|
410
|
+
let transaction = self.transaction_mut()?;
|
|
411
|
+
match kind {
|
|
412
|
+
sql2::SqlStatementKind::Write => {
|
|
413
|
+
execute_transaction_write(transaction, statement, params)
|
|
414
|
+
.await
|
|
415
|
+
.map_err(|error| normalize_sql_surface_error(error, sql))
|
|
416
|
+
}
|
|
417
|
+
sql2::SqlStatementKind::Read => {
|
|
418
|
+
let plan = sql2::create_transaction_read_logical_plan_from_parsed(
|
|
419
|
+
transaction,
|
|
420
|
+
sql,
|
|
421
|
+
statement,
|
|
422
|
+
)
|
|
423
|
+
.await
|
|
424
|
+
.map_err(|error| normalize_sql_surface_error(error, sql))?;
|
|
425
|
+
let result = sql2::execute_logical_plan(plan, params)
|
|
426
|
+
.await
|
|
427
|
+
.map_err(|error| normalize_sql_surface_error(error, sql))?;
|
|
428
|
+
Ok(ExecuteResult::from_sql_query_result(result))
|
|
429
|
+
}
|
|
430
|
+
sql2::SqlStatementKind::Other => Err(LixError::new(
|
|
431
|
+
LixError::CODE_UNSUPPORTED_SQL,
|
|
432
|
+
"SQL statement is not supported by Lix SQL",
|
|
433
|
+
)),
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async fn execute_transaction_write(
|
|
439
|
+
transaction: &mut crate::transaction::Transaction,
|
|
440
|
+
statement: datafusion::sql::parser::Statement,
|
|
441
|
+
params: &[Value],
|
|
442
|
+
) -> Result<ExecuteResult, LixError> {
|
|
443
|
+
let tx_plan = sql2::create_write_logical_plan_from_parsed(transaction, statement).await?;
|
|
444
|
+
let result = sql2::execute_logical_plan(tx_plan, params).await?;
|
|
445
|
+
let affected_rows = affected_rows_from_query_result(result)?;
|
|
446
|
+
Ok(ExecuteResult::from_rows_affected(affected_rows))
|
|
447
|
+
}
|
|
448
|
+
|
|
386
449
|
fn normalize_sql_surface_error(error: LixError, sql: &str) -> LixError {
|
|
387
450
|
if error.code.starts_with("LIX_ERROR_PATH_") && sql_uses_public_filesystem_path_surface(sql) {
|
|
388
451
|
return LixError {
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
//! Engine session boundary.
|
|
2
2
|
//!
|
|
3
|
-
//! Transaction invariant:
|
|
4
|
-
//!
|
|
5
|
-
//!
|
|
6
|
-
//!
|
|
7
|
-
//! open `Transaction` directly or use session-level read helpers inside write
|
|
8
|
-
//! flows.
|
|
3
|
+
//! Transaction invariant: a session has one execution lease. Parent-handle
|
|
4
|
+
//! calls use it for implicit single-statement execution; explicit transactions
|
|
5
|
+
//! hold it until commit or rollback. Session APIs must not open `Transaction`
|
|
6
|
+
//! directly or use session-level read helpers inside write flows.
|
|
9
7
|
|
|
10
8
|
mod context;
|
|
11
9
|
mod create_version;
|
|
@@ -14,6 +12,7 @@ mod merge;
|
|
|
14
12
|
#[cfg(feature = "storage-benches")]
|
|
15
13
|
pub mod optimization9_sql2_bench;
|
|
16
14
|
mod switch_version;
|
|
15
|
+
mod transaction;
|
|
17
16
|
|
|
18
17
|
pub use context::SessionContext;
|
|
19
18
|
pub(crate) use context::{SessionMode, WORKSPACE_VERSION_KEY};
|
|
@@ -25,3 +24,4 @@ pub use merge::{
|
|
|
25
24
|
MergeVersionReceipt,
|
|
26
25
|
};
|
|
27
26
|
pub use switch_version::{SwitchVersionOptions, SwitchVersionReceipt};
|
|
27
|
+
pub use transaction::SessionTransaction;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
use std::sync::Arc;
|
|
2
|
+
|
|
3
|
+
use crate::functions::FunctionContext;
|
|
4
|
+
use crate::transaction::{open_transaction, Transaction};
|
|
5
|
+
use crate::LixError;
|
|
6
|
+
|
|
7
|
+
use super::context::SessionTransactionGuard;
|
|
8
|
+
use super::SessionContext;
|
|
9
|
+
|
|
10
|
+
pub struct SessionTransaction {
|
|
11
|
+
pub(super) transaction: Option<Transaction>,
|
|
12
|
+
pub(super) runtime_functions: FunctionContext,
|
|
13
|
+
_transaction_guard: SessionTransactionGuard,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
impl SessionContext {
|
|
17
|
+
pub async fn begin_transaction(&self) -> Result<SessionTransaction, LixError> {
|
|
18
|
+
self.ensure_open()?;
|
|
19
|
+
let transaction_guard = self.reserve_session_transaction()?;
|
|
20
|
+
let opened = match open_transaction(
|
|
21
|
+
&self.mode,
|
|
22
|
+
self.storage.clone(),
|
|
23
|
+
Arc::clone(&self.live_state),
|
|
24
|
+
Arc::clone(&self.tracked_state),
|
|
25
|
+
Arc::clone(&self.binary_cas),
|
|
26
|
+
Arc::clone(&self.commit_store),
|
|
27
|
+
Arc::clone(&self.version_ctx),
|
|
28
|
+
Arc::clone(&self.catalog_context),
|
|
29
|
+
)
|
|
30
|
+
.await
|
|
31
|
+
{
|
|
32
|
+
Ok(opened) => opened,
|
|
33
|
+
Err(error) => {
|
|
34
|
+
return Err(error);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
Ok(SessionTransaction {
|
|
38
|
+
transaction: Some(opened.transaction),
|
|
39
|
+
runtime_functions: opened.runtime_functions,
|
|
40
|
+
_transaction_guard: transaction_guard,
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
impl SessionTransaction {
|
|
46
|
+
pub(super) fn transaction_mut(&mut self) -> Result<&mut Transaction, LixError> {
|
|
47
|
+
self.transaction
|
|
48
|
+
.as_mut()
|
|
49
|
+
.ok_or_else(|| transaction_state_error("Lix transaction is closed"))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
pub async fn commit(mut self) -> Result<(), LixError> {
|
|
53
|
+
let transaction = self
|
|
54
|
+
.transaction
|
|
55
|
+
.take()
|
|
56
|
+
.ok_or_else(|| transaction_state_error("Lix transaction is closed"))?;
|
|
57
|
+
let result = transaction
|
|
58
|
+
.commit(&self.runtime_functions)
|
|
59
|
+
.await
|
|
60
|
+
.map(|_| ());
|
|
61
|
+
result
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
pub async fn rollback(mut self) -> Result<(), LixError> {
|
|
65
|
+
let transaction = self
|
|
66
|
+
.transaction
|
|
67
|
+
.take()
|
|
68
|
+
.ok_or_else(|| transaction_state_error("Lix transaction is closed"))?;
|
|
69
|
+
let result = transaction.rollback().await;
|
|
70
|
+
result
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
pub(crate) fn transaction_state_error(message: impl Into<String>) -> LixError {
|
|
75
|
+
LixError::new("LIX_INVALID_TRANSACTION_STATE", message)
|
|
76
|
+
}
|
|
@@ -3,8 +3,6 @@ use datafusion::sql::sqlparser::ast::{
|
|
|
3
3
|
FromTable, ObjectName, Query, SetExpr, Statement as SqlStatement, TableFactor, TableObject,
|
|
4
4
|
TableWithJoins,
|
|
5
5
|
};
|
|
6
|
-
use datafusion::sql::sqlparser::dialect::GenericDialect;
|
|
7
|
-
use datafusion::sql::sqlparser::parser::Parser;
|
|
8
6
|
|
|
9
7
|
use crate::LixError;
|
|
10
8
|
|
|
@@ -15,30 +13,18 @@ pub(crate) enum SqlStatementKind {
|
|
|
15
13
|
Other,
|
|
16
14
|
}
|
|
17
15
|
|
|
18
|
-
pub(crate) fn classify_statement(sql: &str) -> Result<SqlStatementKind, LixError> {
|
|
19
|
-
let statements = parse_sql_statements(sql)?;
|
|
20
|
-
let [statement] = statements.as_slice() else {
|
|
21
|
-
return Ok(SqlStatementKind::Other);
|
|
22
|
-
};
|
|
23
|
-
Ok(classify_ast_statement(statement))
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
pub(crate) fn validate_supported_statement_ast(sql: &str) -> Result<(), LixError> {
|
|
27
|
-
let statements = parse_sql_statements(sql)?;
|
|
28
|
-
let [statement] = statements.as_slice() else {
|
|
29
|
-
return Err(unsupported_sql_error(
|
|
30
|
-
"Lix SQL only supports one statement per execute() call",
|
|
31
|
-
));
|
|
32
|
-
};
|
|
33
|
-
validate_supported_ast_statement(statement)
|
|
34
|
-
}
|
|
35
|
-
|
|
36
16
|
pub(crate) fn validate_supported_datafusion_statement_ast(
|
|
37
17
|
statement: &DataFusionStatement,
|
|
38
18
|
) -> Result<(), LixError> {
|
|
39
19
|
match statement {
|
|
40
20
|
DataFusionStatement::Statement(statement) => validate_supported_ast_statement(statement),
|
|
41
21
|
DataFusionStatement::Explain(explain) => {
|
|
22
|
+
if classify_datafusion_statement(explain.statement.as_ref()) == SqlStatementKind::Write
|
|
23
|
+
{
|
|
24
|
+
return Err(unsupported_sql_error(
|
|
25
|
+
"EXPLAIN of write statements is not supported by Lix SQL",
|
|
26
|
+
));
|
|
27
|
+
}
|
|
42
28
|
validate_supported_datafusion_statement_ast(explain.statement.as_ref())
|
|
43
29
|
}
|
|
44
30
|
_ => Err(unsupported_sql_error(format!(
|
|
@@ -47,6 +33,14 @@ pub(crate) fn validate_supported_datafusion_statement_ast(
|
|
|
47
33
|
}
|
|
48
34
|
}
|
|
49
35
|
|
|
36
|
+
pub(crate) fn classify_datafusion_statement(statement: &DataFusionStatement) -> SqlStatementKind {
|
|
37
|
+
match statement {
|
|
38
|
+
DataFusionStatement::Statement(statement) => classify_ast_statement(statement),
|
|
39
|
+
DataFusionStatement::Explain(_) => SqlStatementKind::Read,
|
|
40
|
+
_ => SqlStatementKind::Other,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
50
44
|
pub(crate) fn datafusion_statement_dml_target_table_names(
|
|
51
45
|
statement: &DataFusionStatement,
|
|
52
46
|
) -> Vec<String> {
|
|
@@ -55,15 +49,6 @@ pub(crate) fn datafusion_statement_dml_target_table_names(
|
|
|
55
49
|
targets
|
|
56
50
|
}
|
|
57
51
|
|
|
58
|
-
fn parse_sql_statements(sql: &str) -> Result<Vec<SqlStatement>, LixError> {
|
|
59
|
-
Parser::parse_sql(&GenericDialect {}, sql).map_err(|error| {
|
|
60
|
-
LixError::new(
|
|
61
|
-
LixError::CODE_PARSE_ERROR,
|
|
62
|
-
format!("sql2 SQL parse error: {error}"),
|
|
63
|
-
)
|
|
64
|
-
})
|
|
65
|
-
}
|
|
66
|
-
|
|
67
52
|
fn collect_datafusion_statement_dml_target_table_names(
|
|
68
53
|
statement: &DataFusionStatement,
|
|
69
54
|
targets: &mut Vec<String>,
|
|
@@ -133,7 +118,7 @@ fn classify_ast_statement(statement: &SqlStatement) -> SqlStatementKind {
|
|
|
133
118
|
SqlStatementKind::Write
|
|
134
119
|
}
|
|
135
120
|
SqlStatement::Query(_) => SqlStatementKind::Read,
|
|
136
|
-
SqlStatement::Explain {
|
|
121
|
+
SqlStatement::Explain { .. } => SqlStatementKind::Read,
|
|
137
122
|
_ => SqlStatementKind::Other,
|
|
138
123
|
}
|
|
139
124
|
}
|
|
@@ -142,7 +127,14 @@ fn validate_supported_ast_statement(statement: &SqlStatement) -> Result<(), LixE
|
|
|
142
127
|
match statement {
|
|
143
128
|
SqlStatement::Query(query) => validate_supported_query(query),
|
|
144
129
|
SqlStatement::Insert(_) | SqlStatement::Update(_) | SqlStatement::Delete(_) => Ok(()),
|
|
145
|
-
SqlStatement::Explain { statement, .. } =>
|
|
130
|
+
SqlStatement::Explain { statement, .. } => {
|
|
131
|
+
if classify_ast_statement(statement.as_ref()) == SqlStatementKind::Write {
|
|
132
|
+
return Err(unsupported_sql_error(
|
|
133
|
+
"EXPLAIN of write statements is not supported by Lix SQL",
|
|
134
|
+
));
|
|
135
|
+
}
|
|
136
|
+
validate_supported_ast_statement(statement)
|
|
137
|
+
}
|
|
146
138
|
_ => Err(unsupported_sql_error(format!(
|
|
147
139
|
"SQL statement is not supported by Lix SQL: {statement}"
|
|
148
140
|
))),
|
|
@@ -59,7 +59,7 @@ fn classify_datafusion_error(error: &DataFusionError) -> LixError {
|
|
|
59
59
|
|
|
60
60
|
if looks_like_unsupported_dialect(&lower) {
|
|
61
61
|
return LixError::new(LixError::CODE_DIALECT_UNSUPPORTED, message)
|
|
62
|
-
.with_hint("Lix SQL uses DataFusion syntax. Use lix_json_get(...) or lix_json_get_text(...) for JSON access, and
|
|
62
|
+
.with_hint("Lix SQL uses DataFusion syntax. Use lix_json_get(...) or lix_json_get_text(...) for JSON access, and placeholders like ?, ? or $1, $2, ...");
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
if looks_like_unsupported_runtime_plan(&lower) {
|
|
@@ -76,9 +76,8 @@ fn classify_datafusion_error(error: &DataFusionError) -> LixError {
|
|
|
76
76
|
|| lower.contains("placeholder")
|
|
77
77
|
|| lower.contains("bind")
|
|
78
78
|
{
|
|
79
|
-
return LixError::new(LixError::CODE_PARSE_ERROR, message)
|
|
80
|
-
"Use numbered placeholders like $1, $2,
|
|
81
|
-
);
|
|
79
|
+
return LixError::new(LixError::CODE_PARSE_ERROR, message)
|
|
80
|
+
.with_hint("Use placeholders like ?, ? or numbered placeholders like $1, $2, ...");
|
|
82
81
|
}
|
|
83
82
|
|
|
84
83
|
if lower.contains("requires start_commit_id")
|
|
@@ -4,7 +4,9 @@ use datafusion::common::metadata::{FieldMetadata, ScalarAndMetadata};
|
|
|
4
4
|
use datafusion::common::{ParamValues, ScalarValue};
|
|
5
5
|
use datafusion::logical_expr::{Expr, LogicalPlan, WriteOp};
|
|
6
6
|
use datafusion::prelude::SessionContext;
|
|
7
|
-
use datafusion::sql::parser::Statement as DataFusionStatement;
|
|
7
|
+
use datafusion::sql::parser::{DFParserBuilder, Statement as DataFusionStatement};
|
|
8
|
+
use datafusion::sql::sqlparser::dialect::GenericDialect;
|
|
9
|
+
use datafusion::sql::sqlparser::tokenizer::{Token, Tokenizer};
|
|
8
10
|
use serde_json::{json, Value as JsonValue};
|
|
9
11
|
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
|
10
12
|
|
|
@@ -13,7 +15,7 @@ use crate::{LixError, LixNotice, SqlQueryResult, Value};
|
|
|
13
15
|
|
|
14
16
|
use super::predicate_typecheck::validate_json_predicate_expr_with_dfschema;
|
|
15
17
|
use super::result_metadata::{field_is_json, LIX_VALUE_TYPE_JSON, LIX_VALUE_TYPE_METADATA_KEY};
|
|
16
|
-
use super::session::{build_read_session, build_write_session
|
|
18
|
+
use super::session::{build_read_session, build_write_session};
|
|
17
19
|
use super::write_normalization::{
|
|
18
20
|
is_binary_type, lix_file_data_type_lix_error, logical_expr_is_binary_or_null,
|
|
19
21
|
};
|
|
@@ -60,15 +62,24 @@ pub(crate) async fn create_logical_plan(
|
|
|
60
62
|
ctx: &dyn SqlExecutionContext,
|
|
61
63
|
sql: &str,
|
|
62
64
|
) -> Result<SqlLogicalPlan, LixError> {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
+
let statement = parse_statement(sql)?;
|
|
66
|
+
create_logical_plan_from_parsed(ctx, sql, statement).await
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
pub(crate) fn parse_statement(sql: &str) -> Result<DataFusionStatement, LixError> {
|
|
70
|
+
parse_datafusion_statement(sql)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
pub(crate) async fn create_logical_plan_from_parsed(
|
|
74
|
+
ctx: &dyn SqlExecutionContext,
|
|
75
|
+
sql: &str,
|
|
76
|
+
statement: DataFusionStatement,
|
|
77
|
+
) -> Result<SqlLogicalPlan, LixError> {
|
|
65
78
|
validate_public_read_sql_surface(sql)?;
|
|
79
|
+
super::validate_supported_datafusion_statement_ast(&statement)?;
|
|
80
|
+
super::udfs::validate_public_udf_calls_in_datafusion_statement(&statement)?;
|
|
66
81
|
let session = build_read_session(ctx).await?;
|
|
67
|
-
let plan = session
|
|
68
|
-
.state()
|
|
69
|
-
.create_logical_plan(sql)
|
|
70
|
-
.await
|
|
71
|
-
.map_err(datafusion_error_to_lix_error)?;
|
|
82
|
+
let plan = create_logical_plan_from_statement(&session, statement).await?;
|
|
72
83
|
validate_supported_logical_plan(&plan)?;
|
|
73
84
|
validate_json_predicates_in_logical_plan(&plan)?;
|
|
74
85
|
let kind = classify_logical_plan(&plan);
|
|
@@ -88,10 +99,17 @@ pub(crate) async fn create_write_logical_plan(
|
|
|
88
99
|
ctx: &mut dyn SqlWriteExecutionContext,
|
|
89
100
|
sql: &str,
|
|
90
101
|
) -> Result<SqlLogicalPlan, LixError> {
|
|
91
|
-
|
|
102
|
+
let statement = parse_statement(sql)?;
|
|
103
|
+
create_write_logical_plan_from_parsed(ctx, statement).await
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
pub(crate) async fn create_write_logical_plan_from_parsed(
|
|
107
|
+
ctx: &mut dyn SqlWriteExecutionContext,
|
|
108
|
+
statement: DataFusionStatement,
|
|
109
|
+
) -> Result<SqlLogicalPlan, LixError> {
|
|
110
|
+
super::udfs::validate_public_udf_calls_in_datafusion_statement(&statement)?;
|
|
92
111
|
let visible_schemas = ctx.list_visible_schemas()?;
|
|
93
|
-
super::public_bind::
|
|
94
|
-
let statement = parse_datafusion_statement(sql)?;
|
|
112
|
+
super::public_bind::validate_public_dml_statement(&statement, &visible_schemas)?;
|
|
95
113
|
super::validate_supported_datafusion_statement_ast(&statement)?;
|
|
96
114
|
reject_read_only_history_view_dml_from_statement(&statement, &visible_schemas)?;
|
|
97
115
|
let session = build_write_session(ctx).await?;
|
|
@@ -111,6 +129,30 @@ pub(crate) async fn create_write_logical_plan(
|
|
|
111
129
|
})
|
|
112
130
|
}
|
|
113
131
|
|
|
132
|
+
pub(crate) async fn create_transaction_read_logical_plan_from_parsed(
|
|
133
|
+
ctx: &mut dyn SqlWriteExecutionContext,
|
|
134
|
+
sql: &str,
|
|
135
|
+
statement: DataFusionStatement,
|
|
136
|
+
) -> Result<SqlLogicalPlan, LixError> {
|
|
137
|
+
validate_public_read_sql_surface(sql)?;
|
|
138
|
+
super::validate_supported_datafusion_statement_ast(&statement)?;
|
|
139
|
+
super::udfs::validate_public_udf_calls_in_datafusion_statement(&statement)?;
|
|
140
|
+
let session = build_write_session(ctx).await?;
|
|
141
|
+
let plan = create_logical_plan_from_statement(&session, statement).await?;
|
|
142
|
+
validate_supported_logical_plan(&plan)?;
|
|
143
|
+
validate_json_predicates_in_logical_plan(&plan)?;
|
|
144
|
+
let kind = classify_logical_plan(&plan);
|
|
145
|
+
let notices = history_filter_notices(&plan);
|
|
146
|
+
|
|
147
|
+
Ok(SqlLogicalPlan {
|
|
148
|
+
session,
|
|
149
|
+
plan,
|
|
150
|
+
kind,
|
|
151
|
+
notices,
|
|
152
|
+
strict_binary_params: BTreeSet::new(),
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
114
156
|
fn validate_public_read_sql_surface(sql: &str) -> Result<(), LixError> {
|
|
115
157
|
let normalized = sql.to_ascii_lowercase();
|
|
116
158
|
if normalized.contains("lower(path)") {
|
|
@@ -131,12 +173,64 @@ fn validate_public_read_sql_surface(sql: &str) -> Result<(), LixError> {
|
|
|
131
173
|
}
|
|
132
174
|
|
|
133
175
|
fn parse_datafusion_statement(sql: &str) -> Result<DataFusionStatement, LixError> {
|
|
134
|
-
let
|
|
135
|
-
let
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
176
|
+
let dialect = GenericDialect {};
|
|
177
|
+
let mut next_index = 1usize;
|
|
178
|
+
let mut has_anonymous = false;
|
|
179
|
+
let mut explicit_placeholders = Vec::new();
|
|
180
|
+
|
|
181
|
+
let mut tokens = Vec::new();
|
|
182
|
+
Tokenizer::new(&dialect, sql)
|
|
183
|
+
.tokenize_with_location_into_buf_with_mapper(&mut tokens, |mut token_span| {
|
|
184
|
+
if let Token::Placeholder(placeholder) = &token_span.token {
|
|
185
|
+
if placeholder == "?" {
|
|
186
|
+
has_anonymous = true;
|
|
187
|
+
token_span.token = Token::Placeholder(format!("${next_index}"));
|
|
188
|
+
next_index += 1;
|
|
189
|
+
} else {
|
|
190
|
+
explicit_placeholders.push(placeholder.clone());
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
token_span
|
|
194
|
+
})
|
|
195
|
+
.map_err(|error| {
|
|
196
|
+
LixError::new(
|
|
197
|
+
LixError::CODE_PARSE_ERROR,
|
|
198
|
+
format!("sql2 SQL tokenize error: {error}"),
|
|
199
|
+
)
|
|
200
|
+
})?;
|
|
201
|
+
|
|
202
|
+
if has_anonymous && !explicit_placeholders.is_empty() {
|
|
203
|
+
return Err(LixError::new(
|
|
204
|
+
LixError::CODE_PARSE_ERROR,
|
|
205
|
+
"SQL mixes anonymous and explicit parameter placeholders",
|
|
206
|
+
)
|
|
207
|
+
.with_hint("Use either anonymous placeholders like ?, ? or numbered placeholders like $1, $2, but not both.")
|
|
208
|
+
.with_details(json!({
|
|
209
|
+
"operation": "execute",
|
|
210
|
+
"explicit_placeholders": explicit_placeholders,
|
|
211
|
+
})));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let mut statements = DFParserBuilder::new(tokens)
|
|
215
|
+
.with_dialect(&dialect)
|
|
216
|
+
.build()
|
|
217
|
+
.map_err(datafusion_error_to_lix_error)?
|
|
218
|
+
.parse_statements()
|
|
219
|
+
.map_err(datafusion_error_to_lix_error)?;
|
|
220
|
+
|
|
221
|
+
if statements.len() > 1 {
|
|
222
|
+
return Err(LixError::new(
|
|
223
|
+
LixError::CODE_UNSUPPORTED_SQL,
|
|
224
|
+
"Lix SQL only supports one statement per execute() call",
|
|
225
|
+
));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
statements.pop_front().ok_or_else(|| {
|
|
229
|
+
LixError::new(
|
|
230
|
+
LixError::CODE_PARSE_ERROR,
|
|
231
|
+
"sql2 DataFusion error: No SQL statements were provided in the query string",
|
|
232
|
+
)
|
|
233
|
+
})
|
|
140
234
|
}
|
|
141
235
|
|
|
142
236
|
async fn create_logical_plan_from_statement(
|
|
@@ -267,7 +361,7 @@ fn placeholder_index(id: &str) -> Result<usize, LixError> {
|
|
|
267
361
|
LixError::CODE_PARSE_ERROR,
|
|
268
362
|
format!("unsupported SQL parameter placeholder '{id}'"),
|
|
269
363
|
)
|
|
270
|
-
.with_hint("Use numbered placeholders like $1, $2, ...")
|
|
364
|
+
.with_hint("Use placeholders like ?, ? or numbered placeholders like $1, $2, ...")
|
|
271
365
|
})
|
|
272
366
|
}
|
|
273
367
|
|
|
@@ -362,7 +456,7 @@ fn expected_positional_parameter_count(
|
|
|
362
456
|
LixError::CODE_PARSE_ERROR,
|
|
363
457
|
format!("unsupported SQL parameter placeholder '{name}'"),
|
|
364
458
|
)
|
|
365
|
-
.with_hint("Use numbered placeholders like $1, $2, ...")
|
|
459
|
+
.with_hint("Use placeholders like ?, ? or numbered placeholders like $1, $2, ...")
|
|
366
460
|
.with_details(json!({
|
|
367
461
|
"operation": "execute",
|
|
368
462
|
"placeholder": name,
|
|
@@ -373,7 +467,7 @@ fn expected_positional_parameter_count(
|
|
|
373
467
|
LixError::CODE_PARSE_ERROR,
|
|
374
468
|
"SQL parameter placeholders are 1-indexed",
|
|
375
469
|
)
|
|
376
|
-
.with_hint("Use numbered placeholders like $1, $2, ...")
|
|
470
|
+
.with_hint("Use placeholders like ?, ? or numbered placeholders like $1, $2, ...")
|
|
377
471
|
.with_details(json!({
|
|
378
472
|
"operation": "execute",
|
|
379
473
|
"placeholder": name,
|
|
@@ -1870,7 +1964,6 @@ mod tests {
|
|
|
1870
1964
|
"DELETE FROM lix_state_history",
|
|
1871
1965
|
"DELETE FROM LIX_STATE_HISTORY",
|
|
1872
1966
|
"DELETE FROM main.LIX_STATE_HISTORY",
|
|
1873
|
-
"EXPLAIN DELETE FROM lix_state_history",
|
|
1874
1967
|
] {
|
|
1875
1968
|
let blob_reader: Arc<dyn BlobDataReader> = Arc::new(DummyBlobReader);
|
|
1876
1969
|
let live_state = Arc::new(DummyLiveStateReader);
|
|
@@ -30,9 +30,8 @@ mod version_scope;
|
|
|
30
30
|
mod write_normalization;
|
|
31
31
|
|
|
32
32
|
pub(crate) use classify::{
|
|
33
|
-
|
|
34
|
-
validate_supported_datafusion_statement_ast,
|
|
35
|
-
SqlStatementKind,
|
|
33
|
+
classify_datafusion_statement, datafusion_statement_dml_target_table_names,
|
|
34
|
+
validate_supported_datafusion_statement_ast, SqlStatementKind,
|
|
36
35
|
};
|
|
37
36
|
pub(crate) use context::{
|
|
38
37
|
CommitStoreQuerySource, SqlCommitStoreQuerySource, SqlExecutionContext, SqlJsonReader,
|
|
@@ -41,6 +40,8 @@ pub(crate) use context::{
|
|
|
41
40
|
};
|
|
42
41
|
#[allow(unused_imports)]
|
|
43
42
|
pub(crate) use execute::{
|
|
44
|
-
create_logical_plan,
|
|
43
|
+
create_logical_plan, create_logical_plan_from_parsed,
|
|
44
|
+
create_transaction_read_logical_plan_from_parsed, create_write_logical_plan,
|
|
45
|
+
create_write_logical_plan_from_parsed, execute_logical_plan, execute_sql, parse_statement,
|
|
45
46
|
SqlLogicalPlan,
|
|
46
47
|
};
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
use datafusion::logical_expr::{LogicalPlan, WriteOp};
|
|
2
|
+
use datafusion::sql::parser::Statement as DataFusionStatement;
|
|
2
3
|
use datafusion::sql::sqlparser::ast::{
|
|
3
4
|
Assignment, AssignmentTarget, Delete, FromTable, ObjectName, Statement, TableFactor,
|
|
4
5
|
TableObject, TableWithJoins, Update,
|
|
5
6
|
};
|
|
6
|
-
use datafusion::sql::sqlparser::dialect::GenericDialect;
|
|
7
|
-
use datafusion::sql::sqlparser::parser::Parser;
|
|
8
7
|
use serde_json::Value as JsonValue;
|
|
9
8
|
|
|
10
9
|
use crate::LixError;
|
|
@@ -30,18 +29,25 @@ impl DmlOperation {
|
|
|
30
29
|
}
|
|
31
30
|
}
|
|
32
31
|
|
|
33
|
-
pub(crate) fn
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
format!("sql2 SQL parse error: {error}"),
|
|
38
|
-
)
|
|
39
|
-
})?;
|
|
40
|
-
let [statement] = statements.as_slice() else {
|
|
41
|
-
return Ok(());
|
|
42
|
-
};
|
|
32
|
+
pub(crate) fn validate_datafusion_statement(
|
|
33
|
+
statement: &DataFusionStatement,
|
|
34
|
+
visible_schemas: &[JsonValue],
|
|
35
|
+
) -> Result<(), LixError> {
|
|
43
36
|
let contracts = PublicTableContracts::new(visible_schemas)?;
|
|
44
|
-
|
|
37
|
+
validate_datafusion_statement_with_contracts(statement, &contracts)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
fn validate_datafusion_statement_with_contracts(
|
|
41
|
+
statement: &DataFusionStatement,
|
|
42
|
+
contracts: &PublicTableContracts,
|
|
43
|
+
) -> Result<(), LixError> {
|
|
44
|
+
match statement {
|
|
45
|
+
DataFusionStatement::Statement(statement) => validate_statement(statement, contracts),
|
|
46
|
+
DataFusionStatement::Explain(explain) => {
|
|
47
|
+
validate_datafusion_statement_with_contracts(explain.statement.as_ref(), contracts)
|
|
48
|
+
}
|
|
49
|
+
_ => Ok(()),
|
|
50
|
+
}
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
pub(crate) fn validate_plan(
|
|
@@ -4,17 +4,18 @@ mod dml;
|
|
|
4
4
|
mod table;
|
|
5
5
|
|
|
6
6
|
use datafusion::logical_expr::LogicalPlan;
|
|
7
|
+
use datafusion::sql::parser::Statement as DataFusionStatement;
|
|
7
8
|
use serde_json::Value as JsonValue;
|
|
8
9
|
|
|
9
10
|
use crate::LixError;
|
|
10
11
|
|
|
11
12
|
pub(crate) use dml::DmlOperation;
|
|
12
13
|
|
|
13
|
-
pub(crate) fn
|
|
14
|
-
|
|
14
|
+
pub(crate) fn validate_public_dml_statement(
|
|
15
|
+
statement: &DataFusionStatement,
|
|
15
16
|
visible_schemas: &[JsonValue],
|
|
16
17
|
) -> Result<(), LixError> {
|
|
17
|
-
dml::
|
|
18
|
+
dml::validate_datafusion_statement(statement, visible_schemas)
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
pub(crate) fn validate_public_dml_plan(
|
|
@@ -15,7 +15,7 @@ use datafusion::logical_expr::ScalarUDF;
|
|
|
15
15
|
|
|
16
16
|
use crate::functions::FunctionProviderHandle;
|
|
17
17
|
|
|
18
|
-
pub(crate) use public_call::
|
|
18
|
+
pub(crate) use public_call::validate_public_udf_calls_in_datafusion_statement;
|
|
19
19
|
|
|
20
20
|
#[cfg(test)]
|
|
21
21
|
pub(crate) fn system_sql2_function_provider() -> FunctionProviderHandle {
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
use std::ops::ControlFlow;
|
|
2
2
|
|
|
3
|
+
use datafusion::sql::parser::Statement as DataFusionStatement;
|
|
3
4
|
use datafusion::sql::sqlparser::ast::{
|
|
4
5
|
Expr, Function, FunctionArg, FunctionArgExpr, FunctionArguments, ObjectNamePart, Statement,
|
|
5
6
|
Value, Visit, Visitor,
|
|
6
7
|
};
|
|
8
|
+
#[cfg(test)]
|
|
7
9
|
use datafusion::sql::sqlparser::dialect::GenericDialect;
|
|
10
|
+
#[cfg(test)]
|
|
8
11
|
use datafusion::sql::sqlparser::parser::Parser;
|
|
9
12
|
|
|
10
13
|
use crate::LixError;
|
|
11
14
|
|
|
15
|
+
#[cfg(test)]
|
|
12
16
|
pub(crate) fn validate_public_udf_calls(sql: &str) -> Result<(), LixError> {
|
|
13
17
|
let statements = Parser::parse_sql(&GenericDialect {}, sql).map_err(|error| {
|
|
14
18
|
LixError::new(
|
|
@@ -68,6 +72,29 @@ fn validate_public_function_call(function: &Function) -> Result<(), LixError> {
|
|
|
68
72
|
}
|
|
69
73
|
}
|
|
70
74
|
|
|
75
|
+
pub(crate) fn validate_public_udf_calls_in_datafusion_statement(
|
|
76
|
+
statement: &DataFusionStatement,
|
|
77
|
+
) -> Result<(), LixError> {
|
|
78
|
+
let mut visitor = PublicUdfCallVisitor;
|
|
79
|
+
visit_datafusion_statement(statement, &mut visitor)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
fn visit_datafusion_statement(
|
|
83
|
+
statement: &DataFusionStatement,
|
|
84
|
+
visitor: &mut PublicUdfCallVisitor,
|
|
85
|
+
) -> Result<(), LixError> {
|
|
86
|
+
match statement {
|
|
87
|
+
DataFusionStatement::Statement(statement) => match statement.visit(visitor) {
|
|
88
|
+
ControlFlow::Continue(()) => Ok(()),
|
|
89
|
+
ControlFlow::Break(error) => Err(*error),
|
|
90
|
+
},
|
|
91
|
+
DataFusionStatement::Explain(explain) => {
|
|
92
|
+
visit_datafusion_statement(explain.statement.as_ref(), visitor)
|
|
93
|
+
}
|
|
94
|
+
_ => Ok(()),
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
71
98
|
fn public_lix_function_name(function: &Function) -> Option<&'static str> {
|
|
72
99
|
let part = function.name.0.last()?;
|
|
73
100
|
let ident = match part {
|