Skip to content
131 changes: 130 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
## Containers

This package is a collection of various container-like data structures. Currently only `SeqDict` and `SeqSet` are included.
This package is a collection of various container-like data structures including `SeqDict`, `SeqSet`, and bidirectional/multi-value dictionaries.

Install using `lamdera install lamdera/containers`.

Expand All @@ -19,6 +19,135 @@ For example insertions are `O(log(n))` rather than `O(n)` and fromList is `O(n *
<sup>*Non-equatable Elm values are currently: functions, `Bytes`, `Html`, `Json.Value`, `Task`, `Cmd`, `Sub`, `Never`, `Texture`, `Shader`, and any datastructures containing these types.</sup>


## BiSeqDict, MultiSeqDict, and MultiBiSeqDict (bidirectional and multi-value dictionaries)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One general thing I felt reading through as a whole was even after fully understanding what's going on, reading BiSeqDict and MultiBiSeqDict and MultiSeqDict I kept thinking "wait hold on which one is that?", scrolling back to the titles below to see the BiSeqDict (Many-to-One) and going "Ah right yes, this is the many-to-one one.

That just kept happening over and over... 😅

So what if instead, we names them the thing they are?

  • ManyToOne
  • OneToMany
  • ManyToMany

I'm not 100% a fan of this naming in general, but after redoing the examples with this naming at least to me reads a lot nicer and helps keep clarity in my head of what's going on, so I think it is a step better naming than the SeqDict versions:

ManyToOne (formerly BiSeqDict)

import ManyToOne exposing (ManyToOne)

type UserId = UserId Never
type WorkspaceId = WorkspaceId Never

userWorkspaces : ManyToOne (Id UserId) (Id WorkspaceId)
userWorkspaces =
    ManyToOne.empty
        |> ManyToOne.insert aliceId workspace1
        |> ManyToOne.insert bobId workspace1
        |> ManyToOne.insert charlieId workspace2

-- Forward lookup
ManyToOne.get aliceId userWorkspaces
--> Just workspace1

-- Reverse lookup (renamed API)
ManyToOne.getKeys workspace1 userWorkspaces
--> SeqSet.fromList [ aliceId, bobId ]

OneToMany (formerly MultiSeqDict)

import OneToMany exposing (OneToMany)

type PropertyId = PropertyId Never
type UnitId = UnitId Never

propertyUnits : OneToMany (Id PropertyId) (Id UnitId)
propertyUnits =
    OneToMany.empty
        |> OneToMany.insert property1 unit101
        |> OneToMany.insert property1 unit102
        |> OneToMany.insert property2 unit201

-- Forward lookup
OneToMany.getAll property1 propertyUnits
--> SeqSet.fromList [ unit101, unit102 ]

-- Reverse lookup (renamed API)
OneToMany.getKeys unit101 propertyUnits
--> SeqSet.fromList [ property1 ]

-- Remove a specific association
OneToMany.remove property1 unit102 propertyUnits

ManyToMany (formerly MultiBiSeqDict)

import ManyToMany exposing (ManyToMany)

type ChatId = ChatId Never
type DocumentId = DocumentId Never

chatDocuments : ManyToMany (Id ChatId) (Id DocumentId)
chatDocuments =
    ManyToMany.empty
        |> ManyToMany.insert chat1 doc1
        |> ManyToMany.insert chat1 doc2
        |> ManyToMany.insert chat2 doc1

-- Forward lookup
ManyToMany.getAll chat1 chatDocuments
--> SeqSet.fromList [ doc1, doc2 ]

-- Reverse lookup (renamed API)
ManyToMany.getKeys doc1 chatDocuments
--> SeqSet.fromList [ chat1, chat2 ]

-- Transfer
chatDocuments
    |> ManyToMany.remove chat1 doc2
    |> ManyToMany.insert chat3 doc2

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this naming because it naturally fits in with OneToOne.elm module I'd like to add

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this. Will wait for team consensus on exact names


These data structures extend the capabilities of `SeqDict` to handle more complex relationships:

### BiSeqDict (Many-to-One)

`BiSeqDict` is a bidirectional dictionary that maintains a reverse mapping from values back to keys. This is useful when:
- Multiple keys can map to the same value
- You need efficient lookups in both directions
- You want to find all keys associated with a particular value

**Example with opaque types:**
```elm
import BiSeqDict exposing (BiSeqDict)

-- Opaque ID types (not comparable!)
type UserId = UserId Never
type WorkspaceId = WorkspaceId Never

-- Multiple users can belong to the same workspace
userWorkspaces : BiSeqDict (Id UserId) (Id WorkspaceId)
userWorkspaces =
BiSeqDict.empty
|> BiSeqDict.insert aliceId workspace1
|> BiSeqDict.insert bobId workspace1
|> BiSeqDict.insert charlieId workspace2

-- Forward lookup: What workspace does alice belong to?
BiSeqDict.get aliceId userWorkspaces
--> Just workspace1

-- Reverse lookup: Who are all members of workspace1?
BiSeqDict.getReverse workspace1 userWorkspaces
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps BiSeqDict.getKeys workspace1 userWorkspaces would be clearer?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. Though still reads a bit awkward with the collection name - "get the keys for doc1 chat documents"... open to other ideas

--> SeqSet.fromList [aliceId, bobId]
```

**Note:** This works with opaque ID types that aren't `comparable` - you couldn't do this with regular `Dict`!

**Performance:** O(log n) for both forward and reverse lookups.

### MultiSeqDict (One-to-Many)

`MultiSeqDict` allows one key to map to multiple values. This is useful when:
- A single key naturally has multiple associated values
- You want to maintain a collection of values per key
- You need set semantics (no duplicate values per key)

**Example with opaque types:**
```elm
import MultiSeqDict exposing (MultiSeqDict)

type PropertyId = PropertyId Never
type UnitId = UnitId Never

-- A property can have multiple units
propertyUnits : MultiSeqDict (Id PropertyId) (Id UnitId)
propertyUnits =
MultiSeqDict.empty
|> MultiSeqDict.insert property1 unit101
|> MultiSeqDict.insert property1 unit102
|> MultiSeqDict.insert property2 unit201

-- Get all units for property1
MultiSeqDict.get property1 propertyUnits
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
MultiSeqDict.get property1 propertyUnits
MultiSeqDict.getAll property1 propertyUnits

A little torn on this one. .get might be what you reach for naturally when writing code.

But .getAll will be much more helpful and clear when reading code – all other .get are singular in other collections.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed 👍

--> SeqSet.fromList [unit101, unit102]

-- Remove a specific unit
MultiSeqDict.remove property1 unit102 propertyUnits
```

**Performance:** O(log n) for lookups and insertions.

### MultiBiSeqDict (Many-to-Many)

`MultiBiSeqDict` combines both features: multiple values per key AND efficient reverse lookups. This is useful when:
- You have a many-to-many relationship
- You need lookups in both directions
- Each key can have multiple values and each value can be associated with multiple keys

**Real-world example: Documents can belong to multiple chats**
```elm
import MultiBiSeqDict exposing (MultiBiSeqDict)

type ChatId = ChatId Never
type DocumentId = DocumentId Never

-- Documents can be shared across multiple chats
-- Chats can have multiple documents
-- Documents can be transferred between chats
chatDocuments : MultiBiSeqDict (Id ChatId) (Id DocumentId)
chatDocuments =
MultiBiSeqDict.empty
|> MultiBiSeqDict.insert chat1 doc1
|> MultiBiSeqDict.insert chat1 doc2
|> MultiBiSeqDict.insert chat2 doc1 -- doc1 is shared!

-- What documents are in chat1?
MultiBiSeqDict.get chat1 chatDocuments
--> SeqSet.fromList [doc1, doc2]

-- Which chats contain doc1?
MultiBiSeqDict.getReverse doc1 chatDocuments
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, consideration for change to both .get -> .getAll and .getReverse -> .getKeys.

But when reading through the example code, the insertion/removal reads beautifully, but the retrieval reads clunkily, my brain is juggling which direction things are going.

MultiBiSeqDict.getAll chat1 chatDocuments

Reads to me as "get all chat1 documents" -> not bad.

MultiBiSeqDict.getReverse doc1 chatDocuments
MultiBiSeqDict.getKeys doc1 chatDocuments

Reads to me as "get the reverse document chat documents?"
Or "get the keys for doc1 chat documents...?"

Feel like there's an opportunity for something much clearer here. Will think a bit more but ideas welcome.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same - open to ideas if something reads better

--> SeqSet.fromList [chat1, chat2]

-- Transfer doc2 from chat1 to chat3
chatDocuments
|> MultiBiSeqDict.remove chat1 doc2
|> MultiBiSeqDict.insert chat3 doc2
```

**Why this is better than regular Dict:**
- ✅ Works with opaque ID types (not `comparable`)
- ✅ O(log n) queries in both directions
- ✅ Automatic consistency when transferring documents
- ❌ Regular `Dict` would require manual index maintenance and comparable keys

**Performance:** O(log n) for lookups in both directions.

### Key Features

All three types:
- ✅ Work with any equatable types (no `comparable` constraint)
- ✅ Preserve insertion order
- ✅ Provide O(log n) performance for core operations
- ✅ Automatically maintain consistency (removing a key updates all related mappings)

**Wire3 Support:** Full Lamdera Wire3 codec support is included for all three types (`encodeBiSeqDict`, `encodeMultiSeqDict`, `encodeMultiBiSeqDict`), allowing them to be used directly in your Lamdera `BackendModel`, `FrontendModel`, and messages.


## Comparison to other Elm packages

See miniBill's [comparison of Elm Dict implementations](https://docs.google.com/spreadsheets/d/1j2rHUx5Nf5auvg5ikzYxbW4e1M9g0-hgU8nMogLD4EY) for a meta-analysis of implementation and performance characteristics.
Expand Down
5 changes: 4 additions & 1 deletion elm.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
"version": "1.0.0",
"exposed-modules": [
"SeqDict",
"SeqSet"
"SeqSet",
"BiSeqDict",
"MultiSeqDict",
"MultiBiSeqDict"
],
"elm-version": "0.19.0 <= v < 0.20.0",
"dependencies": {
Expand Down
Loading