Odin and Me ยท Chapter 9

Chapter 9: Building Muninn, the note store

This chapter:

  • Split a growing project across several files in one package main.
  • Design the Note struct and a Store to hold them.
  • Use a [dynamic]Note properly: append, and removing by index.
  • Write add_note, find_note, delete_note, list_notes and search_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(&notes, Note{title = "Shopping", body = "Milk and eggs"})

// removing by index:
ordered_remove(&notes, 0)    // shifts everything after index 0 down by one -- keeps order
unordered_remove(&notes, 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 a map[int]Note lookup 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 with next_id and with search_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

  1. Add an update_note procedure: update_note(store: ^Store, id: int, title, body: string) -> bool, which finds the note by id and replaces its title and body (returning false if no note has that id). Remember to strings.clone the new title/body, same as add_note does.
  2. delete_note uses unordered_remove. Change it to ordered_remove instead, add three notes, delete the first one, and list the rest. What's different about the resulting order compared to unordered_remove? Which behaviour would you actually want for a note-keeper a person is using, and does it matter?
  3. search_notes only 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.
  4. What happens if you call find_note or delete_note with an id of 0, which no real note will ever have (since next_id starts at 0 and 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?
  5. Store's next_id only ever goes up. Sketch (in words) what would go wrong for find_note and delete_note if add_note instead 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