Skip to content

Commit 7008bd6

Browse files
committed
Add support for unified save() method on DbRecord
1 parent 3222aae commit 7008bd6

10 files changed

Lines changed: 443 additions & 208 deletions

File tree

declarative_sqlite/CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,24 @@
1+
## 1.2.0
2+
3+
### Features
4+
- **Unified Save Method**: Enhanced `DbRecord.save()` to automatically handle both insert and update operations
5+
- `save()` now intelligently detects whether a record is new (needs INSERT) or existing (needs UPDATE)
6+
- Eliminates the need to manually choose between `insert()` and `save()` for updates
7+
- Added `isNewRecord` property to check whether a record needs insertion
8+
- Deprecated explicit `insert()` method in favor of unified `save()` approach
9+
- After successful insert via `save()`, record data is automatically refreshed with all system columns
10+
- Multiple consecutive `save()` calls work seamlessly on the same record
11+
12+
### Developer Experience
13+
- Simplified CRUD workflow - just use `save()` for everything
14+
- Reduced cognitive load - no need to track insert vs update state manually
15+
- Better API consistency across create and update operations
16+
17+
### Documentation
18+
- Updated data modeling guide with unified save examples
19+
- Enhanced CRUD operations documentation with recommended patterns
20+
- Added comprehensive example demonstrating unified save approach
21+
122
## 1.1.0
223

324
### Features

declarative_sqlite/lib/src/db_record.dart

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,19 @@ abstract class DbRecord {
2323

2424
/// Map to track which fields have been modified since creation
2525
final Set<String> _modifiedFields = <String>{};
26+
27+
/// Tracks whether this is a new record (needs insert) or existing (needs update)
28+
/// True if record was created without a system_id (new record to be inserted)
29+
/// False if record was loaded from database (existing record to be updated)
30+
bool _isNewRecord;
2631

2732
DbRecord(Map<String, Object?> data, this._tableName, this._database)
2833
: _data = Map<String, Object?>.from(data),
2934
_tableDefinition = _database.schema.userTables.firstWhereOrNull(
3035
(table) => table.name == _tableName,
3136
),
32-
_updateTableName = _tableName;
37+
_updateTableName = _tableName,
38+
_isNewRecord = data['system_id'] == null;
3339

3440
/// Gets the table name for this record
3541
String get tableName => _tableName;
@@ -61,6 +67,10 @@ abstract class DbRecord {
6167

6268
/// Gets the set of fields that have been modified (read-only copy)
6369
Set<String> get modifiedFields => Set.unmodifiable(_modifiedFields);
70+
71+
/// Returns true if this record needs to be inserted (new record)
72+
/// Returns false if this record already exists and needs to be updated
73+
bool get isNewRecord => _isNewRecord;
6474

6575
/// Gets the system_id for this record
6676
String? get systemId => getRawValue('system_id') as String?;
@@ -285,21 +295,37 @@ abstract class DbRecord {
285295
void setFilesetField(String columnName, FilesetField? value) =>
286296
setValue(columnName, value);
287297

288-
/// Saves any modified fields back to the database
298+
/// Saves the record to the database.
299+
///
300+
/// Automatically determines whether to insert (for new records) or update (for existing records).
301+
/// - For new records (created without system_id): performs an INSERT
302+
/// - For existing records (loaded from database): performs an UPDATE of modified fields only
303+
///
289304
/// Throws StateError if the target table doesn't support CRUD operations
290305
Future<void> save() async {
291306
_checkCrudPermission('save');
292307

308+
if (_isNewRecord) {
309+
// New record - perform insert
310+
await _performInsert();
311+
} else {
312+
// Existing record - perform update of modified fields
313+
await _performUpdate();
314+
}
315+
}
316+
317+
/// Internal method to perform the actual update operation
318+
Future<void> _performUpdate() async {
293319
if (_modifiedFields.isEmpty) return;
294320

295321
final systemId = this.systemId;
296322
if (systemId == null) {
297-
throw StateError('Cannot save record without system_id');
323+
throw StateError('Cannot update record without system_id');
298324
}
299325

300326
final systemVersion = this.systemVersion;
301327
if (systemVersion == null) {
302-
throw StateError('Cannot save record without system_version');
328+
throw StateError('Cannot update record without system_version');
303329
}
304330

305331
// Build update map with only modified fields (excluding system columns)
@@ -323,19 +349,55 @@ abstract class DbRecord {
323349
// Clear modified fields after successful save
324350
_modifiedFields.clear();
325351
}
352+
353+
/// Internal method to perform the actual insert operation
354+
Future<void> _performInsert() async {
355+
// Remove system columns - they'll be added by the database layer
356+
final insertData = Map<String, Object?>.from(_data);
357+
insertData.removeWhere((key, value) => key.startsWith('system_'));
358+
359+
final systemId = await _database.insert(_updateTableName ?? _tableName, insertData);
360+
361+
// Reload the record from database to get all system columns
362+
final results = await _database.queryTable(
363+
_updateTableName ?? _tableName,
364+
where: 'system_id = ?',
365+
whereArgs: [systemId],
366+
);
367+
368+
if (results.isEmpty) {
369+
throw StateError('Failed to reload record after insert');
370+
}
371+
372+
// Update the record's data with fresh data from database
373+
_data.clear();
374+
_data.addAll(results.first);
375+
_isNewRecord = false;
376+
377+
// Clear modified fields since this is now persisted
378+
_modifiedFields.clear();
379+
}
326380

327381
/// Creates a new record in the database with the current data
382+
///
383+
/// Note: Consider using `save()` instead, which automatically handles both insert and update.
384+
///
328385
/// Throws StateError if the target table doesn't support CRUD operations
386+
@Deprecated('Use save() instead, which automatically handles insert vs update')
329387
Future<void> insert() async {
330388
_checkCrudPermission('insert');
331389

332390
// Remove system columns - they'll be added by the database layer
333391
final insertData = Map<String, Object?>.from(_data);
334392
insertData.removeWhere((key, value) => key.startsWith('system_'));
335393

336-
await _database.insert(_updateTableName ?? _tableName, insertData);
394+
final systemId = await _database.insert(_updateTableName ?? _tableName, insertData);
395+
396+
// Update the record's data with the new system_id and mark as no longer new
397+
_data['system_id'] = systemId;
398+
_isNewRecord = false;
337399

338-
// Clear modified fields since this is a new record
400+
// Clear modified fields since this is now persisted
339401
_modifiedFields.clear();
340402
}
341403

@@ -381,6 +443,9 @@ abstract class DbRecord {
381443
// Update the data map with fresh data
382444
_data.clear();
383445
_data.addAll(results.first);
446+
447+
// Mark as existing record since we just loaded from database
448+
_isNewRecord = false;
384449

385450
// Clear modified fields since we have fresh data
386451
_modifiedFields.clear();

declarative_sqlite/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: declarative_sqlite
22
description: A dart package for declaratively creating SQLite tables and automatically migrating them.
3-
version: 1.1.0
3+
version: 1.2.0
44
repository: https://github.com/graknol/declarative_sqlite
55

66
environment:
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import 'package:declarative_sqlite/declarative_sqlite.dart';
2+
import 'package:declarative_sqlite/src/files/filesystem_file_repository.dart';
3+
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
4+
import 'package:test/test.dart';
5+
6+
void main() {
7+
sqfliteFfiInit();
8+
final databaseFactory = databaseFactoryFfi;
9+
10+
group('Unified save() method', () {
11+
late DeclarativeDatabase database;
12+
13+
setUp(() async {
14+
final schema = SchemaBuilder()
15+
.table('users', (table) {
16+
table.text('name').notNull('');
17+
table.integer('age').notNull(0);
18+
table.text('email').lww();
19+
table.key(['system_id']).primary();
20+
})
21+
.build();
22+
23+
database = await DeclarativeDatabase.open(
24+
':memory:',
25+
databaseFactory: databaseFactory,
26+
schema: schema,
27+
fileRepository: FilesystemFileRepository('temp_test'),
28+
);
29+
});
30+
31+
test('save() inserts a new record when created without system_id', () async {
32+
// Create a new record without system_id
33+
final record = GenericDbRecord({
34+
'name': 'Alice',
35+
'age': 30,
36+
'email': 'alice@example.com',
37+
}, 'users', database);
38+
39+
// Verify it's marked as new
40+
expect(record.isNewRecord, isTrue);
41+
expect(record.systemId, isNull);
42+
43+
// Save should perform an INSERT
44+
await record.save();
45+
46+
// After save, should no longer be new and should have system_id
47+
expect(record.isNewRecord, isFalse);
48+
expect(record.systemId, isNotNull);
49+
50+
// Verify the record exists in database
51+
final results = await database.queryTable('users');
52+
expect(results.length, 1);
53+
expect(results[0]['name'], 'Alice');
54+
expect(results[0]['age'], 30);
55+
expect(results[0]['email'], 'alice@example.com');
56+
});
57+
58+
test('save() updates an existing record when loaded from database', () async {
59+
// First insert a record directly
60+
final systemId = await database.insert('users', {
61+
'name': 'Bob',
62+
'age': 25,
63+
'email': 'bob@example.com',
64+
});
65+
66+
// Load the record from database
67+
final results = await database.queryTable('users',
68+
where: 'system_id = ?',
69+
whereArgs: [systemId]
70+
);
71+
final record = GenericDbRecord(results[0], 'users', database);
72+
73+
// Verify it's marked as existing
74+
expect(record.isNewRecord, isFalse);
75+
expect(record.systemId, systemId);
76+
77+
// Modify the record
78+
record.setValue('email', 'bob.updated@example.com');
79+
80+
// Save should perform an UPDATE
81+
await record.save();
82+
83+
// Verify the update
84+
final updatedResults = await database.queryTable('users',
85+
where: 'system_id = ?',
86+
whereArgs: [systemId]
87+
);
88+
expect(updatedResults.length, 1);
89+
expect(updatedResults[0]['name'], 'Bob'); // Unchanged
90+
expect(updatedResults[0]['age'], 25); // Unchanged
91+
expect(updatedResults[0]['email'], 'bob.updated@example.com'); // Updated
92+
});
93+
94+
test('save() can be called multiple times on the same record', () async {
95+
// Create new record
96+
final record = GenericDbRecord({
97+
'name': 'Charlie',
98+
'age': 35,
99+
'email': 'charlie@example.com',
100+
}, 'users', database);
101+
102+
// First save - inserts
103+
await record.save();
104+
expect(record.isNewRecord, isFalse);
105+
final systemId = record.systemId;
106+
expect(systemId, isNotNull);
107+
108+
// Modify and save again - updates (using LWW column)
109+
record.setValue('email', 'charlie.updated@example.com');
110+
await record.save();
111+
112+
// Verify update worked
113+
final results = await database.queryTable('users',
114+
where: 'system_id = ?',
115+
whereArgs: [systemId]
116+
);
117+
expect(results.length, 1);
118+
expect(results[0]['email'], 'charlie.updated@example.com');
119+
120+
// Modify and save one more time - updates again
121+
record.setValue('email', 'charlie.new@example.com');
122+
await record.save();
123+
124+
// Verify second update worked
125+
final results2 = await database.queryTable('users',
126+
where: 'system_id = ?',
127+
whereArgs: [systemId]
128+
);
129+
expect(results2.length, 1);
130+
expect(results2[0]['email'], 'charlie.new@example.com');
131+
});
132+
133+
test('save() handles empty modifications on existing record', () async {
134+
// Insert and load a record
135+
final systemId = await database.insert('users', {
136+
'name': 'Diana',
137+
'age': 28,
138+
'email': 'diana@example.com',
139+
});
140+
141+
final results = await database.queryTable('users',
142+
where: 'system_id = ?',
143+
whereArgs: [systemId]
144+
);
145+
final record = GenericDbRecord(results[0], 'users', database);
146+
147+
// Call save without modifications
148+
await record.save();
149+
150+
// Should complete without error
151+
expect(record.isNewRecord, isFalse);
152+
expect(record.systemId, systemId);
153+
});
154+
155+
test('reload() maintains correct isNewRecord state', () async {
156+
// Insert a record
157+
final systemId = await database.insert('users', {
158+
'name': 'Eve',
159+
'age': 32,
160+
'email': 'eve@example.com',
161+
});
162+
163+
// Load the record
164+
final results = await database.queryTable('users',
165+
where: 'system_id = ?',
166+
whereArgs: [systemId]
167+
);
168+
final record = GenericDbRecord(results[0], 'users', database);
169+
170+
expect(record.isNewRecord, isFalse);
171+
172+
// Reload the record
173+
await record.reload();
174+
175+
// Should still not be a new record
176+
expect(record.isNewRecord, isFalse);
177+
expect(record.systemId, systemId);
178+
});
179+
180+
test('isNewRecord getter provides correct state', () async {
181+
// New record
182+
final newRecord = GenericDbRecord({
183+
'name': 'Frank',
184+
'age': 40,
185+
}, 'users', database);
186+
expect(newRecord.isNewRecord, isTrue);
187+
188+
// Existing record
189+
final systemId = await database.insert('users', {
190+
'name': 'Grace',
191+
'age': 29,
192+
});
193+
final results = await database.queryTable('users',
194+
where: 'system_id = ?',
195+
whereArgs: [systemId]
196+
);
197+
final existingRecord = GenericDbRecord(results[0], 'users', database);
198+
expect(existingRecord.isNewRecord, isFalse);
199+
});
200+
});
201+
}
202+
203+
/// A generic DbRecord implementation for testing
204+
class GenericDbRecord extends DbRecord {
205+
GenericDbRecord(
206+
Map<String, Object?> data,
207+
String tableName,
208+
DeclarativeDatabase database,
209+
) : super(data, tableName, database);
210+
}

0 commit comments

Comments
 (0)