groups: a name is optional, and an id is minted
The empty string slipped past NOT NULL and badged a row with air;
now NULL is the one way to be nameless, and the id says who lends there.
Four letters, k to z — the alphabet jj mints its own ids inside;
all three tables rebuilt, since a dropped parent cascades the children aside.
Assisted-by: Claude Opus 5 via Claude Code
diff --git a/db/migrations/005_group_identity.rb b/db/migrations/005_group_identity.rb
new file mode 100644
index 0000000..182455b
--- /dev/null
+++ b/db/migrations/005_group_identity.rb
@@ -0,0 +1,82 @@
+# Group identity: a name a group may not have, and an id in the shape
+# jj spells a change id.
+#
+# The name is the author's label, and 004 made it required — which left
+# the empty string in as a second spelling of nameless, one that reads
+# on a member's page as an empty badge. Nullable is the honest column:
+# NULL is a group with no name, '' is refused, and a nameless group is
+# displayed by its id instead (Store#inherited_rows composes the
+# fallback into the read).
+#
+# Which makes the id something a person reads, so it is minted rather
+# than chosen: four reverse-hex letters, k through z, the alphabet jj
+# renders a change id in so that an id can never be mistaken for a hash
+# (Store#next_group_id). GLOB rather than LIKE, because GLOB takes the
+# character ranges this shape is written as and because its case
+# sensitivity is its own rather than a per-connection pragma's — 004
+# records the second reason at length. `glob(X, Y)` is `Y GLOB X`:
+# pattern first, SQLite's argument order for the function form.
+#
+# Both are CHECK constraints, and SQLite cannot alter one, so the table
+# is rebuilt the way 003 and 004 rebuilt changes. Unlike changes,
+# groups is referenced: with foreign keys on, DROP TABLE runs an
+# implicit DELETE FROM first, and the members and the properties would
+# cascade away behind it. So all three tables are rebuilt together —
+# created beside the old ones, rows copied, the children dropped before
+# the parent, then renamed into place, which is also what points the
+# children's foreign keys back at `groups`. Turning foreign keys off
+# around a single-table rebuild is not available here: that pragma is a
+# no-op inside a transaction, and a migration is one.
+#
+# A row whose id is not four letters in k-z stops the migration rather
+# than being rewritten into something that fits. Nothing public writes
+# these tables, so such a row was seeded by hand, and choosing its new
+# id is the author's call and not this file's.
+#
+# The shareable-line constraint is restated rather than referenced —
+# a migration is standalone, and 004 holds the reasoning for which
+# names it admits. `text: true` and locals over constants, for the
+# reasons 001 and 004 record.
+folded = Sequel.function(:upper, :line)
+shareable = %w[ADR NOTE].flat_map { |name|
+ [Sequel.like(folded, "#{name}:%"), Sequel.like(folded, "#{name};%")]
+}
+
+Sequel.migration do
+ change do
+ create_table(:groups_new, strict: true) do
+ String :id, text: true, primary_key: true, null: false
+ String :name, text: true
+ constraint(:id_is_a_change_id, Sequel.function(:glob, "[k-z][k-z][k-z][k-z]", :id))
+ constraint(:name_is_absent_or_given, Sequel.|({name: nil}, Sequel.~(name: "")))
+ end
+
+ create_table(:group_properties_new, strict: true) do
+ foreign_key :group_id, :groups_new, type: String, text: true, null: false, on_delete: :cascade
+ Integer :position, null: false
+ String :line, text: true, null: false
+ primary_key [:group_id, :position]
+ constraint(:line_is_shareable, Sequel.|(*shareable))
+ end
+
+ create_table(:group_members_new, strict: true) do
+ foreign_key :group_id, :groups_new, type: String, text: true, null: false, on_delete: :cascade
+ foreign_key :card_id, :cards, type: String, text: true, null: false, on_delete: :cascade
+ primary_key [:group_id, :card_id]
+ end
+
+ run "INSERT INTO groups_new (id, name) SELECT id, name FROM groups"
+ run "INSERT INTO group_properties_new (group_id, position, line) " \
+ "SELECT group_id, position, line FROM group_properties"
+ run "INSERT INTO group_members_new (group_id, card_id) " \
+ "SELECT group_id, card_id FROM group_members"
+
+ drop_table(:group_properties)
+ drop_table(:group_members)
+ drop_table(:groups)
+
+ rename_table(:groups_new, :groups)
+ rename_table(:group_properties_new, :group_properties)
+ rename_table(:group_members_new, :group_members)
+ end
+end
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 37144f2..0e3fe98 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -82,11 +82,12 @@ module ProTacts
# @rbs skip
Photo = Data.define(:mime_type, :bytes)
- # One line a group lends this contact, and the name of the group
- # that lends it. The name rather than the group itself, because
- # the name is the whole of what a card shows about it today and
- # nothing here can navigate to a record that has no screen yet
- # (docs/DESIGN.md, "Relationships are navigable").
+ # One line a group lends this contact, and what to call the group
+ # that lends it: its name, or its id where it has no name
+ # (Store#inherited_rows). A label rather than the group itself,
+ # because the label is the whole of what a card shows about it
+ # today and nothing here can navigate to a record that has no
+ # screen yet (docs/DESIGN.md, "Relationships are navigable").
# @rbs skip
Inherited = Data.define(:group, :line)
# @rbs skip
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index d4d2188..8e7a514 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -1,5 +1,6 @@
require "date"
require "pathname"
+require "securerandom"
require "sequel"
require "sentry-ruby"
@@ -77,6 +78,19 @@ module ProTacts
# ordinary, and SQLITE_BUSY straight back to the client is not.
BUSY_TIMEOUT = 5_000 #: Integer
+ # Hex in the letters jj renders a change id with: 0 is z, f is k,
+ # and every digit lands in k-z, so an id is never a hash and never
+ # a word its author meant. `tr` maps the alphabets in one pass, in
+ # the order this pair is written.
+ REVERSE_HEX = "zyxwvutsrqponmlk" #: String
+
+ # How many ids a create draws before giving up. Four letters is one
+ # of 65,536 and an address book holds groups by the dozen, so a
+ # first collision is already unlucky and a run of eight is a broken
+ # generator rather than a run of bad draws — better to say so than
+ # to spin.
+ GROUP_ID_ATTEMPTS = 8 #: Integer
+
# Sequel's migrations, run on open. They ship with the code rather
# than with a deployment, so the path is relative to this file.
# `__dir__` is nil only for code with no file behind it, which a
@@ -335,6 +349,31 @@ module ProTacts
end
end
+ # Creates a group and hands back the id it was given. The name is
+ # the author's label and optional; a group without one is displayed
+ # by its id (see #inherited_rows), and the empty string is refused
+ # by the schema rather than kept as a second spelling of nameless.
+ #
+ # The id is minted here rather than taken from the caller, for the
+ # shape db/migrations/005_group_identity.rb pins. The insert is
+ # what settles a collision, not a read before it: two creates
+ # drawing at once would both find the same id free, so a duplicate
+ # comes back as the primary key's own violation and the next
+ # attempt draws again.
+ #: (?name: String?) -> String
+ def create_group(name: nil)
+ GROUP_ID_ATTEMPTS.times do
+ id = next_group_id
+ begin
+ groups.insert(id:, name:)
+ return id
+ rescue Sequel::UniqueConstraintViolation
+ next
+ end
+ end
+ raise "no free group id in #{GROUP_ID_ATTEMPTS} draws: #{groups.count} groups already"
+ end
+
# The change log from a sequence number on, oldest first: the window
# a sync-collection report answers from (RFC 6578 section 3.2) — the
# entries after the client's token are the changes it has not seen.
@@ -387,11 +426,25 @@ module ProTacts
@database[:birthdays]
end
+ #: () -> Sequel::Dataset
+ def groups
+ @database[:groups]
+ end
+
#: () -> Sequel::Dataset
def group_members
@database[:group_members]
end
+ # Four hex digits spelled in REVERSE_HEX. SecureRandom rather than
+ # rand: the draws have to be independent of anything a caller can
+ # observe or seed, and two bytes of it is exactly the four digits
+ # the id is wide.
+ #: () -> String
+ def next_group_id
+ SecureRandom.hex(2).tr("0-9a-f", REVERSE_HEX)
+ end
+
#: (String card_id, String action, String? etag) -> void
def record(card_id, action, etag)
change_log.insert(card_id:, action:, etag:)
@@ -594,6 +647,12 @@ module ProTacts
# share. Ordered by the group's id rather than its name, because a
# rename must not move a member's lines and change every etag in
# the group.
+ #
+ # A group with no name is lent under its id instead, coalesced here
+ # rather than at the surfaces: a mark on an inherited row names the
+ # group it came from, and a group with no name still has to be
+ # named as some one group among several
+ # (db/migrations/005_group_identity.rb).
#: () -> Sequel::Dataset
def inherited_rows
group_members
@@ -601,7 +660,7 @@ module ProTacts
.join(:groups, id: Sequel[:group_members][:group_id])
.select(
Sequel[:group_members][:card_id],
- Sequel[:groups][:name].as(:group_name),
+ Sequel.function(:coalesce, Sequel[:groups][:name], Sequel[:groups][:id]).as(:group_name),
Sequel[:group_properties][:line],
)
.order(
diff --git a/sig/gems/sequel.rbs b/sig/gems/sequel.rbs
index c3f102e..01356c7 100644
--- a/sig/gems/sequel.rbs
+++ b/sig/gems/sequel.rbs
@@ -32,6 +32,10 @@ module Sequel
# "recently updated" reads sort by.
def self.desc: (Symbol name) -> untyped
+ # A SQL function call: `coalesce` over a group's name and its id, in
+ # the read that lends an unnamed group's lines under something.
+ def self.function: (Symbol name, *untyped arguments) -> untyped
+
class Database
def []: (Symbol table) -> Dataset
@@ -155,4 +159,9 @@ module Sequel
class CheckConstraintViolation < ConstraintViolation
end
+
+ # What an insert onto a taken primary key raises: the answer a group
+ # id's draw waits for rather than asking first.
+ class UniqueConstraintViolation < ConstraintViolation
+ end
end
diff --git a/test/fixture_data.rb b/test/fixture_data.rb
index a0d6025..15c0c49 100644
--- a/test/fixture_data.rb
+++ b/test/fixture_data.rb
@@ -48,31 +48,55 @@ module FixtureData
"NOTE:Gate code 1854. The dog is friendly\\, the goose is not.",
].freeze #: Array[String]
- # The groups the fixture book carries: one household, the three
- # household-* cards in it. Those cards are a family — one surname, a
- # mobile and an email each, and no address or note of their own — so
- # everything the admin UI shows on them in those two rows is the
- # group's, and removing a member visibly takes it away. They sit
- # outside the recorded macOS exchange's hrefs, so the replay keeps
- # serving that session the bytes it saw.
+ # The groups the fixture book carries. The first is a household, the
+ # three household-* cards in it. Those cards are a family — one
+ # surname, a mobile and an email each, and no address or note of
+ # their own — so everything the admin UI shows on them in those two
+ # rows is the group's, and removing a member visibly takes it away.
+ # They sit outside the recorded macOS exchange's hrefs, so the replay
+ # keeps serving that session the bytes it saw.
+ #
+ # The second is the nameless case: every bday-* card in one group
+ # with no name and nothing to lend
+ # (db/migrations/005_group_identity.rb). It moves no member's bytes —
+ # a group that holds nothing composes nothing, so the replay is
+ # untouched by it — and what it is here for is the surfaces that show
+ # a group rather than a card, which have a group with no name to
+ # render as its id (docs/DESIGN.md, "Groups are first-class
+ # records").
#
# Seeded in Ruby rather than a fixture file because the cards are the
# only evidence-shaped fixtures — a group is rows across three
- # tables with no file format of its own. Authoring groups is the
- # admin UI's task; until it arrives nothing public writes these
- # tables, so the rows land the way backdate's do, through the
- # store's own database.
+ # tables with no file format of its own. The group itself is
+ # Store#create_group's, which mints the id no caller gets to choose;
+ # its properties and members have no write path yet — authoring is
+ # the admin UI's task — so those rows land the way backdate's do,
+ # through the store's own database.
MEMBERS = %w[household-george household-mary household-alicia].freeze #: Array[String]
+ # The seed's birthday shapes, read off the card ids rather than
+ # listed again, so a shape added to test/fixtures/cards joins the
+ # group by being named like its neighbours.
+ #: () -> Array[String]
+ def self.birthday_cards
+ cards.keys.grep(/\Abday-/)
+ end
+
#: (ProTacts::Store store) -> void
def self.seed_groups(store)
database = store.instance_variable_get(:@database)
- database[:groups].insert(id: "household", name: "Boole household")
+
+ household = store.create_group(name: "Boole household")
HOUSEHOLD.each.with_index do |line, position|
- database[:group_properties].insert(group_id: "household", position:, line:)
+ database[:group_properties].insert(group_id: household, position:, line:)
end
MEMBERS.each do |id|
- database[:group_members].insert(group_id: "household", card_id: id)
+ database[:group_members].insert(group_id: household, card_id: id)
+ end
+
+ birthdays = store.create_group
+ birthday_cards.each do |id|
+ database[:group_members].insert(group_id: birthdays, card_id: id)
end
end
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 2b428e1..7a59b71 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -46,23 +46,25 @@ class AdminContactsPagesTest < Minitest::Test
end
end
- # A household's lines, and the seeding a group takes: authoring is
- # still the admin UI's task, so the rows land through the store's
- # own database the way FixtureData's do (test/fixture_data.rb).
+ # A household's lines, and the seeding a group takes: a created
+ # group for the row that mints an id, and its properties and members
+ # through the store's own database the way FixtureData's do
+ # (test/fixture_data.rb), authoring being still the admin UI's task.
HOUSEHOLD = [
"ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom",
"NOTE:Gate code 1854.",
].freeze #: Array[String]
- def add_group(store, name:, members:, lines:)
+ def add_group(store, members:, lines:, name: nil)
database = store.instance_variable_get(:@database)
- database[:groups].insert(id: "household", name:)
+ group_id = store.create_group(name:)
lines.each.with_index do |line, position|
- database[:group_properties].insert(group_id: "household", position:, line:)
+ database[:group_properties].insert(group_id:, position:, line:)
end
members.each do |card_id|
- database[:group_members].insert(group_id: "household", card_id:)
+ database[:group_members].insert(group_id:, card_id:)
end
+ group_id
end
def test_index_lists_recently_updated_contacts
@@ -328,6 +330,20 @@ class AdminContactsPagesTest < Minitest::Test
end
end
+ # A group with no name marks its rows with its id, which is what the
+ # mark is for: an empty badge would say a row came from somewhere and
+ # then not say where.
+ def test_show_marks_a_nameless_groups_rows_with_its_id
+ with_contacts({"ada" => ADA}) do |store|
+ id = add_group(store, members: ["ada"], lines: HOUSEHOLD)
+
+ get "/contacts/ada"
+ marks = last_response.body.scan(%r{<span class="badge">([^<]*)</span>})
+
+ assert_equal [[id], [id]], marks
+ end
+ end
+
# A contact in no group is marked nowhere: the badge is provenance,
# not decoration, so a card with nothing lent renders none.
def test_show_marks_nothing_on_a_contact_in_no_group
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index bc35e01..4283fad 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -934,18 +934,21 @@ class StoreTest < Minitest::Test
HOUSEHOLD_ADDRESS = "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom" #: String
HOUSEHOLD_NOTE = "NOTE:Gate code 1854." #: String
- # There is no write path for the group tables yet — authoring is the
- # admin UI's task — so the tests arrange them the way the fixture
- # seeder does, straight through the store's own database.
- def add_group(store, id:, members:, lines:)
+ # A group's own row is Store#create_group's, which is what mints the
+ # id; its properties and its members have no write path yet —
+ # authoring is the admin UI's task — so those land the way the
+ # fixture seeder's do, straight through the store's own database.
+ # Hands back the id, which is the only way a caller learns it.
+ def add_group(store, members:, lines:, name: nil)
db = database(store)
- db[:groups].insert(id:, name: id.capitalize)
+ id = store.create_group(name:)
lines.each.with_index do |line, position|
db[:group_properties].insert(group_id: id, position:, line:)
end
members.each do |card_id|
db[:group_members].insert(group_id: id, card_id:)
end
+ id
end
# A member's served card is the stored one with the group's lines
@@ -959,7 +962,7 @@ class StoreTest < Minitest::Test
)
with_store({"aiden" => born, "znorth" => ZED}) do |store|
- add_group(store, id: "household", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS, HOUSEHOLD_NOTE])
+ add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS, HOUSEHOLD_NOTE])
assert_equal composed, store.contact("aiden").vcard.to_s
assert_equal composed, store.contacts.find { it.id == "aiden" }.vcard.to_s
@@ -972,7 +975,7 @@ class StoreTest < Minitest::Test
def test_a_contact_in_no_group_serves_unchanged_bytes_and_etag
with_store({"aiden" => AIDEN, "znorth" => ZED}) do |store|
before = store.contact("aiden")
- add_group(store, id: "household", members: ["znorth"], lines: [HOUSEHOLD_ADDRESS])
+ add_group(store, members: ["znorth"], lines: [HOUSEHOLD_ADDRESS])
after = store.contact("aiden")
assert_equal AIDEN, after.vcard.to_s
@@ -986,7 +989,7 @@ class StoreTest < Minitest::Test
def test_leaving_a_group_restores_the_stored_bytes
with_store({"aiden" => AIDEN}) do |store|
before = store.contact("aiden").etag
- add_group(store, id: "household", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+ add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
refute_equal before, store.contact("aiden").etag
database(store)[:group_members].where(card_id: "aiden").delete
@@ -997,16 +1000,19 @@ class StoreTest < Minitest::Test
# Two groups' lines compose in group-id order, then position — the
# order is a fact of the schema, not of whichever join SQLite
- # returns first, so the composed bytes never move between reads.
+ # returns first, so the composed bytes never move between reads. The
+ # ids are minted rather than chosen, so the expectation is sorted by
+ # them: which group leads is the ids' business, that the id decides
+ # it rather than the creation order is this test's.
def test_two_groups_compose_in_a_fixed_order
with_store({"aiden" => AIDEN}) do |store|
- add_group(store, id: "zedly", members: ["aiden"], lines: [HOUSEHOLD_NOTE])
- add_group(store, id: "household", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+ lent = {
+ add_group(store, members: ["aiden"], lines: [HOUSEHOLD_NOTE]) => HOUSEHOLD_NOTE,
+ add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS]) => HOUSEHOLD_ADDRESS,
+ }
- composed = AIDEN.sub(
- "END:VCARD\r\n",
- "#{HOUSEHOLD_ADDRESS}\r\n#{HOUSEHOLD_NOTE}\r\nEND:VCARD\r\n",
- )
+ first, second = lent.sort.map { it.last }
+ composed = AIDEN.sub("END:VCARD\r\n", "#{first}\r\n#{second}\r\nEND:VCARD\r\n")
assert_equal composed, store.contact("aiden").vcard.to_s
end
end
@@ -1016,7 +1022,7 @@ class StoreTest < Minitest::Test
# no index row, and rebuilding the index leaves it that way.
def test_the_index_holds_nothing_the_group_composes_in
with_store({"aiden" => AIDEN}) do |store|
- add_group(store, id: "household", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+ add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
assert_includes store.contact("aiden").vcard.to_s, HOUSEHOLD_ADDRESS
assert_includes indexed_names(store, "aiden"), "FN"
@@ -1033,7 +1039,7 @@ class StoreTest < Minitest::Test
# entry is with_store's own seed put, logged before the group existed.
def test_writes_log_the_composed_etag_for_a_member
with_store({"aiden" => AIDEN}) do |store|
- add_group(store, id: "household", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+ add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
put = store.put("aiden", AIDEN)
edit = store.rewrite("aiden", AIDEN, birthday: nil)
@@ -1049,7 +1055,7 @@ class StoreTest < Minitest::Test
# collection — a screen marks a row as the household's from either.
def test_an_inherited_line_carries_its_groups_name
with_store({"aiden" => AIDEN}) do |store|
- add_group(store, id: "household", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+ add_group(store, name: "Household", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
listed = store.contacts.find { it.id == "aiden" }
[store.contact("aiden"), listed].each do |contact|
@@ -1058,6 +1064,20 @@ class StoreTest < Minitest::Test
end
end
+ # A group with no name lends its lines under its id, on both reads —
+ # a mark that says which group a row came from, where the name would
+ # otherwise leave the row marked with nothing at all.
+ def test_a_nameless_groups_lines_are_marked_with_its_id
+ with_store({"aiden" => AIDEN}) do |store|
+ id = add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+
+ listed = store.contacts.find { it.id == "aiden" }
+ [store.contact("aiden"), listed].each do |contact|
+ assert_equal id, contact.group_of(contact.addresses.fetch(0).line)
+ end
+ end
+ end
+
# The schema is the only gate on what a group may hold until
# authoring exists, so what it admits is worth pinning: an address
# or a note, bare or parameterized, in whatever case the author
@@ -1067,22 +1087,59 @@ class StoreTest < Minitest::Test
def test_a_group_holds_only_addresses_and_notes
with_store({"aiden" => AIDEN}) do |store|
properties = database(store)[:group_properties]
- database(store)[:groups].insert(id: "household", name: "Household")
+ group_id = store.create_group(name: "Household")
admitted = [HOUSEHOLD_ADDRESS, HOUSEHOLD_NOTE, "ADR:;;1 Long Road;;;;", "note:lowercase is the same name"]
admitted.each.with_index do |line, position|
- properties.insert(group_id: "household", position:, line:)
+ properties.insert(group_id:, position:, line:)
end
assert_equal admitted, properties.order(:position).map { it.fetch(:line) }
["TEL;TYPE=home:+44 20 5555 0100", "EMAIL:boole@example.com", "item1.ADR:;;1 Long Road;;;;"].each do |line|
assert_raises(Sequel::ConstraintViolation) do
- properties.insert(group_id: "household", position: admitted.size, line:)
+ properties.insert(group_id:, position: admitted.size, line:)
end
end
end
end
+ # An id is minted, never chosen, in the shape jj spells a change id:
+ # four letters from k to z, drawn independently, so that two creates
+ # in a row are two groups.
+ def test_a_created_group_takes_a_change_id
+ with_store do |store|
+ ids = Array.new(8) { store.create_group }
+
+ ids.each { assert_match(/\A[k-z]{4}\z/, it) }
+ assert_equal ids.size, ids.uniq.size
+ end
+ end
+
+ # The schema is the gate on the id's shape, the way it is the gate on
+ # what a group may hold: nothing else stops a hand-written row from
+ # taking a word, a hash, or a change id's letters in the wrong case.
+ def test_the_schema_refuses_an_id_that_is_not_a_change_id
+ with_store do |store|
+ ["household", "abcd", "kxs", "kxsvv", "KXSV", "kx s"].each do |id|
+ assert_raises(Sequel::ConstraintViolation) do
+ database(store)[:groups].insert(id:, name: "Household")
+ end
+ end
+ end
+ end
+
+ # A group may have no name — NULL, the one spelling of it. The empty
+ # string is the other spelling the column would otherwise admit, and
+ # it renders as a mark with nothing in it, so the schema refuses it.
+ def test_a_group_may_be_nameless_but_not_named_nothing
+ with_store do |store|
+ id = store.create_group
+
+ assert_nil database(store)[:groups].where(id:).sole.fetch(:name)
+ assert_raises(Sequel::ConstraintViolation) { store.create_group(name: "") }
+ end
+ end
+
# The fourth action value exists for the fan-out a group edit will
# write; nothing writes it yet, and this pins that the log can hold
# one when it does.
@@ -1124,6 +1181,38 @@ class StoreTest < Minitest::Test
end
end
+ # The group rebuild carries all three tables, and the hazard it is
+ # written around is this one: dropping the old groups table with
+ # foreign keys on runs an implicit DELETE FROM, and the members and
+ # the properties would cascade away behind it. So the members and
+ # the properties are asserted across the upgrade, and both cascades
+ # asserted still to fire on the far side — a rebuild that forgot to
+ # redeclare them would pass the first half of this alone.
+ def test_the_migration_carries_a_group_with_its_members_and_properties
+ Dir.mktmpdir do |dir|
+ path = Pathname.new(dir) / "contacts.db"
+ Sequel.connect("sqlite://#{path}") do |db|
+ Sequel::Migrator.run(db, ProTacts::Store::MIGRATIONS.to_s, target: 4)
+ db[:cards].insert(id: "aiden", vcard: AIDEN)
+ db[:groups].insert(id: "nous", name: "Boole household")
+ db[:group_properties].insert(group_id: "nous", position: 0, line: HOUSEHOLD_ADDRESS)
+ db[:group_members].insert(group_id: "nous", card_id: "aiden")
+ end
+
+ ProTacts::Store.connect(path) do |store|
+ contact = store.contact("aiden")
+ assert_includes contact.vcard.to_s, HOUSEHOLD_ADDRESS
+ assert_equal "Boole household", contact.group_of(contact.addresses.fetch(0).line)
+
+ database(store)[:cards].where(id: "aiden").delete
+ assert_empty database(store)[:group_members].all
+
+ database(store)[:groups].where(id: "nous").delete
+ assert_empty database(store)[:group_properties].all
+ end
+ end
+ end
+
# A database at the old shape — BDAY in the card, no birthdays table
# — is carried over by the same subtraction a write makes: modeled
# forms move, unmodeled forms stay put.