Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/stream_core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## Upcoming

### ✨ Features

- Added `insertAt` parameter to `upsert` for controlling insertion position of new elements

## 0.3.1

### ✨ Features
Expand Down
21 changes: 18 additions & 3 deletions packages/stream_core/lib/src/utils/list_extensions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ extension ListExtensions<T extends Object> on List<T> {
/// Inserts or replaces an element in the list based on a key.
///
/// If an element with the same key already exists, it will be replaced.
/// Otherwise, the new element will be appended to the end of the list.
/// Otherwise, the new element will be inserted at the position determined by
/// [insertAt] (defaults to appending at the end).
/// Time complexity: O(n) for search, O(n) for list creation.
///
/// ```dart
Expand All @@ -64,23 +65,37 @@ extension ListExtensions<T extends Object> on List<T> {
/// );
/// // Result: [User(id: '1', name: 'Alice Updated'), User(id: '2', name: 'Bob')]
///
/// // Adding new element
/// // Adding new element (appends to end)
/// final withNew = users.upsert(
/// User(id: '3', name: 'Charlie'),
/// key: (user) => user.id,
/// );
/// // Result: [User(id: '1', name: 'Alice'), User(id: '2', name: 'Bob'), User(id: '3', name: 'Charlie')]
///
/// // Insert at specific position
/// final inserted = users.upsert(
/// User(id: '3', name: 'Charlie'),
/// key: (user) => user.id,
/// insertAt: (list) => 0, // Insert at beginning
/// );
/// // Result: [User(id: '3', name: 'Charlie'), User(id: '1', name: 'Alice'), User(id: '2', name: 'Bob')]
/// ```
List<T> upsert<K>(
T element, {
required K Function(T item) key,
int Function(List<T> list)? insertAt,
T Function(T original, T updated)? update,
}) {
final elementKey = key(element);
final index = indexWhere((e) => key(e) == elementKey);

// Add the element if it does not exist
if (index == -1) return [...this, element];
if (index == -1) {
final insertionIndex = insertAt?.call(this) ?? length;
// Clamp index to valid range [0, length]
final validIndex = insertionIndex.clamp(0, length);
return [...this].apply((it) => it.insert(validIndex, element));
}

T handleUpdate(T original, T updated) {
if (update != null) return update(original, updated);
Expand Down
118 changes: 118 additions & 0 deletions packages/stream_core/test/query/list_extensions_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,124 @@ void main() {
expect(result.first.content, 'Hello Updated');
expect(result.last.content, 'World');
});

test('should insert at beginning when insertAt returns 0', () {
final users = [
const _TestUser(id: '1', name: 'Alice'),
const _TestUser(id: '2', name: 'Bob'),
];

final result = users.upsert(
const _TestUser(id: '3', name: 'Charlie'),
key: (user) => user.id,
insertAt: (list) => 0,
);

expect(result.length, 3);
expect(result[0].name, 'Charlie');
expect(result[1].name, 'Alice');
expect(result[2].name, 'Bob');
});

test('should insert at middle position when insertAt specifies', () {
final users = [
const _TestUser(id: '1', name: 'Alice'),
const _TestUser(id: '2', name: 'Bob'),
const _TestUser(id: '3', name: 'Charlie'),
];

final result = users.upsert(
const _TestUser(id: '4', name: 'David'),
key: (user) => user.id,
insertAt: (list) => 1,
);

expect(result.length, 4);
expect(result[0].name, 'Alice');
expect(result[1].name, 'David');
expect(result[2].name, 'Bob');
expect(result[3].name, 'Charlie');
});

test('should clamp insertAt index to valid range', () {
final users = [
const _TestUser(id: '1', name: 'Alice'),
const _TestUser(id: '2', name: 'Bob'),
];

// Index too large
final resultLarge = users.upsert(
const _TestUser(id: '3', name: 'Charlie'),
key: (user) => user.id,
insertAt: (list) => 100, // Way beyond list length
);

expect(resultLarge.length, 3);
expect(resultLarge.last.name, 'Charlie'); // Should be clamped to end

// Negative index
final resultNegative = users.upsert(
const _TestUser(id: '4', name: 'David'),
key: (user) => user.id,
insertAt: (list) => -5,
);

expect(resultNegative.length, 3);
expect(
resultNegative.first.name,
'David',
); // Should be clamped to start
});

test('should use insertAt with list information', () {
final scores = [
const _TestScore(userId: 1, points: 100),
const _TestScore(userId: 2, points: 200),
const _TestScore(userId: 3, points: 150),
];

// Insert at position based on list length
final result = scores.upsert(
const _TestScore(userId: 4, points: 175),
key: (score) => score.userId,
insertAt: (list) => list.length ~/ 2, // Insert at middle
);

expect(result.length, 4);
expect(result[1].userId, 4); // Inserted at index 1 (3 ~/ 2 = 1)
});

test('should not use insertAt when replacing existing element', () {
final users = [
const _TestUser(id: '1', name: 'Alice'),
const _TestUser(id: '2', name: 'Bob'),
const _TestUser(id: '3', name: 'Charlie'),
];

final result = users.upsert(
const _TestUser(id: '2', name: 'Bob Updated'),
key: (user) => user.id,
insertAt: (list) => 0, // Should be ignored when replacing
);

expect(result.length, 3);
expect(result[0].name, 'Alice');
expect(result[1].name, 'Bob Updated'); // Replaced in place
expect(result[2].name, 'Charlie');
});

test('should work with insertAt on empty list', () {
final users = <_TestUser>[];

final result = users.upsert(
const _TestUser(id: '1', name: 'Alice'),
key: (user) => user.id,
insertAt: (list) => 0,
);

expect(result.length, 1);
expect(result.first.name, 'Alice');
});
});

group('updateWhere', () {
Expand Down