Chapter 9: Building Muninn, the note store
This chapter:
- Split a growing project across several files in one
package main. - Design the
Notestruct and aStoreto hold them. - Use a
[dynamic]Noteproperly:append, and removing by index. - Write
add_note,find_note,delete_note,list_notesandsearch_notes. - Exercise the store from a temporary
main, with no menu yet.
Where we left off: Chapters 1 through 8 were each a standalone program. From here, everything starts converging on one project: Muninn, named for one of Odin's ravens (memory). This chapter builds the part it can't do without: somewhere to actually keep the notes.
9.1 From one file to several
Every program so far has fit in one .odin file. Muninn won't, not because Odin requires splitting a project across files (a package compiles as one unit regardless of how many files it's spread across), but because a note store, a menu, a screen layer and a persistence layer are different concerns, and keeping each in its own file makes the project easier to hold in your head as it grows. From here on, work happens directly in a project directory rather than a single file:
odin-and-me/
โโโ note.odin # the Note struct
โโโ store.odin # the Store and its operations
โโโ main.odin # (temporary, this chapter) exercises the store
odin check . # type-checks the whole directory as one package
odin run . # compiles and runs it
No more -file flag from here: that was always for treating one lone file as a stand-in package; a real directory of .odin files needs neither.
9.2 The Note struct
// note.odin
package main
Note :: struct {
id: int,
title: string,
body: string,
created_at: int, // an incrementing counter, not a real timestamp
}
created_at is deliberately not a real timestamp: Odin's standard library can give you one, but a note-keeper you're building to learn the language doesn't need to reach for date/time handling just to answer "which notes came first". A plain incrementing counter (which, as you'll see in a moment, ends up being the same number as id) answers that honestly enough.
9.3 Dynamic arrays, properly
Chapter 8's notes_v1.odin already used a [dynamic]Note in passing. Muninn's store leans on it properly: append to add, and one of two removal procedures depending on whether order matters.
notes: [dynamic]Note
append(¬es, Note{title = "Shopping", body = "Milk and eggs"})
// removing by index:
ordered_remove(¬es, 0) // shifts everything after index 0 down by one -- keeps order
unordered_remove(¬es, 0) // swaps the last element into index 0 -- faster, order not kept
Muninn's delete_note (ยง9.4) uses unordered_remove: nothing we build here ever relies on notes staying in the order they were added (list_notes just walks whatever order the store happens to hold them in), so there's no reason to pay for ordered_remove's extra shifting.
9.4 The store and its operations
Store bundles the notes with the next id to hand out, so both travel together as one thing:
// store.odin
package main
import "core:strings"
Store :: struct {
notes: [dynamic]Note,
next_id: int,
}
add_note :: proc(store: ^Store, title, body: string) -> int {
store.next_id += 1
id := store.next_id
append(&store.notes, Note{
id = id,
title = strings.clone(title),
body = strings.clone(body),
created_at = id,
})
return id
}
find_note :: proc(store: ^Store, id: int) -> (Note, bool) {
for n in store.notes {
if n.id == id {
return n, true
}
}
return {}, false
}
delete_note :: proc(store: ^Store, id: int) -> bool {
for n, i in store.notes {
if n.id == id {
unordered_remove(&store.notes, i)
return true
}
}
return false
}
list_notes :: proc(store: ^Store) -> []Note {
return store.notes[:]
}
add_note takes a ^Store (Chapter 6's pointer-to-struct pattern again, so it can bump next_id and grow store.notes in place) and clones title/body for the same reason Chapter 8's decode_note did: the strings the caller hands in might not outlive the call, so the store needs its own copies. find_note and delete_note are both a linear search over store.notes: for a personal note collection of a few hundred entries at most, scanning every one of them is instant.
9.5 Searching by title
One more operation, search_notes, does a case-insensitive substring search over titles: the piece Chapter 10's search menu item will call directly:
search_notes :: proc(store: ^Store, query: string, allocator := context.allocator) -> [dynamic]Note {
results: [dynamic]Note
results.allocator = allocator
lower_query := strings.to_lower(query)
defer delete(lower_query)
for n in store.notes {
lower_title := strings.to_lower(n.title)
defer delete(lower_title)
if strings.contains(lower_title, lower_query) {
append(&results, n)
}
}
return results
}
Lower-casing both sides before comparing means a search for "sho" matches a note titled "Shopping"; without that, strings.contains is case-sensitive and the search would feel needlessly fussy. search_notes returns a fresh [dynamic]Note, a list of copies of the matching notes rather than pointers into the store, good enough for showing search results, and it means the caller doesn't have to worry about the store changing underneath the list it's holding.
A map for fast lookup?
find_note's linear search could be amap[int]Notelookup instead, and for a note collection running into the tens of thousands that would matter. We keep our note count in the dozens, where linear search is imperceptibly fast. Reaching for a map here would be solving a performance problem this program doesn't have, at the cost of a container that's genuinely fiddlier to keep in sync withnext_idand withsearch_notes' need to walk every note anyway. Know the map option exists; don't reach for it until scanning a slice actually becomes slow enough to notice.
9.6 Exercising the store
No menu yet: that's Chapter 10. For now, a temporary main proves the store works:
// main.odin (temporary, Chapter 10 replaces this)
package main
import "core:fmt"
main :: proc() {
store: Store
id1 := add_note(&store, "Shopping", "Milk and eggs")
id2 := add_note(&store, "Todo", "Write chapter 9")
for n in list_notes(&store) {
fmt.printfln("%d. %s -- %s", n.id, n.title, n.body)
}
fmt.println("---")
found, ok := find_note(&store, id2)
fmt.println(ok, found.title)
fmt.println("---")
deleted := delete_note(&store, id2)
fmt.println("deleted:", deleted)
for n in list_notes(&store) {
fmt.printfln("%d. %s -- %s", n.id, n.title, n.body)
}
fmt.println("--- search ---")
add_note(&store, "Groceries", "Bread and butter")
results := search_notes(&store, "sho")
defer delete(results)
for n in results {
fmt.printfln("%d. %s", n.id, n.title)
}
}
$ odin run .
1. Shopping -- Milk and eggs
2. Todo -- Write chapter 9
---
true Todo
---
deleted: true
1. Shopping -- Milk and eggs
--- search ---
1. Shopping
id1 is deliberately unused past being printed in the first list: it exists so add_note's return value has a name in this example, the same way you'd actually use it once Chapter 10's menu reports "Added note 3" back to the user.
Checkpoint
The store compiles and does everything a note store needs to: add, find, delete, list, and search by title, all exercised from a temporary main that adds a few notes and checks each operation against what you'd expect. No menu, no colour, no persistence yet: those arrive in Chapter 10.
Exercises
- Add an
update_noteprocedure:update_note(store: ^Store, id: int, title, body: string) -> bool, which finds the note by id and replaces its title and body (returningfalseif no note has that id). Remember tostrings.clonethe new title/body, same asadd_notedoes. delete_noteusesunordered_remove. Change it toordered_removeinstead, add three notes, delete the first one, and list the rest. What's different about the resulting order compared tounordered_remove? Which behaviour would you actually want for a note-keeper a person is using, and does it matter?search_notesonly searches titles. Extend it (or add a second procedure,search_notes_full) to also match against a note's body, still case-insensitively. Watch out for a note matching the query in both title and body ending up in the results list twice.- What happens if you call
find_noteordelete_notewith anidof0, which no real note will ever have (sincenext_idstarts at0and is incremented before being used)? Confirm both behave sensibly without you having to add a special case for it, and why don't they need one? Store'snext_idonly ever goes up. Sketch (in words) what would go wrong forfind_noteanddelete_noteifadd_noteinstead reused the smallest deleted id, the way some databases do to avoid "wasting" numbers. Why does my simpler "always increase" rule sidestep that problem entirely?
Further reading
- Odin, dynamic arrays: https://odin-lang.org/docs/overview/#dynamic-arrays
- Odin,
core:slice: https://pkg.odin-lang.org/core/slice/ - Odin, maps: https://odin-lang.org/docs/overview/#maps