sqlite.schema 10.0 KB

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