sqlite.schema 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. /* The SQLite data model: posts live here entire, as the JSON blob they always */
  2. /* were, with everything worth querying projected out of that blob by the */
  3. /* database rather than by Perl. */
  4. /* */
  5. /* Every column but the blob is GENERATED ALWAYS ... VIRTUAL, so it costs no */
  6. /* storage, cannot drift from the post it was drawn from, and can be indexed. */
  7. /* Adding a queryable field later is one ALTER TABLE plus one CREATE INDEX -- */
  8. /* no migration of the data itself, because the data never moves. */
  9. CREATE TABLE IF NOT EXISTS posts (
  10. id INTEGER PRIMARY KEY AUTOINCREMENT,
  11. post_data TEXT NOT NULL,
  12. /* Identity and history. A post is a uuid; a row is one version of it. */
  13. uuid TEXT GENERATED ALWAYS AS (json_extract(post_data, '$.id')) VIRTUAL NOT NULL,
  14. /* Coalesced: add() always sets a version, but a post hand-written without */
  15. /* one is a version 0 post, not a row the database should refuse. */
  16. version INTEGER GENERATED ALWAYS AS (COALESCE(json_extract(post_data, '$.version'), 0)) VIRTUAL NOT NULL,
  17. /* The scalars get() filters on. */
  18. created INTEGER GENERATED ALWAYS AS (json_extract(post_data, '$.created')) VIRTUAL,
  19. title TEXT GENERATED ALWAYS AS (json_extract(post_data, '$.title')) VIRTUAL,
  20. user TEXT GENERATED ALWAYS AS (json_extract(post_data, '$.user')) VIRTUAL,
  21. author TEXT GENERATED ALWAYS AS (json_extract(post_data, '$.author')) VIRTUAL,
  22. form TEXT GENERATED ALWAYS AS (json_extract(post_data, '$.form')) VIRTUAL,
  23. aclname TEXT GENERATED ALWAYS AS (json_extract(post_data, '$.aclname')) VIRTUAL,
  24. visibility TEXT GENERATED ALWAYS AS (json_extract(post_data, '$.visibility')) VIRTUAL,
  25. local_href TEXT GENERATED ALWAYS AS (json_extract(post_data, '$.local_href')) VIRTUAL,
  26. /* Free text. json_extract hands back the string for a scalar and the JSON */
  27. /* for an array, which is what a slide deck or an invoice stores -- either */
  28. /* way the words are in there for the tokenizer to find. */
  29. data TEXT GENERATED ALWAYS AS (json_extract(post_data, '$.data')) VIRTUAL
  30. );
  31. CREATE UNIQUE INDEX IF NOT EXISTS posts_version ON posts(uuid, version);
  32. CREATE INDEX IF NOT EXISTS posts_created ON posts(created);
  33. CREATE INDEX IF NOT EXISTS posts_title ON posts(title);
  34. CREATE INDEX IF NOT EXISTS posts_user ON posts(user);
  35. CREATE INDEX IF NOT EXISTS posts_form ON posts(form);
  36. CREATE INDEX IF NOT EXISTS posts_aclname ON posts(aclname);
  37. CREATE INDEX IF NOT EXISTS posts_visibility ON posts(visibility);
  38. CREATE INDEX IF NOT EXISTS posts_local_href ON posts(local_href);
  39. /* A post is a uuid; a row is one version of it, so nearly every query has to */
  40. /* know which row is the current one. Worked out with a GROUP BY that is one */
  41. /* pass over every post per query -- which is the scan this whole model exists */
  42. /* to stop -- so the triggers keep the answer here instead, one row per post. */
  43. /* */
  44. /* first_created is the post's birthday, as opposed to the date of its most */
  45. /* recent edit. It is what get() hands back as created, what older and newer */
  46. /* filter on, and what the results are ordered by, so it is indexed. */
  47. CREATE TABLE IF NOT EXISTS post_versions (
  48. uuid TEXT PRIMARY KEY,
  49. version_min INTEGER NOT NULL,
  50. version_max INTEGER NOT NULL,
  51. first_id INTEGER NOT NULL,
  52. latest_id INTEGER NOT NULL,
  53. first_created INTEGER
  54. );
  55. CREATE INDEX IF NOT EXISTS post_versions_created ON post_versions(first_created);
  56. CREATE INDEX IF NOT EXISTS post_versions_latest ON post_versions(latest_id);
  57. /* Every trigger below is dropped and recreated rather than created IF NOT */
  58. /* EXISTS. The schema is applied on every connect, and a trigger is code: with */
  59. /* IF NOT EXISTS a database that already had one kept its old copy for good, so */
  60. /* fixing a trigger would have fixed it only for sites that had not run yet. */
  61. /* Versions are only ever appended, so an insert can settle this incrementally. */
  62. /* Every expression in DO UPDATE sees the row as it was before this statement, */
  63. /* which is what makes the comparisons below read the way they do. */
  64. DROP TRIGGER IF EXISTS posts_versions_insert;
  65. CREATE TRIGGER posts_versions_insert AFTER INSERT ON posts BEGIN
  66. INSERT INTO post_versions (uuid, version_min, version_max, first_id, latest_id, first_created)
  67. VALUES (new.uuid, new.version, new.version, new.id, new.id, new.created)
  68. ON CONFLICT(uuid) DO UPDATE SET
  69. version_max = MAX(version_max, new.version),
  70. latest_id = CASE WHEN new.version >= version_max THEN new.id ELSE latest_id END,
  71. version_min = MIN(version_min, new.version),
  72. first_id = CASE WHEN new.version < version_min THEN new.id ELSE first_id END,
  73. first_created = CASE WHEN new.version < version_min THEN new.created ELSE first_created END;
  74. END;
  75. /* Deletes take whole posts rather than single versions, and are rare, so this */
  76. /* recomputes rather than trying to unpick the increments above. */
  77. DROP TRIGGER IF EXISTS posts_versions_delete;
  78. CREATE TRIGGER posts_versions_delete AFTER DELETE ON posts BEGIN
  79. DELETE FROM post_versions WHERE uuid = old.uuid;
  80. INSERT INTO post_versions (uuid, version_min, version_max, first_id, latest_id, first_created)
  81. SELECT old.uuid,
  82. MIN(version),
  83. MAX(version),
  84. (SELECT id FROM posts WHERE uuid = old.uuid ORDER BY version ASC LIMIT 1),
  85. (SELECT id FROM posts WHERE uuid = old.uuid ORDER BY version DESC LIMIT 1),
  86. (SELECT created FROM posts WHERE uuid = old.uuid ORDER BY version ASC LIMIT 1)
  87. FROM posts WHERE uuid = old.uuid
  88. /* GROUP BY so that deleting a post's last version inserts nothing. A */
  89. /* bare aggregate over no rows still returns one row, of NULLs, which */
  90. /* the NOT NULL columns above would reject -- taking the DELETE with it. */
  91. GROUP BY uuid;
  92. END;
  93. /* Tags are an array, and an index over the whole array's JSON text would only */
  94. /* answer "is this the exact same list". Membership needs one row per tag, so */
  95. /* the trigger below unpacks json_each() into here and the database keeps it in */
  96. /* step with the blob on its own. */
  97. /* */
  98. /* Tags only, and deliberately. A post's acls are folded into its tags by */
  99. /* Trog::DataModule::_process(), which then deletes the acls field outright, so */
  100. /* tags is the whole truth about who may see a post -- and get() has always */
  101. /* answered the acls filter by looking at tags. Indexing an acls field here as */
  102. /* well would let a post carrying one be found by a caller holding that acl even */
  103. /* though the flat file model would not have shown it. */
  104. CREATE TABLE IF NOT EXISTS post_tags (
  105. post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
  106. tag TEXT NOT NULL
  107. );
  108. CREATE INDEX IF NOT EXISTS post_tags_tag ON post_tags(tag, post_id);
  109. CREATE INDEX IF NOT EXISTS post_tags_post ON post_tags(post_id);
  110. DROP TRIGGER IF EXISTS posts_tags_insert;
  111. CREATE TRIGGER posts_tags_insert AFTER INSERT ON posts BEGIN
  112. INSERT INTO post_tags (post_id, tag)
  113. SELECT new.id, value FROM json_each(json_extract(new.post_data, '$.tags'))
  114. WHERE json_type(new.post_data, '$.tags') = 'array'
  115. /* A null in the array is not a tag. Trog::DataModule::_process() used */
  116. /* to push a post's visibility into its tags without defaulting it */
  117. /* first, so posts written before that carry a null there -- and a null */
  118. /* tag is one no query ever matches, which is exactly what leaving it */
  119. /* out of the index amounts to. Indexing it instead would refuse to */
  120. /* store the post at all, which is a great deal worse than the */
  121. /* untaggable post it already was. */
  122. AND value IS NOT NULL;
  123. END;
  124. DROP TRIGGER IF EXISTS posts_tags_delete;
  125. CREATE TRIGGER posts_tags_delete AFTER DELETE ON posts BEGIN
  126. DELETE FROM post_tags WHERE post_id = old.id;
  127. END;
  128. /* Full text search for the 'like' filter. */
  129. /* */
  130. /* The trigram tokenizer rather than the default: 'like' has always meant a */
  131. /* case insensitive substring match, and trigram is the one tokenizer that */
  132. /* answers substring queries, so search keeps the semantics it had and simply */
  133. /* stops being a scan. A word/stem search would be tokenize='unicode61' here */
  134. /* and no other change, but it would quietly stop matching inside words. */
  135. /* */
  136. /* Trigram cannot answer a term shorter than 3 characters; Trog::Data::SQLite */
  137. /* falls back to LIKE for those. */
  138. CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
  139. title,
  140. data,
  141. content='posts',
  142. content_rowid='id',
  143. tokenize='trigram'
  144. );
  145. DROP TRIGGER IF EXISTS posts_fts_insert;
  146. CREATE TRIGGER posts_fts_insert AFTER INSERT ON posts BEGIN
  147. INSERT INTO posts_fts (rowid, title, data) VALUES (new.id, new.title, new.data);
  148. END;
  149. DROP TRIGGER IF EXISTS posts_fts_delete;
  150. CREATE TRIGGER posts_fts_delete AFTER DELETE ON posts BEGIN
  151. INSERT INTO posts_fts (posts_fts, rowid, title, data) VALUES ('delete', old.id, old.title, old.data);
  152. END;