Skip to content

Commit 3635cd9

Browse files
committed
sqlite: pass conflict type to conflict resolution handler
1 parent b814038 commit 3635cd9

File tree

3 files changed

+214
-33
lines changed

3 files changed

+214
-33
lines changed

doc/api/sqlite.md

+50-7
Original file line numberDiff line numberDiff line change
@@ -234,10 +234,24 @@ added:
234234
* `options` {Object} The configuration options for how the changes will be applied.
235235
* `filter` {Function} Skip changes that, when targeted table name is supplied to this function, return a truthy value.
236236
By default, all changes are attempted.
237-
* `onConflict` {number} Determines how conflicts are handled. **Default**: `SQLITE_CHANGESET_ABORT`.
238-
* `SQLITE_CHANGESET_OMIT`: conflicting changes are omitted.
239-
* `SQLITE_CHANGESET_REPLACE`: conflicting changes replace existing values.
240-
* `SQLITE_CHANGESET_ABORT`: abort on conflict and roll back database.
237+
* `onConflict` {Function} A function that determines how to handle conflicts. The function receives one argument,
238+
which can be one of the following values:
239+
240+
* `SQLITE_CHANGESET_DATA`: A `DELETE` or `UPDATE` change does not contain the expected "before" values.
241+
* `SQLITE_CHANGESET_NOTFOUND`: A row matching the primary key of the `DELETE` or `UPDATE` change does not exist.
242+
* `SQLITE_CHANGESET_CONFLICT`: An `INSERT` change results in a duplicate primary key.
243+
* `SQLITE_CHANGESET_FOREIGN_KEY`: Applying a change would result in a foreign key violation.
244+
* `SQLITE_CHANGESET_CONSTRAINT`: Applying a change results in a `UNIQUE`, `CHECK`, or `NOT NULL` constraint
245+
violation.
246+
247+
The function should return one of the following values:
248+
249+
* `SQLITE_CHANGESET_OMIT`: Omit conflicting changes.
250+
* `SQLITE_CHANGESET_REPLACE`: Replace existing values with conflicting changes (only valid with
251+
`SQLITE_CHANGESET_DATA` or `SQLITE_CHANGESET_CONFLICT` conflicts).
252+
* `SQLITE_CHANGESET_ABORT`: Abort on conflict and roll back the database.
253+
254+
**Default**: A function that returns `SQLITE_CHANGESET_ABORT`.
241255
* Returns: {boolean} Whether the changeset was applied succesfully without being aborted.
242256

243257
An exception is thrown if the database is not
@@ -496,9 +510,38 @@ An object containing commonly used constants for SQLite operations.
496510

497511
The following constants are exported by the `sqlite.constants` object.
498512

499-
#### Conflict-resolution constants
513+
#### Conflict resolution constants
514+
515+
One of the following constants is available as an argument to the `onConflict` conflict resolution handler passed to [`database.applyChangeset()`](#databaseapplychangesetchangeset-options)). See also [Constants Passed To The Conflict Handler](https://www.sqlite.org/session/c_changeset_conflict.html) in the SQLite documentation.
516+
517+
<table>
518+
<tr>
519+
<th>Constant</th>
520+
<th>Description</th>
521+
</tr>
522+
<tr>
523+
<td><code>SQLITE_CHANGESET_DATA</code></td>
524+
<td>The conflict handler is invoked with this constant when processing a DELETE or UPDATE change if a row with the required PRIMARY KEY fields is present in the database, but one or more other (non primary-key) fields modified by the update do not contain the expected "before" values.</td>
525+
</tr>
526+
<tr>
527+
<td><code>SQLITE_CHANGESET_NOTFOUND</code></td>
528+
<td>The conflict handler is invoked with this constant when processing a DELETE or UPDATE change if a row with the required PRIMARY KEY fields is not present in the database.</td>
529+
</tr>
530+
<tr>
531+
<td><code>SQLITE_CHANGESET_CONFLICT</code></td>
532+
<td>This constant is passed to the conflict handler while processing an INSERT change if the operation would result in duplicate primary key values.</td>
533+
</tr>
534+
<tr>
535+
<td><code>SQLITE_CHANGESET_CONSTRAINT</code></td>
536+
<td>If foreign key handling is enabled, and applying a changeset leaves the database in a state containing foreign key violations, the conflict handler is invoked with this constant exactly once before the changeset is committed. If the conflict handler returns SQLITE_CHANGESET_OMIT, the changes, including those that caused the foreign key constraint violation, are committed. Or, if it returns SQLITE_CHANGESET_ABORT, the changeset is rolled back.</td>
537+
</tr>
538+
<tr>
539+
<td><code>SQLITE_CHANGESET_FOREIGN_KEY</code></td>
540+
<td>If any other constraint violation occurs while applying a change (i.e. a UNIQUE, CHECK or NOT NULL constraint), the conflict handler is invoked with this constant.</td>
541+
</tr>
542+
</table>
500543

501-
The following constants are meant for use with [`database.applyChangeset()`](#databaseapplychangesetchangeset-options).
544+
One of the following constants must be returned from the `onConflict` conflict resolution handler passed to [`database.applyChangeset()`](#databaseapplychangesetchangeset-options). See also [Constants Returned From The Conflict Handler](https://www.sqlite.org/session/c_changeset_abort.html) in the SQLite documentation.
502545

503546
<table>
504547
<tr>
@@ -511,7 +554,7 @@ The following constants are meant for use with [`database.applyChangeset()`](#da
511554
</tr>
512555
<tr>
513556
<td><code>SQLITE_CHANGESET_REPLACE</code></td>
514-
<td>Conflicting changes replace existing values.</td>
557+
<td>Conflicting changes replace existing values. Note that this value can only be returned when the type of conflict is either `SQLITE_CHANGESET_DATA` or `SQLITE_CHANGESET_CONFLICT`.</td>
515558
</tr>
516559
<tr>
517560
<td><code>SQLITE_CHANGESET_ABORT</code></td>

src/node_sqlite.cc

+18-7
Original file line numberDiff line numberDiff line change
@@ -731,11 +731,11 @@ void DatabaseSync::CreateSession(const FunctionCallbackInfo<Value>& args) {
731731

732732
// the reason for using static functions here is that SQLite needs a
733733
// function pointer
734-
static std::function<int()> conflictCallback;
734+
static std::function<int(int)> conflictCallback;
735735

736736
static int xConflict(void* pCtx, int eConflict, sqlite3_changeset_iter* pIter) {
737737
if (!conflictCallback) return SQLITE_CHANGESET_ABORT;
738-
return conflictCallback();
738+
return conflictCallback(eConflict);
739739
}
740740

741741
static std::function<bool(std::string)> filterCallback;
@@ -773,15 +773,20 @@ void DatabaseSync::ApplyChangeset(const FunctionCallbackInfo<Value>& args) {
773773
options->Get(env->context(), env->onconflict_string()).ToLocalChecked();
774774

775775
if (!conflictValue->IsUndefined()) {
776-
if (!conflictValue->IsNumber()) {
776+
if (!conflictValue->IsFunction()) {
777777
THROW_ERR_INVALID_ARG_TYPE(
778778
env->isolate(),
779-
"The \"options.onConflict\" argument must be a number.");
779+
"The \"options.onConflict\" argument must be a function.");
780780
return;
781781
}
782-
783-
int conflictInt = conflictValue->Int32Value(env->context()).FromJust();
784-
conflictCallback = [conflictInt]() -> int { return conflictInt; };
782+
Local<Function> conflictFunc = conflictValue.As<Function>();
783+
conflictCallback = [env, conflictFunc](int conflictType) -> int {
784+
Local<Value> argv[] = {Integer::New(env->isolate(), conflictType)};
785+
Local<Value> result =
786+
conflictFunc->Call(env->context(), Null(env->isolate()), 1, argv)
787+
.ToLocalChecked();
788+
return result->Int32Value(env->context()).FromJust();
789+
};
785790
}
786791

787792
if (options->HasOwnProperty(env->context(), env->filter_string())
@@ -1662,6 +1667,12 @@ void DefineConstants(Local<Object> target) {
16621667
NODE_DEFINE_CONSTANT(target, SQLITE_CHANGESET_OMIT);
16631668
NODE_DEFINE_CONSTANT(target, SQLITE_CHANGESET_REPLACE);
16641669
NODE_DEFINE_CONSTANT(target, SQLITE_CHANGESET_ABORT);
1670+
1671+
NODE_DEFINE_CONSTANT(target, SQLITE_CHANGESET_DATA);
1672+
NODE_DEFINE_CONSTANT(target, SQLITE_CHANGESET_NOTFOUND);
1673+
NODE_DEFINE_CONSTANT(target, SQLITE_CHANGESET_CONFLICT);
1674+
NODE_DEFINE_CONSTANT(target, SQLITE_CHANGESET_CONSTRAINT);
1675+
NODE_DEFINE_CONSTANT(target, SQLITE_CHANGESET_FOREIGN_KEY);
16651676
}
16661677

16671678
static void Initialize(Local<Object> target,

test/parallel/test-sqlite-session.js

+146-19
Original file line numberDiff line numberDiff line change
@@ -128,15 +128,15 @@ test('database.createSession() - use table option to track specific table', (t)
128128
});
129129

130130
suite('conflict resolution', () => {
131+
const createDataTableSql = `CREATE TABLE data (
132+
key INTEGER PRIMARY KEY,
133+
value TEXT UNIQUE
134+
) STRICT`;
135+
131136
const prepareConflict = () => {
132137
const database1 = new DatabaseSync(':memory:');
133138
const database2 = new DatabaseSync(':memory:');
134139

135-
const createDataTableSql = `CREATE TABLE data (
136-
key INTEGER PRIMARY KEY,
137-
value TEXT
138-
) STRICT
139-
`;
140140
database1.exec(createDataTableSql);
141141
database2.exec(createDataTableSql);
142142

@@ -151,7 +151,91 @@ suite('conflict resolution', () => {
151151
};
152152
};
153153

154-
test('database.applyChangeset() - conflict with default behavior (abort)', (t) => {
154+
const prepareDataConflict = () => {
155+
const database1 = new DatabaseSync(':memory:');
156+
const database2 = new DatabaseSync(':memory:');
157+
158+
database1.exec(createDataTableSql);
159+
database2.exec(createDataTableSql);
160+
161+
const insertSql = 'INSERT INTO data (key, value) VALUES (?, ?)';
162+
database1.prepare(insertSql).run(1, 'hello');
163+
database2.prepare(insertSql).run(1, 'othervalue');
164+
const session = database1.createSession();
165+
database1.prepare('UPDATE data SET value = ? WHERE key = ?').run('foo', 1);
166+
return {
167+
database2,
168+
changeset: session.changeset()
169+
};
170+
};
171+
172+
const prepareNotFoundConflict = () => {
173+
const database1 = new DatabaseSync(':memory:');
174+
const database2 = new DatabaseSync(':memory:');
175+
176+
database1.exec(createDataTableSql);
177+
database2.exec(createDataTableSql);
178+
179+
const insertSql = 'INSERT INTO data (key, value) VALUES (?, ?)';
180+
database1.prepare(insertSql).run(1, 'hello');
181+
const session = database1.createSession();
182+
database1.prepare('DELETE FROM data WHERE key = 1').run();
183+
return {
184+
database2,
185+
changeset: session.changeset()
186+
};
187+
};
188+
189+
const prepareFkConflict = () => {
190+
const database1 = new DatabaseSync(':memory:');
191+
const database2 = new DatabaseSync(':memory:');
192+
193+
database1.exec(createDataTableSql);
194+
database2.exec(createDataTableSql);
195+
const fkTableSql = `CREATE TABLE other (
196+
key INTEGER PRIMARY KEY,
197+
ref REFERENCES data(key)
198+
)`;
199+
database1.exec(fkTableSql);
200+
database2.exec(fkTableSql);
201+
202+
const insertDataSql = 'INSERT INTO data (key, value) VALUES (?, ?)';
203+
const insertOtherSql = 'INSERT INTO other (key, ref) VALUES (?, ?)';
204+
database1.prepare(insertDataSql).run(1, 'hello');
205+
database2.prepare(insertDataSql).run(1, 'hello');
206+
database1.prepare(insertOtherSql).run(1, 1);
207+
database2.prepare(insertOtherSql).run(1, 1);
208+
209+
database1.exec('DELETE FROM other WHERE key = 1'); // So we don't get a fk violation in database1
210+
const session = database1.createSession();
211+
database1.prepare('DELETE FROM data WHERE key = 1').run(); // Changeset with fk violation
212+
database2.exec('PRAGMA foreign_keys = ON'); // Needs to be supported, otherwise will fail here
213+
214+
return {
215+
database2,
216+
changeset: session.changeset()
217+
};
218+
};
219+
220+
const prepareConstraintConflict = () => {
221+
const database1 = new DatabaseSync(':memory:');
222+
const database2 = new DatabaseSync(':memory:');
223+
224+
database1.exec(createDataTableSql);
225+
database2.exec(createDataTableSql);
226+
227+
const insertSql = 'INSERT INTO data (key, value) VALUES (?, ?)';
228+
const session = database1.createSession();
229+
database1.prepare(insertSql).run(1, 'hello');
230+
database2.prepare(insertSql).run(2, 'hello'); // database2 already constains hello
231+
232+
return {
233+
database2,
234+
changeset: session.changeset()
235+
};
236+
};
237+
238+
test('database.applyChangeset() - SQLITE_CHANGESET_CONFLICT conflict with default behavior (abort)', (t) => {
155239
const { database2, changeset } = prepareConflict();
156240
// When changeset is aborted due to a conflict, applyChangeset should return false
157241
t.assert.strictEqual(database2.applyChangeset(changeset), false);
@@ -160,40 +244,83 @@ suite('conflict resolution', () => {
160244
[{ value: 'world' }]); // unchanged
161245
});
162246

163-
test('database.applyChangeset() - conflict with SQLITE_CHANGESET_ABORT', (t) => {
247+
test('database.applyChangeset() - SQLITE_CHANGESET_CONFLICT conflict handled with SQLITE_CHANGESET_ABORT', (t) => {
164248
const { database2, changeset } = prepareConflict();
249+
let conflictType = null;
165250
const result = database2.applyChangeset(changeset, {
166-
onConflict: constants.SQLITE_CHANGESET_ABORT
251+
onConflict: (conflictType_) => {
252+
conflictType = conflictType_;
253+
return constants.SQLITE_CHANGESET_ABORT;
254+
}
167255
});
168256
// When changeset is aborted due to a conflict, applyChangeset should return false
169257
t.assert.strictEqual(result, false);
258+
t.assert.strictEqual(conflictType, constants.SQLITE_CHANGESET_CONFLICT);
170259
deepStrictEqual(t)(
171260
database2.prepare('SELECT value from data').all(),
172261
[{ value: 'world' }]); // unchanged
173262
});
174263

175-
test('database.applyChangeset() - conflict with SQLITE_CHANGESET_REPLACE', (t) => {
176-
const { database2, changeset } = prepareConflict();
264+
test('database.applyChangeset() - SQLITE_CHANGESET_DATA conflict handled with SQLITE_CHANGESET_REPLACE', (t) => {
265+
const { database2, changeset } = prepareDataConflict();
266+
let conflictType = null;
177267
const result = database2.applyChangeset(changeset, {
178-
onConflict: constants.SQLITE_CHANGESET_REPLACE
268+
onConflict: (conflictType_) => {
269+
conflictType = conflictType_;
270+
return constants.SQLITE_CHANGESET_REPLACE;
271+
}
179272
});
180273
// Not aborted due to conflict, so should return true
181274
t.assert.strictEqual(result, true);
275+
t.assert.strictEqual(conflictType, constants.SQLITE_CHANGESET_DATA);
182276
deepStrictEqual(t)(
183277
database2.prepare('SELECT value from data ORDER BY key').all(),
184-
[{ value: 'hello' }, { value: 'foo' }]); // replaced
278+
[{ value: 'foo' }]); // replaced
185279
});
186280

187-
test('database.applyChangeset() - conflict with SQLITE_CHANGESET_OMIT', (t) => {
188-
const { database2, changeset } = prepareConflict();
281+
test('database.applyChangeset() - SQLITE_CHANGESET_NOTFOUND conflict with SQLITE_CHANGESET_OMIT', (t) => {
282+
const { database2, changeset } = prepareNotFoundConflict();
283+
let conflictType = null;
189284
const result = database2.applyChangeset(changeset, {
190-
onConflict: constants.SQLITE_CHANGESET_OMIT
285+
onConflict: (conflictType_) => {
286+
conflictType = conflictType_;
287+
return constants.SQLITE_CHANGESET_OMIT;
288+
}
191289
});
192290
// Not aborted due to conflict, so should return true
193291
t.assert.strictEqual(result, true);
194-
deepStrictEqual(t)(
195-
database2.prepare('SELECT value from data ORDER BY key ASC').all(),
196-
[{ value: 'world' }, { value: 'foo' }]); // Conflicting change omitted
292+
t.assert.strictEqual(conflictType, constants.SQLITE_CHANGESET_NOTFOUND);
293+
deepStrictEqual(t)(database2.prepare('SELECT value from data').all(), []);
294+
});
295+
296+
test('database.applyChangeset() - SQLITE_CHANGESET_FOREIGN_KEY conflict', (t) => {
297+
const { database2, changeset } = prepareFkConflict();
298+
let conflictType = null;
299+
const result = database2.applyChangeset(changeset, {
300+
onConflict: (conflictType_) => {
301+
conflictType = conflictType_;
302+
return constants.SQLITE_CHANGESET_OMIT;
303+
}
304+
});
305+
// Not aborted due to conflict, so should return true
306+
t.assert.strictEqual(result, true);
307+
t.assert.strictEqual(conflictType, constants.SQLITE_CHANGESET_FOREIGN_KEY);
308+
deepStrictEqual(t)(database2.prepare('SELECT value from data').all(), []);
309+
});
310+
311+
test('database.applyChangeset() - SQLITE_CHANGESET_CONSTRAINT conflict', (t) => {
312+
const { database2, changeset } = prepareConstraintConflict();
313+
let conflictType = null;
314+
const result = database2.applyChangeset(changeset, {
315+
onConflict: (conflictType_) => {
316+
conflictType = conflictType_;
317+
return constants.SQLITE_CHANGESET_OMIT;
318+
}
319+
});
320+
// Not aborted due to conflict, so should return true
321+
t.assert.strictEqual(result, true);
322+
t.assert.strictEqual(conflictType, constants.SQLITE_CHANGESET_CONSTRAINT);
323+
deepStrictEqual(t)(database2.prepare('SELECT key, value from data').all(), [{ key: 2, value: 'hello' }]);
197324
});
198325
});
199326

@@ -299,7 +426,7 @@ test('database.applyChangeset() - wrong arguments', (t) => {
299426
}, null);
300427
}, {
301428
name: 'TypeError',
302-
message: 'The "options.onConflict" argument must be a number.'
429+
message: 'The "options.onConflict" argument must be a function.'
303430
});
304431
});
305432

0 commit comments

Comments
 (0)