perf(dictionary): a search finds keys by their first characters, indexed

A search matched LIKE '%|eat%' over every entry's barred keys, which no
index can answer, so each one read the whole dictionary: 43-57 ms in
JMdict's 218,863 entries. dictionary_search_key holds a row per key and
loose key with its first one, two and three characters in columns of
their own and one index on them, so the keys a query starts are an
equality the index answers whatever the database's collation (Serverpod's
models can't declare COLLATE "C" or text_pattern_ops). A search now takes
5-26 ms, at 1.4 million rows and 190 MB for JMdict, and its load takes
72 s instead of 33 s.

The loader fills the table beside the entries and loads a dictionary
again when it has none. Until then, in the minutes after the deploying
server starts, the search reads the barred keys as before; those columns
stay until a later release, since the server before a deploy's switch
still searches them. The migration only creates the table and its index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-17 13:22:24 +02:00
commit 8e3a4e56dc
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
19 changed files with 17709 additions and 19 deletions

File diff suppressed because one or more lines are too long

View file

@ -99,11 +99,15 @@ abstract class DictionaryEntry
String labels; String labels;
/// What a search matches, lower case, between bars /// What a search matches, lower case, between bars
/// (`|食べる|たべる|taberu|eat|`). /// (`|食べる|たべる|taberu|eat|`). A search reads them from
/// `DictionarySearchKey` since 2026-10-17; these stay for a dictionary
/// loaded before, until it loads again, and for the server a deploy
/// replaces, until a later release drops them.
String searchKeys; String searchKeys;
/// What a search matches after every searchKeys match: spellings people /// What a search matches after every searchKeys match: spellings people
/// type that aren't quite right (`sushi` for すうし), between bars too. /// type that aren't quite right (`sushi` for すうし), between bars too;
/// kept as searchKeys are.
String looseKeys; String looseKeys;
/// The entry as cards take it: its JSON, as the file holds it. /// The entry as cards take it: its JSON, as the file holds it.

View file

@ -0,0 +1,207 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// A key a search for words matches (`DictionaryEndpoint.search`), one row
/// each: every key and loose key of every entry, with its first one, two and
/// three characters in columns of their own, so that finding the keys a
/// query starts is an equality an index answers whatever the database's
/// collation (decided 2026-10-17: LIKE '%|eat%' over the entries' barred
/// keys read every entry, 43–57 ms a search in JMdict; this takes 1–6 ms).
/// Written by the loader beside the entries.
abstract class DictionarySearchKey
implements _isc.SerializableModel, _isc.ProtocolSerialization {
DictionarySearchKey._({
this.id,
required this.source,
required this.glossLanguage,
required this.key,
required this.loose,
required this.start1,
required this.start2,
required this.start3,
required this.sourceId,
required this.rank,
});
factory DictionarySearchKey({
int? id,
required String source,
required String glossLanguage,
required String key,
required bool loose,
required String start1,
required String start2,
required String start3,
required String sourceId,
required int rank,
}) = _DictionarySearchKeyImpl;
factory DictionarySearchKey.fromJson(Map<String, dynamic> jsonSerialization) {
return DictionarySearchKey(
id: jsonSerialization['id'] as int?,
source: jsonSerialization['source'] as String,
glossLanguage: jsonSerialization['glossLanguage'] as String,
key: jsonSerialization['key'] as String,
loose: _isc.BoolJsonExtension.fromJson(jsonSerialization['loose']),
start1: jsonSerialization['start1'] as String,
start2: jsonSerialization['start2'] as String,
start3: jsonSerialization['start3'] as String,
sourceId: jsonSerialization['sourceId'] as String,
rank: jsonSerialization['rank'] as int,
);
}
/// The database id, set if the object has been inserted into the
/// database or if it has been fetched from the database. Otherwise,
/// the id will be null.
int? id;
/// The entry's source and meaning language, as on the entry.
String source;
String glossLanguage;
/// The key as the entry's `searchKeys` or `looseKeys` hold it.
String key;
/// Whether it is a loose key: a spelling people type that isn't quite
/// right, found after the right ones.
bool loose;
/// The key's first character, its first two and its first three (a key
/// shorter than that whole); characters as Unicode counts them.
String start1;
String start2;
String start3;
/// The entry's id in its source, which with the source finds it.
String sourceId;
/// The entry's rank, so the best entries come first.
int rank;
/// Returns a shallow copy of this [DictionarySearchKey]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
DictionarySearchKey copyWith({
int? id,
String? source,
String? glossLanguage,
String? key,
bool? loose,
String? start1,
String? start2,
String? start3,
String? sourceId,
int? rank,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'DictionarySearchKey',
if (id != null) 'id': id,
'source': source,
'glossLanguage': glossLanguage,
'key': key,
'loose': loose,
'start1': start1,
'start2': start2,
'start3': start3,
'sourceId': sourceId,
'rank': rank,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'DictionarySearchKey',
if (id != null) 'id': id,
'source': source,
'glossLanguage': glossLanguage,
'key': key,
'loose': loose,
'start1': start1,
'start2': start2,
'start3': start3,
'sourceId': sourceId,
'rank': rank,
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _Undefined {}
class _DictionarySearchKeyImpl extends DictionarySearchKey {
_DictionarySearchKeyImpl({
int? id,
required String source,
required String glossLanguage,
required String key,
required bool loose,
required String start1,
required String start2,
required String start3,
required String sourceId,
required int rank,
}) : super._(
id: id,
source: source,
glossLanguage: glossLanguage,
key: key,
loose: loose,
start1: start1,
start2: start2,
start3: start3,
sourceId: sourceId,
rank: rank,
);
/// Returns a shallow copy of this [DictionarySearchKey]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
DictionarySearchKey copyWith({
Object? id = _Undefined,
String? source,
String? glossLanguage,
String? key,
bool? loose,
String? start1,
String? start2,
String? start3,
String? sourceId,
int? rank,
}) {
return DictionarySearchKey(
id: id is int? ? id : this.id,
source: source ?? this.source,
glossLanguage: glossLanguage ?? this.glossLanguage,
key: key ?? this.key,
loose: loose ?? this.loose,
start1: start1 ?? this.start1,
start2: start2 ?? this.start2,
start3: start3 ?? this.start3,
sourceId: sourceId ?? this.sourceId,
rank: rank ?? this.rank,
);
}
}

View file

@ -59,6 +59,7 @@ import 'dictionary/dictionary_entry.dart' as _i4ipxv77;
import 'dictionary/dictionary_hit.dart' as _ivbh47kc; import 'dictionary/dictionary_hit.dart' as _ivbh47kc;
import 'dictionary/dictionary_key.dart' as _i9gd3xbn; import 'dictionary/dictionary_key.dart' as _i9gd3xbn;
import 'dictionary/dictionary_key_hit.dart' as _i6b9unou; import 'dictionary/dictionary_key_hit.dart' as _i6b9unou;
import 'dictionary/dictionary_search_key.dart' as _ip3xfr1w;
import 'dictionary/dictionary_source.dart' as _ie12eb65; import 'dictionary/dictionary_source.dart' as _ie12eb65;
import 'discussion/discussion_post_view.dart' as _ifltqotq; import 'discussion/discussion_post_view.dart' as _ifltqotq;
import 'discussion/discussion_refusal.dart' as _iysrt1r7; import 'discussion/discussion_refusal.dart' as _iysrt1r7;
@ -122,6 +123,7 @@ export 'dictionary/dictionary_entry.dart';
export 'dictionary/dictionary_hit.dart'; export 'dictionary/dictionary_hit.dart';
export 'dictionary/dictionary_key.dart'; export 'dictionary/dictionary_key.dart';
export 'dictionary/dictionary_key_hit.dart'; export 'dictionary/dictionary_key_hit.dart';
export 'dictionary/dictionary_search_key.dart';
export 'dictionary/dictionary_source.dart'; export 'dictionary/dictionary_source.dart';
export 'discussion/discussion_post_view.dart'; export 'discussion/discussion_post_view.dart';
export 'discussion/discussion_refusal.dart'; export 'discussion/discussion_refusal.dart';
@ -2377,6 +2379,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _i6b9unou.DictionaryKeyHit) { if (t == _i6b9unou.DictionaryKeyHit) {
return _i6b9unou.DictionaryKeyHit.fromJson(data) as T; return _i6b9unou.DictionaryKeyHit.fromJson(data) as T;
} }
if (t == _ip3xfr1w.DictionarySearchKey) {
return _ip3xfr1w.DictionarySearchKey.fromJson(data) as T;
}
if (t == _ie12eb65.DictionarySource) { if (t == _ie12eb65.DictionarySource) {
return _ie12eb65.DictionarySource.fromJson(data) as T; return _ie12eb65.DictionarySource.fromJson(data) as T;
} }
@ -2594,6 +2599,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
return (data != null ? _i6b9unou.DictionaryKeyHit.fromJson(data) : null) return (data != null ? _i6b9unou.DictionaryKeyHit.fromJson(data) : null)
as T; as T;
} }
if (t == _isc.getType<_ip3xfr1w.DictionarySearchKey?>()) {
return (data != null
? _ip3xfr1w.DictionarySearchKey.fromJson(data)
: null)
as T;
}
if (t == _isc.getType<_ie12eb65.DictionarySource?>()) { if (t == _isc.getType<_ie12eb65.DictionarySource?>()) {
return (data != null ? _ie12eb65.DictionarySource.fromJson(data) : null) return (data != null ? _ie12eb65.DictionarySource.fromJson(data) : null)
as T; as T;
@ -2895,6 +2906,7 @@ class Protocol extends _isd.DatabaseSerializationManager {
_ivbh47kc.DictionaryHit => 'DictionaryHit', _ivbh47kc.DictionaryHit => 'DictionaryHit',
_i9gd3xbn.DictionaryKey => 'DictionaryKey', _i9gd3xbn.DictionaryKey => 'DictionaryKey',
_i6b9unou.DictionaryKeyHit => 'DictionaryKeyHit', _i6b9unou.DictionaryKeyHit => 'DictionaryKeyHit',
_ip3xfr1w.DictionarySearchKey => 'DictionarySearchKey',
_ie12eb65.DictionarySource => 'DictionarySource', _ie12eb65.DictionarySource => 'DictionarySource',
_ifltqotq.DiscussionPostView => 'DiscussionPostView', _ifltqotq.DiscussionPostView => 'DiscussionPostView',
_iysrt1r7.DiscussionRefusal => 'DiscussionRefusal', _iysrt1r7.DiscussionRefusal => 'DiscussionRefusal',
@ -2997,6 +3009,8 @@ class Protocol extends _isd.DatabaseSerializationManager {
return 'DictionaryKey'; return 'DictionaryKey';
case _i6b9unou.DictionaryKeyHit(): case _i6b9unou.DictionaryKeyHit():
return 'DictionaryKeyHit'; return 'DictionaryKeyHit';
case _ip3xfr1w.DictionarySearchKey():
return 'DictionarySearchKey';
case _ie12eb65.DictionarySource(): case _ie12eb65.DictionarySource():
return 'DictionarySource'; return 'DictionarySource';
case _ifltqotq.DiscussionPostView(): case _ifltqotq.DiscussionPostView():
@ -3173,6 +3187,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (dataClassName == 'DictionaryKeyHit') { if (dataClassName == 'DictionaryKeyHit') {
return deserialize<_i6b9unou.DictionaryKeyHit>(data['data']); return deserialize<_i6b9unou.DictionaryKeyHit>(data['data']);
} }
if (dataClassName == 'DictionarySearchKey') {
return deserialize<_ip3xfr1w.DictionarySearchKey>(data['data']);
}
if (dataClassName == 'DictionarySource') { if (dataClassName == 'DictionarySource') {
return deserialize<_ie12eb65.DictionarySource>(data['data']); return deserialize<_ie12eb65.DictionarySource>(data['data']);
} }

View file

@ -1,4 +1,7 @@
import 'dart:math' as math;
import 'package:halkyon_learn_server/src/dictionary/device_dictionaries.dart'; import 'package:halkyon_learn_server/src/dictionary/device_dictionaries.dart';
import 'package:halkyon_learn_server/src/dictionary/dictionary_loader.dart';
import 'package:halkyon_learn_server/src/dictionary/dictionary_search.dart'; import 'package:halkyon_learn_server/src/dictionary/dictionary_search.dart';
import 'package:halkyon_learn_server/src/generated/protocol.dart'; import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:serverpod/serverpod.dart'; import 'package:serverpod/serverpod.dart';
@ -59,6 +62,93 @@ class DictionaryEndpoint extends Endpoint {
return const []; return const [];
} }
final count = (limit ?? 30).clamp(1, maxSearchResults); final count = (limit ?? 30).clamp(1, maxSearchResults);
final entries = await hasSearchKeys(session, source, glossLanguage)
? await _byKeys(session, source, glossLanguage, variants, count)
: await _byBarredKeys(session, source, glossLanguage, variants, count);
return [for (final entry in entries) _hit(entry)];
}
/// The first [count] entries [variants] find through the dictionary's
/// search keys (`DictionarySearchKey`), in [search]'s order, then by the
/// entry's id in its source. A variant's keys are found by their first
/// characters, up to three, which the index holds, and a longer variant's
/// among those by LIKE.
Future<List<DictionaryEntry>> _byKeys(
Session session,
String source,
String glossLanguage,
List<String> variants,
int count,
) async {
final parameters = <String, Object?>{
'source': source,
'glossLanguage': glossLanguage,
'limit': count,
};
final starting = <String>[];
final whole = <String>[];
for (final (i, variant) in variants.indexed) {
// As the keys hold it: a bar is a space there.
final key = variant.replaceAll('|', ' ');
final length = key.runes.length;
final starts = math.min(length, 3);
parameters['whole$i'] = key;
whole.add('"key" = @whole$i');
for (var n = 1; n <= starts; n++) {
parameters['start${n}of$i'] = keyStart(key, n);
}
if (length > 3) {
parameters['starts$i'] = '${likeEscaped(variant)}%';
}
starting.add(
[
for (var n = 1; n <= starts; n++) '"start$n" = @start${n}of$i',
if (length > 3) '"key" LIKE @starts$i',
].join(' AND '),
);
}
final isWhole = whole.join(' OR ');
// SELECT "sourceId" FROM "dictionary_search_key" WHERE the dictionary
// is this one AND a key starts with a variant, GROUP BY entry,
// ORDER BY its best tier (a key that is a variant, a loose key that
// is, a key starting with one, a loose key), its rank, its id.
final rows = await session.db.unsafeQuery(
'SELECT "sourceId" FROM "dictionary_search_key" '
'WHERE "source" = @source AND "glossLanguage" = @glossLanguage '
'AND ((${starting.join(') OR (')})) '
'GROUP BY "sourceId" '
'ORDER BY min(CASE '
'WHEN NOT "loose" AND ($isWhole) THEN 0 '
'WHEN "loose" AND ($isWhole) THEN 1 '
'WHEN NOT "loose" THEN 2 ELSE 3 END), min("rank"), "sourceId" '
'LIMIT @limit',
parameters: QueryParameters.named(parameters),
);
final sourceIds = [for (final row in rows) row.first! as String];
final bySourceId = {
for (final entry in await DictionaryEntry.db.find(
session,
where: (t) =>
t.source.equals(source) &
t.glossLanguage.equals(glossLanguage) &
t.sourceId.inSet(sourceIds.toSet()),
))
entry.sourceId: entry,
};
return [for (final id in sourceIds) ?bySourceId[id]];
}
/// [_byKeys] as the search was before search keys had rows of their own:
/// LIKE over every entry's barred keys, which reads them all. Only for a
/// dictionary loaded before them, until it loads again, which the server
/// does as it starts.
Future<List<DictionaryEntry>> _byBarredKeys(
Session session,
String source,
String glossLanguage,
List<String> variants,
int count,
) async {
// One scan, in the order the tiers give: a key that is the query, a // One scan, in the order the tiers give: a key that is the query, a
// loose key that is, a key starting with it, a loose key starting with // loose key that is, a key starting with it, a loose key starting with
// it; within each the dictionary's order. A key that is the query also // it; within each the dictionary's order. A key that is the query also
@ -96,10 +186,7 @@ class DictionaryEndpoint extends Endpoint {
)) ))
entry.id: entry, entry.id: entry,
}; };
return [ return [for (final id in ids) ?byId[id]];
for (final id in ids)
if (byId[id] case final entry?) _hit(entry),
];
} }
/// The most keys [withKeys] takes at once. /// The most keys [withKeys] takes at once.

View file

@ -23,10 +23,14 @@ fields:
### Its labels between bars (`|common|jlpt:n5|`), for LIKE to find one. ### Its labels between bars (`|common|jlpt:n5|`), for LIKE to find one.
labels: String labels: String
### What a search matches, lower case, between bars ### What a search matches, lower case, between bars
### (`|食べる|たべる|taberu|eat|`). ### (`|食べる|たべる|taberu|eat|`). A search reads them from
### `DictionarySearchKey` since 2026-10-17; these stay for a dictionary
### loaded before, until it loads again, and for the server a deploy
### replaces, until a later release drops them.
searchKeys: String searchKeys: String
### What a search matches after every searchKeys match: spellings people ### What a search matches after every searchKeys match: spellings people
### type that aren't quite right (`sushi` for すうし), between bars too. ### type that aren't quite right (`sushi` for すうし), between bars too;
### kept as searchKeys are.
looseKeys: String looseKeys: String
### The entry as cards take it: its JSON, as the file holds it. ### The entry as cards take it: its JSON, as the file holds it.
entry: String entry: String

View file

@ -137,6 +137,14 @@ Future<void> _unloadGone(Session session, Set<(String, String)> held) async {
transaction: transaction, transaction: transaction,
noReturn: true, noReturn: true,
); );
await DictionarySearchKey.db.deleteWhere(
session,
where: (t) =>
t.source.equals(loaded.source) &
t.glossLanguage.equals(loaded.glossLanguage),
transaction: transaction,
noReturn: true,
);
await DictionarySource.db.deleteRow( await DictionarySource.db.deleteRow(
session, session,
loaded, loaded,
@ -164,14 +172,16 @@ Future<bool> loadDictionaryFile(Session session, File file) async {
where: (t) => where: (t) =>
t.source.equals(source) & t.glossLanguage.equals(glossLanguage), t.source.equals(source) & t.glossLanguage.equals(glossLanguage),
); );
// A dictionary loaded before its keys had rows of their own loads again. // A dictionary loaded before its keys, or its search keys, had rows of
// their own loads again.
if (loaded?.fileHash == hash && if (loaded?.fileHash == hash &&
await DictionaryKey.db.findFirstRow( await DictionaryKey.db.findFirstRow(
session, session,
where: (t) => where: (t) =>
t.source.equals(source) & t.glossLanguage.equals(glossLanguage), t.source.equals(source) & t.glossLanguage.equals(glossLanguage),
) != ) !=
null) { null &&
await hasSearchKeys(session, source, glossLanguage)) {
return false; return false;
} }
@ -196,8 +206,16 @@ Future<bool> loadDictionaryFile(Session session, File file) async {
transaction: transaction, transaction: transaction,
noReturn: true, noReturn: true,
); );
await DictionarySearchKey.db.deleteWhere(
session,
where: (t) =>
t.source.equals(source) & t.glossLanguage.equals(glossLanguage),
transaction: transaction,
noReturn: true,
);
final batch = <DictionaryEntry>[]; final batch = <DictionaryEntry>[];
final keys = <DictionaryKey>[]; final keys = <DictionaryKey>[];
final searchKeys = <DictionarySearchKey>[];
Future<void> flush() async { Future<void> flush() async {
await DictionaryEntry.db.insert( await DictionaryEntry.db.insert(
session, session,
@ -211,9 +229,16 @@ Future<bool> loadDictionaryFile(Session session, File file) async {
transaction: transaction, transaction: transaction,
noReturn: true, noReturn: true,
); );
await DictionarySearchKey.db.insert(
session,
searchKeys,
transaction: transaction,
noReturn: true,
);
count += batch.length; count += batch.length;
batch.clear(); batch.clear();
keys.clear(); keys.clear();
searchKeys.clear();
} }
await for (final line in _lines(file).skip(1)) { await for (final line in _lines(file).skip(1)) {
@ -227,6 +252,7 @@ Future<bool> loadDictionaryFile(Session session, File file) async {
final sourceId = json['id']! as String; final sourceId = json['id']! as String;
final rank = json['rank']! as int; final rank = json['rank']! as int;
final entryKeys = (json['keys']! as List).cast<String>(); final entryKeys = (json['keys']! as List).cast<String>();
final looseKeys = (json['looseKeys'] as List? ?? const []).cast<String>();
// What finds the entry in running text: its own forms where the file // What finds the entry in running text: its own forms where the file
// names them (a German word's, never its English meanings, which // names them (a German word's, never its English meanings, which
// would match English words in German text), else every key. // would match English words in German text), else every key.
@ -245,6 +271,25 @@ Future<bool> loadDictionaryFile(Session session, File file) async {
rank: rank, rank: rank,
), ),
]); ]);
// What a search finds it by: every key, as its barred keys hold it.
searchKeys.addAll([
for (final (key, loose) in {
for (final key in entryKeys) (key.replaceAll('|', ' '), false),
for (final key in looseKeys) (key.replaceAll('|', ' '), true),
})
if (key.isNotEmpty)
DictionarySearchKey(
source: source,
glossLanguage: glossLanguage,
key: key,
loose: loose,
start1: keyStart(key, 1),
start2: keyStart(key, 2),
start3: keyStart(key, 3),
sourceId: sourceId,
rank: rank,
),
]);
batch.add( batch.add(
DictionaryEntry( DictionaryEntry(
source: source, source: source,
@ -256,9 +301,7 @@ Future<bool> loadDictionaryFile(Session session, File file) async {
rank: rank, rank: rank,
labels: barred((json['labels']! as List).cast<String>()), labels: barred((json['labels']! as List).cast<String>()),
searchKeys: barred(entryKeys), searchKeys: barred(entryKeys),
looseKeys: barred( looseKeys: barred(looseKeys),
(json['looseKeys'] as List? ?? const []).cast<String>(),
),
entry: entry, entry: entry,
version: entryVersion(headword, reading, entry), version: entryVersion(headword, reading, entry),
), ),
@ -305,7 +348,7 @@ Future<bool> loadDictionaryFile(Session session, File file) async {
// Fresh statistics, or Postgres plans withKeys' lookups as if the keys // Fresh statistics, or Postgres plans withKeys' lookups as if the keys
// were few and reads all of them (0.6 s for JMdict rather than 20 ms). // were few and reads all of them (0.6 s for JMdict rather than 20 ms).
await session.db.unsafeExecute( await session.db.unsafeExecute(
'ANALYZE "dictionary_entry", "dictionary_key"', 'ANALYZE "dictionary_entry", "dictionary_key", "dictionary_search_key"',
); );
session.log( session.log(
'Loaded the dictionary $name: $count entries of $source ' 'Loaded the dictionary $name: $count entries of $source '
@ -314,6 +357,21 @@ Future<bool> loadDictionaryFile(Session session, File file) async {
return true; return true;
} }
/// Whether the dictionary of [source] in [glossLanguage] has its search
/// keys: one loaded before they had rows of their own has none until it
/// loads again, and is searched as before meanwhile.
Future<bool> hasSearchKeys(
Session session,
String source,
String glossLanguage,
) async =>
await DictionarySearchKey.db.findFirstRow(
session,
where: (t) =>
t.source.equals(source) & t.glossLanguage.equals(glossLanguage),
) !=
null;
/// A digest of what a card takes of an entry: its [headword], [reading] /// A digest of what a card takes of an entry: its [headword], [reading]
/// and [entry] (JSON). Sixteen hexadecimal digits of their SHA-256: enough /// and [entry] (JSON). Sixteen hexadecimal digits of their SHA-256: enough
/// to tell a corrected entry from the one a card was made from. /// to tell a corrected entry from the one a card was made from.

View file

@ -36,6 +36,12 @@ String likeEscaped(String text) => text
.replaceAll('_', r'\_') .replaceAll('_', r'\_')
.replaceAll('|', ' '); .replaceAll('|', ' ');
/// The first [length] characters of [key], as Unicode counts them (a
/// character beyond 16 bits, 𠮟, is one); all of it when shorter. A search
/// key's `start1` to `start3`, and what a query looks them up by.
String keyStart(String key, int length) =>
String.fromCharCodes(key.runes.take(length));
/// [values] between bars, as a dictionary entry's labels and search keys /// [values] between bars, as a dictionary entry's labels and search keys
/// are kept, for LIKE to find one whole (`%|eat|%`) or by its start. /// are kept, for LIKE to find one whole (`%|eat|%`) or by its start.
String barred(Iterable<String> values) => String barred(Iterable<String> values) =>

View file

@ -0,0 +1,31 @@
### A key a search for words matches (`DictionaryEndpoint.search`), one row
### each: every key and loose key of every entry, with its first one, two and
### three characters in columns of their own, so that finding the keys a
### query starts is an equality an index answers whatever the database's
### collation (decided 2026-10-17: LIKE '%|eat%' over the entries' barred
### keys read every entry, 43–57 ms a search in JMdict; this takes 1–6 ms).
### Written by the loader beside the entries.
class: DictionarySearchKey
table: dictionary_search_key
fields:
### The entry's source and meaning language, as on the entry.
source: String
glossLanguage: String
### The key as the entry's `searchKeys` or `looseKeys` hold it.
key: String
### Whether it is a loose key: a spelling people type that isn't quite
### right, found after the right ones.
loose: bool
### The key's first character, its first two and its first three (a key
### shorter than that whole); characters as Unicode counts them.
start1: String
start2: String
start3: String
### The entry's id in its source, which with the source finds it.
sourceId: String
### The entry's rank, so the best entries come first.
rank: int
indexes:
# The keys starting with up to three characters, in a dictionary.
dictionary_search_key_start_idx:
fields: source, glossLanguage, start1, start2, start3

View file

@ -101,11 +101,15 @@ abstract class DictionaryEntry
String labels; String labels;
/// What a search matches, lower case, between bars /// What a search matches, lower case, between bars
/// (`|食べる|たべる|taberu|eat|`). /// (`|食べる|たべる|taberu|eat|`). A search reads them from
/// `DictionarySearchKey` since 2026-10-17; these stay for a dictionary
/// loaded before, until it loads again, and for the server a deploy
/// replaces, until a later release drops them.
String searchKeys; String searchKeys;
/// What a search matches after every searchKeys match: spellings people /// What a search matches after every searchKeys match: spellings people
/// type that aren't quite right (`sushi` for すうし), between bars too. /// type that aren't quite right (`sushi` for すうし), between bars too;
/// kept as searchKeys are.
String looseKeys; String looseKeys;
/// The entry as cards take it: its JSON, as the file holds it. /// The entry as cards take it: its JSON, as the file holds it.
@ -422,11 +426,15 @@ class DictionaryEntryTable extends _is.Table<int?> {
late final _is.ColumnString labels; late final _is.ColumnString labels;
/// What a search matches, lower case, between bars /// What a search matches, lower case, between bars
/// (`|食べる|たべる|taberu|eat|`). /// (`|食べる|たべる|taberu|eat|`). A search reads them from
/// `DictionarySearchKey` since 2026-10-17; these stay for a dictionary
/// loaded before, until it loads again, and for the server a deploy
/// replaces, until a later release drops them.
late final _is.ColumnString searchKeys; late final _is.ColumnString searchKeys;
/// What a search matches after every searchKeys match: spellings people /// What a search matches after every searchKeys match: spellings people
/// type that aren't quite right (`sushi` for すうし), between bars too. /// type that aren't quite right (`sushi` for すうし), between bars too;
/// kept as searchKeys are.
late final _is.ColumnString looseKeys; late final _is.ColumnString looseKeys;
/// The entry as cards take it: its JSON, as the file holds it. /// The entry as cards take it: its JSON, as the file holds it.

View file

@ -0,0 +1,796 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod/serverpod.dart' as _is;
/// A key a search for words matches (`DictionaryEndpoint.search`), one row
/// each: every key and loose key of every entry, with its first one, two and
/// three characters in columns of their own, so that finding the keys a
/// query starts is an equality an index answers whatever the database's
/// collation (decided 2026-10-17: LIKE '%|eat%' over the entries' barred
/// keys read every entry, 43–57 ms a search in JMdict; this takes 1–6 ms).
/// Written by the loader beside the entries.
abstract class DictionarySearchKey
implements _is.TableRow<int?>, _is.ProtocolSerialization {
DictionarySearchKey._({
this.id,
required this.source,
required this.glossLanguage,
required this.key,
required this.loose,
required this.start1,
required this.start2,
required this.start3,
required this.sourceId,
required this.rank,
});
factory DictionarySearchKey({
int? id,
required String source,
required String glossLanguage,
required String key,
required bool loose,
required String start1,
required String start2,
required String start3,
required String sourceId,
required int rank,
}) = _DictionarySearchKeyImpl;
factory DictionarySearchKey.fromJson(Map<String, dynamic> jsonSerialization) {
return DictionarySearchKey(
id: jsonSerialization['id'] as int?,
source: jsonSerialization['source'] as String,
glossLanguage: jsonSerialization['glossLanguage'] as String,
key: jsonSerialization['key'] as String,
loose: _is.BoolJsonExtension.fromJson(jsonSerialization['loose']),
start1: jsonSerialization['start1'] as String,
start2: jsonSerialization['start2'] as String,
start3: jsonSerialization['start3'] as String,
sourceId: jsonSerialization['sourceId'] as String,
rank: jsonSerialization['rank'] as int,
);
}
static final t = DictionarySearchKeyTable();
static const db = DictionarySearchKeyRepository._();
@override
int? id;
/// The entry's source and meaning language, as on the entry.
String source;
String glossLanguage;
/// The key as the entry's `searchKeys` or `looseKeys` hold it.
String key;
/// Whether it is a loose key: a spelling people type that isn't quite
/// right, found after the right ones.
bool loose;
/// The key's first character, its first two and its first three (a key
/// shorter than that whole); characters as Unicode counts them.
String start1;
String start2;
String start3;
/// The entry's id in its source, which with the source finds it.
String sourceId;
/// The entry's rank, so the best entries come first.
int rank;
@override
_is.Table<int?> get table => t;
/// Returns a shallow copy of this [DictionarySearchKey]
/// with some or all fields replaced by the given arguments.
@_is.useResult
DictionarySearchKey copyWith({
int? id,
String? source,
String? glossLanguage,
String? key,
bool? loose,
String? start1,
String? start2,
String? start3,
String? sourceId,
int? rank,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'DictionarySearchKey',
if (id != null) 'id': id,
'source': source,
'glossLanguage': glossLanguage,
'key': key,
'loose': loose,
'start1': start1,
'start2': start2,
'start3': start3,
'sourceId': sourceId,
'rank': rank,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'DictionarySearchKey',
if (id != null) 'id': id,
'source': source,
'glossLanguage': glossLanguage,
'key': key,
'loose': loose,
'start1': start1,
'start2': start2,
'start3': start3,
'sourceId': sourceId,
'rank': rank,
};
}
static DictionarySearchKeyInclude include() {
return DictionarySearchKeyInclude._();
}
static DictionarySearchKeyIncludeList includeList({
_is.WhereExpressionBuilder<DictionarySearchKeyTable>? where,
int? limit,
int? offset,
_is.OrderByBuilder<DictionarySearchKeyTable>? orderBy,
_is.OrderByListBuilder<DictionarySearchKeyTable>? orderByList,
DictionarySearchKeyInclude? include,
}) {
return DictionarySearchKeyIncludeList._(
where: where,
limit: limit,
offset: offset,
orderBy: orderBy?.call(DictionarySearchKey.t),
orderByList: orderByList?.call(DictionarySearchKey.t),
include: include,
);
}
@override
String toString() {
return _is.SerializationManager.encode(this);
}
}
class _Undefined {}
class _DictionarySearchKeyImpl extends DictionarySearchKey {
_DictionarySearchKeyImpl({
int? id,
required String source,
required String glossLanguage,
required String key,
required bool loose,
required String start1,
required String start2,
required String start3,
required String sourceId,
required int rank,
}) : super._(
id: id,
source: source,
glossLanguage: glossLanguage,
key: key,
loose: loose,
start1: start1,
start2: start2,
start3: start3,
sourceId: sourceId,
rank: rank,
);
/// Returns a shallow copy of this [DictionarySearchKey]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
DictionarySearchKey copyWith({
Object? id = _Undefined,
String? source,
String? glossLanguage,
String? key,
bool? loose,
String? start1,
String? start2,
String? start3,
String? sourceId,
int? rank,
}) {
return DictionarySearchKey(
id: id is int? ? id : this.id,
source: source ?? this.source,
glossLanguage: glossLanguage ?? this.glossLanguage,
key: key ?? this.key,
loose: loose ?? this.loose,
start1: start1 ?? this.start1,
start2: start2 ?? this.start2,
start3: start3 ?? this.start3,
sourceId: sourceId ?? this.sourceId,
rank: rank ?? this.rank,
);
}
}
class DictionarySearchKeyUpdateTable
extends _is.UpdateTable<DictionarySearchKeyTable> {
DictionarySearchKeyUpdateTable(super.table);
_is.ColumnValue<String, String> source(String value) => _is.ColumnValue(
table.source,
value,
);
_is.ColumnValue<String, String> glossLanguage(String value) =>
_is.ColumnValue(
table.glossLanguage,
value,
);
_is.ColumnValue<String, String> key(String value) => _is.ColumnValue(
table.key,
value,
);
_is.ColumnValue<bool, bool> loose(bool value) => _is.ColumnValue(
table.loose,
value,
);
_is.ColumnValue<String, String> start1(String value) => _is.ColumnValue(
table.start1,
value,
);
_is.ColumnValue<String, String> start2(String value) => _is.ColumnValue(
table.start2,
value,
);
_is.ColumnValue<String, String> start3(String value) => _is.ColumnValue(
table.start3,
value,
);
_is.ColumnValue<String, String> sourceId(String value) => _is.ColumnValue(
table.sourceId,
value,
);
_is.ColumnValue<int, int> rank(int value) => _is.ColumnValue(
table.rank,
value,
);
}
class DictionarySearchKeyTable extends _is.Table<int?> {
DictionarySearchKeyTable({super.tableRelation})
: super(tableName: 'dictionary_search_key') {
updateTable = DictionarySearchKeyUpdateTable(this);
source = _is.ColumnString(
'source',
this,
);
glossLanguage = _is.ColumnString(
'glossLanguage',
this,
);
key = _is.ColumnString(
'key',
this,
);
loose = _is.ColumnBool(
'loose',
this,
);
start1 = _is.ColumnString(
'start1',
this,
);
start2 = _is.ColumnString(
'start2',
this,
);
start3 = _is.ColumnString(
'start3',
this,
);
sourceId = _is.ColumnString(
'sourceId',
this,
);
rank = _is.ColumnInt(
'rank',
this,
);
}
late final DictionarySearchKeyUpdateTable updateTable;
/// The entry's source and meaning language, as on the entry.
late final _is.ColumnString source;
late final _is.ColumnString glossLanguage;
/// The key as the entry's `searchKeys` or `looseKeys` hold it.
late final _is.ColumnString key;
/// Whether it is a loose key: a spelling people type that isn't quite
/// right, found after the right ones.
late final _is.ColumnBool loose;
/// The key's first character, its first two and its first three (a key
/// shorter than that whole); characters as Unicode counts them.
late final _is.ColumnString start1;
late final _is.ColumnString start2;
late final _is.ColumnString start3;
/// The entry's id in its source, which with the source finds it.
late final _is.ColumnString sourceId;
/// The entry's rank, so the best entries come first.
late final _is.ColumnInt rank;
@override
List<_is.Column> get columns => [
id,
source,
glossLanguage,
key,
loose,
start1,
start2,
start3,
sourceId,
rank,
];
}
class DictionarySearchKeyInclude extends _is.IncludeObject {
DictionarySearchKeyInclude._();
@override
Map<String, _is.Include?> get includes => {};
@override
_is.Table<int?> get table => DictionarySearchKey.t;
}
class DictionarySearchKeyIncludeList extends _is.IncludeList {
DictionarySearchKeyIncludeList._({
_is.WhereExpressionBuilder<DictionarySearchKeyTable>? where,
super.limit,
super.offset,
super.orderBy,
super.orderByList,
super.include,
}) {
super.where = where?.call(DictionarySearchKey.t);
}
@override
Map<String, _is.Include?> get includes => include?.includes ?? {};
@override
_is.Table<int?> get table => DictionarySearchKey.t;
}
class DictionarySearchKeyRepository {
const DictionarySearchKeyRepository._();
/// Returns a list of [DictionarySearchKey]s matching the given query parameters.
///
/// Use [where] to specify which items to include in the return value.
/// If none is specified, all items will be returned.
///
/// To specify the order of the items use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// The maximum number of items can be set by [limit]. If no limit is set,
/// all items matching the query will be returned.
///
/// [offset] defines how many items to skip, after which [limit] (or all)
/// items are read from the database.
///
/// ```dart
/// var persons = await Persons.db.find(
/// session,
/// where: (t) => t.lastName.equals('Jones'),
/// orderBy: (t) => t.firstName,
/// limit: 100,
/// );
/// ```
Future<List<DictionarySearchKey>> find(
_is.DatabaseSession session, {
_is.WhereExpressionBuilder<DictionarySearchKeyTable>? where,
int? limit,
int? offset,
_is.OrderByBuilder<DictionarySearchKeyTable>? orderBy,
_is.OrderByListBuilder<DictionarySearchKeyTable>? orderByList,
_is.Transaction? transaction,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.find<DictionarySearchKey>(
where: where?.call(DictionarySearchKey.t),
orderBy: orderBy?.call(DictionarySearchKey.t),
orderByList: orderByList?.call(DictionarySearchKey.t),
limit: limit,
offset: offset,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Returns the first matching [DictionarySearchKey] matching the given query parameters.
///
/// Use [where] to specify which items to include in the return value.
/// If none is specified, all items will be returned.
///
/// To specify the order use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// [offset] defines how many items to skip, after which the next one will be picked.
///
/// ```dart
/// var youngestPerson = await Persons.db.findFirstRow(
/// session,
/// where: (t) => t.lastName.equals('Jones'),
/// orderBy: (t) => t.age,
/// );
/// ```
Future<DictionarySearchKey?> findFirstRow(
_is.DatabaseSession session, {
_is.WhereExpressionBuilder<DictionarySearchKeyTable>? where,
int? offset,
_is.OrderByBuilder<DictionarySearchKeyTable>? orderBy,
_is.OrderByListBuilder<DictionarySearchKeyTable>? orderByList,
_is.Transaction? transaction,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.findFirstRow<DictionarySearchKey>(
where: where?.call(DictionarySearchKey.t),
orderBy: orderBy?.call(DictionarySearchKey.t),
orderByList: orderByList?.call(DictionarySearchKey.t),
offset: offset,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Finds a single [DictionarySearchKey] by its [id] or null if no such row exists.
Future<DictionarySearchKey?> findById(
_is.DatabaseSession session,
int id, {
_is.Transaction? transaction,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.findById<DictionarySearchKey>(
id,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Inserts all [DictionarySearchKey]s in the list and returns the inserted rows.
///
/// The returned [DictionarySearchKey]s will have their `id` fields set.
///
/// This is an atomic operation, meaning that if one of the rows fails to
/// insert, none of the rows will be inserted.
///
/// If [ignoreConflicts] is set to `true`, rows that conflict with existing
/// rows are silently skipped, and only the successfully inserted rows are
/// returned.
///
/// If [noReturn] is set to `true`, the inserted rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<DictionarySearchKey>> insert(
_is.DatabaseSession session,
List<DictionarySearchKey> rows, {
_is.Transaction? transaction,
bool ignoreConflicts = false,
bool noReturn = false,
}) async {
return session.db.insert<DictionarySearchKey>(
rows,
transaction: transaction,
ignoreConflicts: ignoreConflicts,
noReturn: noReturn,
);
}
/// Inserts a single [DictionarySearchKey] and returns the inserted row.
///
/// The returned [DictionarySearchKey] will have its `id` field set.
Future<DictionarySearchKey> insertRow(
_is.DatabaseSession session,
DictionarySearchKey row, {
_is.Transaction? transaction,
}) async {
return session.db.insertRow<DictionarySearchKey>(
row,
transaction: transaction,
);
}
/// Upserts all [DictionarySearchKey]s in the list and returns the resulting rows.
///
/// If a row conflicts on the given [conflictColumns], the existing row is
/// updated with the new values. Otherwise, a new row is inserted.
///
/// If [updateColumns] is provided, only those columns will be updated on
/// conflict. If null, all non-conflict, non-id columns are updated.
///
/// If [updateWhere] is provided, the update only applies to rows matching the
/// given expression. Conflicting rows that don't match are skipped and not
/// returned, so the resulting list may be shorter than [rows].
///
/// The returned [DictionarySearchKey]s will have their `id` fields set.
///
/// This is an atomic operation, meaning that if one of the rows fails,
/// none of the rows will be affected.
///
/// If [noReturn] is set to `true`, the resulting rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<DictionarySearchKey>> upsert(
_is.DatabaseSession session,
List<DictionarySearchKey> rows, {
required _is.ColumnSelections<DictionarySearchKeyTable> conflictColumns,
_is.ColumnSelections<DictionarySearchKeyTable>? updateColumns,
_is.WhereExpressionBuilder<DictionarySearchKeyTable>? updateWhere,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.upsert<DictionarySearchKey>(
rows,
conflictColumns: conflictColumns(DictionarySearchKey.t),
updateColumns: updateColumns?.call(DictionarySearchKey.t),
updateWhere: updateWhere?.call(DictionarySearchKey.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Upserts a single [DictionarySearchKey] and returns the resulting row.
///
/// If the row conflicts on the given [conflictColumns], the existing row is
/// updated. Otherwise, a new row is inserted.
///
/// If [updateColumns] is provided, only those columns will be updated on
/// conflict. If null, all non-conflict, non-id columns are updated.
///
/// If [updateWhere] is provided, the update only applies when the existing
/// row matches the expression. Returns `null` if no row was affected — for
/// example when [updateWhere] does not match the conflicting row.
///
/// The returned [DictionarySearchKey] will have its `id` field set.
Future<DictionarySearchKey?> upsertRow(
_is.DatabaseSession session,
DictionarySearchKey row, {
required _is.ColumnSelections<DictionarySearchKeyTable> conflictColumns,
_is.ColumnSelections<DictionarySearchKeyTable>? updateColumns,
_is.WhereExpressionBuilder<DictionarySearchKeyTable>? updateWhere,
_is.Transaction? transaction,
}) async {
return session.db.upsertRow<DictionarySearchKey>(
row,
conflictColumns: conflictColumns(DictionarySearchKey.t),
updateColumns: updateColumns?.call(DictionarySearchKey.t),
updateWhere: updateWhere?.call(DictionarySearchKey.t),
transaction: transaction,
);
}
/// Updates all [DictionarySearchKey]s in the list and returns the updated rows. If
/// [columns] is provided, only those columns will be updated. Defaults to
/// all columns.
/// This is an atomic operation, meaning that if one of the rows fails to
/// update, none of the rows will be updated.
///
/// If [noReturn] is set to `true`, the updated rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<DictionarySearchKey>> update(
_is.DatabaseSession session,
List<DictionarySearchKey> rows, {
_is.ColumnSelections<DictionarySearchKeyTable>? columns,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.update<DictionarySearchKey>(
rows,
columns: columns?.call(DictionarySearchKey.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Updates a single [DictionarySearchKey]. The row needs to have its id set.
/// Optionally, a list of [columns] can be provided to only update those
/// columns. Defaults to all columns.
Future<DictionarySearchKey> updateRow(
_is.DatabaseSession session,
DictionarySearchKey row, {
_is.ColumnSelections<DictionarySearchKeyTable>? columns,
_is.Transaction? transaction,
}) async {
return session.db.updateRow<DictionarySearchKey>(
row,
columns: columns?.call(DictionarySearchKey.t),
transaction: transaction,
);
}
/// Updates a single [DictionarySearchKey] by its [id] with the specified [columnValues].
/// Returns the updated row or null if no row with the given id exists.
Future<DictionarySearchKey?> updateById(
_is.DatabaseSession session,
int id, {
required _is.ColumnValueListBuilder<DictionarySearchKeyUpdateTable>
columnValues,
_is.Transaction? transaction,
}) async {
return session.db.updateById<DictionarySearchKey>(
id,
columnValues: columnValues(DictionarySearchKey.t.updateTable),
transaction: transaction,
);
}
/// Updates all [DictionarySearchKey]s matching the [where] expression with the specified [columnValues].
/// Returns the list of updated rows.
///
/// If [noReturn] is set to `true`, the updated rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<DictionarySearchKey>> updateWhere(
_is.DatabaseSession session, {
required _is.ColumnValueListBuilder<DictionarySearchKeyUpdateTable>
columnValues,
required _is.WhereExpressionBuilder<DictionarySearchKeyTable> where,
int? limit,
int? offset,
_is.OrderByBuilder<DictionarySearchKeyTable>? orderBy,
_is.OrderByListBuilder<DictionarySearchKeyTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.updateWhere<DictionarySearchKey>(
columnValues: columnValues(DictionarySearchKey.t.updateTable),
where: where(DictionarySearchKey.t),
limit: limit,
offset: offset,
orderBy: orderBy?.call(DictionarySearchKey.t),
orderByList: orderByList?.call(DictionarySearchKey.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes all [DictionarySearchKey]s in the list and returns the deleted rows.
///
/// To specify the order of the returned rows use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// This is an atomic operation, meaning that if one of the rows fail to
/// be deleted, none of the rows will be deleted.
///
/// If [noReturn] is set to `true`, the deleted rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<DictionarySearchKey>> delete(
_is.DatabaseSession session,
List<DictionarySearchKey> rows, {
_is.OrderByBuilder<DictionarySearchKeyTable>? orderBy,
_is.OrderByListBuilder<DictionarySearchKeyTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.delete<DictionarySearchKey>(
rows,
orderBy: orderBy?.call(DictionarySearchKey.t),
orderByList: orderByList?.call(DictionarySearchKey.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes a single [DictionarySearchKey].
Future<DictionarySearchKey> deleteRow(
_is.DatabaseSession session,
DictionarySearchKey row, {
_is.Transaction? transaction,
}) async {
return session.db.deleteRow<DictionarySearchKey>(
row,
transaction: transaction,
);
}
/// Deletes all rows matching the [where] expression.
///
/// To specify the order of the returned rows use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// If [noReturn] is set to `true`, the deleted rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<DictionarySearchKey>> deleteWhere(
_is.DatabaseSession session, {
required _is.WhereExpressionBuilder<DictionarySearchKeyTable> where,
_is.OrderByBuilder<DictionarySearchKeyTable>? orderBy,
_is.OrderByListBuilder<DictionarySearchKeyTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.deleteWhere<DictionarySearchKey>(
where: where(DictionarySearchKey.t),
orderBy: orderBy?.call(DictionarySearchKey.t),
orderByList: orderByList?.call(DictionarySearchKey.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Counts the number of rows matching the [where] expression. If omitted,
/// will return the count of all rows in the table.
Future<int> count(
_is.DatabaseSession session, {
_is.WhereExpressionBuilder<DictionarySearchKeyTable>? where,
int? limit,
_is.Transaction? transaction,
}) async {
return session.db.count<DictionarySearchKey>(
where: where?.call(DictionarySearchKey.t),
limit: limit,
transaction: transaction,
);
}
/// Acquires row-level locks on [DictionarySearchKey] rows matching the [where] expression.
Future<void> lockRows(
_is.DatabaseSession session, {
required _is.WhereExpressionBuilder<DictionarySearchKeyTable> where,
required _is.LockMode lockMode,
required _is.Transaction transaction,
_is.LockBehavior lockBehavior = _is.LockBehavior.wait,
}) async {
return session.db.lockRows<DictionarySearchKey>(
where: where(DictionarySearchKey.t),
lockMode: lockMode,
lockBehavior: lockBehavior,
transaction: transaction,
);
}
}

View file

@ -66,6 +66,7 @@ import 'dictionary/dictionary_entry.dart' as _i4ipxv77;
import 'dictionary/dictionary_hit.dart' as _ivbh47kc; import 'dictionary/dictionary_hit.dart' as _ivbh47kc;
import 'dictionary/dictionary_key.dart' as _i9gd3xbn; import 'dictionary/dictionary_key.dart' as _i9gd3xbn;
import 'dictionary/dictionary_key_hit.dart' as _i6b9unou; import 'dictionary/dictionary_key_hit.dart' as _i6b9unou;
import 'dictionary/dictionary_search_key.dart' as _ip3xfr1w;
import 'dictionary/dictionary_source.dart' as _ie12eb65; import 'dictionary/dictionary_source.dart' as _ie12eb65;
import 'discussion/discussion_post.dart' as _ig6tsv8f; import 'discussion/discussion_post.dart' as _ig6tsv8f;
import 'discussion/discussion_post_view.dart' as _ifltqotq; import 'discussion/discussion_post_view.dart' as _ifltqotq;
@ -143,6 +144,7 @@ export 'dictionary/dictionary_entry.dart';
export 'dictionary/dictionary_hit.dart'; export 'dictionary/dictionary_hit.dart';
export 'dictionary/dictionary_key.dart'; export 'dictionary/dictionary_key.dart';
export 'dictionary/dictionary_key_hit.dart'; export 'dictionary/dictionary_key_hit.dart';
export 'dictionary/dictionary_search_key.dart';
export 'dictionary/dictionary_source.dart'; export 'dictionary/dictionary_source.dart';
export 'discussion/discussion_post.dart'; export 'discussion/discussion_post.dart';
export 'discussion/discussion_post_view.dart'; export 'discussion/discussion_post_view.dart';
@ -1240,6 +1242,108 @@ class Protocol extends _is.DatabaseSerializationManager {
], ],
managed: true, managed: true,
), ),
_isp.TableDefinition(
name: 'dictionary_search_key',
dartName: 'DictionarySearchKey',
schema: 'public',
module: 'halkyon_learn',
columns: [
_isp.ColumnDefinition(
name: 'id',
columnType: _isp.ColumnType.bigint,
isNullable: false,
dartType: 'int?',
columnDefault: 'serial',
),
_isp.ColumnDefinition(
name: 'source',
columnType: _isp.ColumnType.text,
isNullable: false,
dartType: 'String',
),
_isp.ColumnDefinition(
name: 'glossLanguage',
columnType: _isp.ColumnType.text,
isNullable: false,
dartType: 'String',
),
_isp.ColumnDefinition(
name: 'key',
columnType: _isp.ColumnType.text,
isNullable: false,
dartType: 'String',
),
_isp.ColumnDefinition(
name: 'loose',
columnType: _isp.ColumnType.boolean,
isNullable: false,
dartType: 'bool',
),
_isp.ColumnDefinition(
name: 'start1',
columnType: _isp.ColumnType.text,
isNullable: false,
dartType: 'String',
),
_isp.ColumnDefinition(
name: 'start2',
columnType: _isp.ColumnType.text,
isNullable: false,
dartType: 'String',
),
_isp.ColumnDefinition(
name: 'start3',
columnType: _isp.ColumnType.text,
isNullable: false,
dartType: 'String',
),
_isp.ColumnDefinition(
name: 'sourceId',
columnType: _isp.ColumnType.text,
isNullable: false,
dartType: 'String',
),
_isp.ColumnDefinition(
name: 'rank',
columnType: _isp.ColumnType.bigint,
isNullable: false,
dartType: 'int',
),
],
foreignKeys: [],
indexes: [
_isp.IndexDefinition(
indexName: 'dictionary_search_key_start_idx',
tableSpace: null,
elements: [
_isp.IndexElementDefinition(
type: _isp.IndexElementDefinitionType.column,
definition: 'source',
),
_isp.IndexElementDefinition(
type: _isp.IndexElementDefinitionType.column,
definition: 'glossLanguage',
),
_isp.IndexElementDefinition(
type: _isp.IndexElementDefinitionType.column,
definition: 'start1',
),
_isp.IndexElementDefinition(
type: _isp.IndexElementDefinitionType.column,
definition: 'start2',
),
_isp.IndexElementDefinition(
type: _isp.IndexElementDefinitionType.column,
definition: 'start3',
),
],
type: 'btree',
isUnique: false,
isPrimary: false,
),
],
managed: true,
),
_isp.TableDefinition( _isp.TableDefinition(
name: 'dictionary_source', name: 'dictionary_source',
dartName: 'DictionarySource', dartName: 'DictionarySource',
@ -4090,6 +4194,9 @@ class Protocol extends _is.DatabaseSerializationManager {
if (t == _i6b9unou.DictionaryKeyHit) { if (t == _i6b9unou.DictionaryKeyHit) {
return _i6b9unou.DictionaryKeyHit.fromJson(data) as T; return _i6b9unou.DictionaryKeyHit.fromJson(data) as T;
} }
if (t == _ip3xfr1w.DictionarySearchKey) {
return _ip3xfr1w.DictionarySearchKey.fromJson(data) as T;
}
if (t == _ie12eb65.DictionarySource) { if (t == _ie12eb65.DictionarySource) {
return _ie12eb65.DictionarySource.fromJson(data) as T; return _ie12eb65.DictionarySource.fromJson(data) as T;
} }
@ -4360,6 +4467,12 @@ class Protocol extends _is.DatabaseSerializationManager {
return (data != null ? _i6b9unou.DictionaryKeyHit.fromJson(data) : null) return (data != null ? _i6b9unou.DictionaryKeyHit.fromJson(data) : null)
as T; as T;
} }
if (t == _is.getType<_ip3xfr1w.DictionarySearchKey?>()) {
return (data != null
? _ip3xfr1w.DictionarySearchKey.fromJson(data)
: null)
as T;
}
if (t == _is.getType<_ie12eb65.DictionarySource?>()) { if (t == _is.getType<_ie12eb65.DictionarySource?>()) {
return (data != null ? _ie12eb65.DictionarySource.fromJson(data) : null) return (data != null ? _ie12eb65.DictionarySource.fromJson(data) : null)
as T; as T;
@ -4698,6 +4811,7 @@ class Protocol extends _is.DatabaseSerializationManager {
_ivbh47kc.DictionaryHit => 'DictionaryHit', _ivbh47kc.DictionaryHit => 'DictionaryHit',
_i9gd3xbn.DictionaryKey => 'DictionaryKey', _i9gd3xbn.DictionaryKey => 'DictionaryKey',
_i6b9unou.DictionaryKeyHit => 'DictionaryKeyHit', _i6b9unou.DictionaryKeyHit => 'DictionaryKeyHit',
_ip3xfr1w.DictionarySearchKey => 'DictionarySearchKey',
_ie12eb65.DictionarySource => 'DictionarySource', _ie12eb65.DictionarySource => 'DictionarySource',
_ig6tsv8f.DiscussionPost => 'DiscussionPost', _ig6tsv8f.DiscussionPost => 'DiscussionPost',
_ifltqotq.DiscussionPostView => 'DiscussionPostView', _ifltqotq.DiscussionPostView => 'DiscussionPostView',
@ -4821,6 +4935,8 @@ class Protocol extends _is.DatabaseSerializationManager {
return 'DictionaryKey'; return 'DictionaryKey';
case _i6b9unou.DictionaryKeyHit(): case _i6b9unou.DictionaryKeyHit():
return 'DictionaryKeyHit'; return 'DictionaryKeyHit';
case _ip3xfr1w.DictionarySearchKey():
return 'DictionarySearchKey';
case _ie12eb65.DictionarySource(): case _ie12eb65.DictionarySource():
return 'DictionarySource'; return 'DictionarySource';
case _ig6tsv8f.DiscussionPost(): case _ig6tsv8f.DiscussionPost():
@ -5036,6 +5152,9 @@ class Protocol extends _is.DatabaseSerializationManager {
if (dataClassName == 'DictionaryKeyHit') { if (dataClassName == 'DictionaryKeyHit') {
return deserialize<_i6b9unou.DictionaryKeyHit>(data['data']); return deserialize<_i6b9unou.DictionaryKeyHit>(data['data']);
} }
if (dataClassName == 'DictionarySearchKey') {
return deserialize<_ip3xfr1w.DictionarySearchKey>(data['data']);
}
if (dataClassName == 'DictionarySource') { if (dataClassName == 'DictionarySource') {
return deserialize<_ie12eb65.DictionarySource>(data['data']); return deserialize<_ie12eb65.DictionarySource>(data['data']);
} }
@ -5258,6 +5377,8 @@ class Protocol extends _is.DatabaseSerializationManager {
return _i4ipxv77.DictionaryEntry.t; return _i4ipxv77.DictionaryEntry.t;
case _i9gd3xbn.DictionaryKey: case _i9gd3xbn.DictionaryKey:
return _i9gd3xbn.DictionaryKey.t; return _i9gd3xbn.DictionaryKey.t;
case _ip3xfr1w.DictionarySearchKey:
return _ip3xfr1w.DictionarySearchKey.t;
case _ie12eb65.DictionarySource: case _ie12eb65.DictionarySource:
return _ie12eb65.DictionarySource.t; return _ie12eb65.DictionarySource.t;
case _ig6tsv8f.DiscussionPost: case _ig6tsv8f.DiscussionPost:

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,139 @@
{
"__className__": "serverpod.DatabaseMigration",
"actions": [
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "createTable",
"createTable": {
"__className__": "serverpod.TableDefinition",
"name": "dictionary_search_key",
"dartName": "DictionarySearchKey",
"module": "halkyon_learn",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 6,
"isNullable": false,
"columnDefault": "serial",
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "source",
"fieldName": "source",
"columnType": 0,
"isNullable": false,
"dartType": "String"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "glossLanguage",
"fieldName": "glossLanguage",
"columnType": 0,
"isNullable": false,
"dartType": "String"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "key",
"fieldName": "key",
"columnType": 0,
"isNullable": false,
"dartType": "String"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "loose",
"fieldName": "loose",
"columnType": 1,
"isNullable": false,
"dartType": "bool"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "start1",
"fieldName": "start1",
"columnType": 0,
"isNullable": false,
"dartType": "String"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "start2",
"fieldName": "start2",
"columnType": 0,
"isNullable": false,
"dartType": "String"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "start3",
"fieldName": "start3",
"columnType": 0,
"isNullable": false,
"dartType": "String"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "sourceId",
"fieldName": "sourceId",
"columnType": 0,
"isNullable": false,
"dartType": "String"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "rank",
"fieldName": "rank",
"columnType": 6,
"isNullable": false,
"dartType": "int"
}
],
"foreignKeys": [],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "dictionary_search_key_start_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "source"
},
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "glossLanguage"
},
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "start1"
},
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "start2"
},
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "start3"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"managed": true
}
}
],
"warnings": [],
"migrationApiVersion": 1
}

View file

@ -0,0 +1,64 @@
BEGIN;
--
-- ACTION CREATE TABLE
--
CREATE TABLE "dictionary_search_key" (
"id" bigserial PRIMARY KEY,
"source" text NOT NULL,
"glossLanguage" text NOT NULL,
"key" text NOT NULL,
"loose" boolean NOT NULL,
"start1" text NOT NULL,
"start2" text NOT NULL,
"start3" text NOT NULL,
"sourceId" text NOT NULL,
"rank" bigint NOT NULL
);
-- Indexes
CREATE INDEX "dictionary_search_key_start_idx" ON "dictionary_search_key" USING btree ("source", "glossLanguage", "start1", "start2", "start3");
--
-- MIGRATION VERSION FOR halkyon_learn
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('halkyon_learn', '20261017110130653', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20261017110130653', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod', '20260824182259319', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260824182259319', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_auth_idp
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_auth_idp', '20260924105404509', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260924105404509', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_offline_sync
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_offline_sync', '20260914143806119', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260914143806119', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_auth_core
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_auth_core', '20260924105232991', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260924105232991', "timestamp" = now();
COMMIT;

View file

@ -31,3 +31,4 @@
20261015171326465-email-sign-in 20261015171326465-email-sign-in
20261015220121195-verified-links 20261015220121195-verified-links
20261015220626303-discussions 20261015220626303-discussions
20261017110130653

View file

@ -131,6 +131,10 @@ void main() {
tearDown(() async { tearDown(() async {
await DictionaryEntry.db.deleteWhere(session, where: (t) => t.id > 0); await DictionaryEntry.db.deleteWhere(session, where: (t) => t.id > 0);
await DictionaryKey.db.deleteWhere(session, where: (t) => t.id > 0); await DictionaryKey.db.deleteWhere(session, where: (t) => t.id > 0);
await DictionarySearchKey.db.deleteWhere(
session,
where: (t) => t.id > 0,
);
await DictionarySource.db.deleteWhere( await DictionarySource.db.deleteWhere(
session, session,
where: (t) => t.id > 0, where: (t) => t.id > 0,
@ -397,6 +401,52 @@ void main() {
expect(await search('|'), isEmpty); expect(await search('|'), isEmpty);
}); });
test('a search finds a key by its first character, its first two, '
'three or more, a character beyond 16 bits counting as one, and '
'a key shorter than the query never', () async {
final shikaru = entry(
'1590120',
'𠮟る',
reading: 'しかる',
rank: 120000,
keys: ['𠮟る', 'しかる', 'shikaru', 'to scold', 'scold'],
);
await loadDictionaryFile(session, write([taberu, terebi, shikaru]));
expect(await search('𠮟'), ['𠮟る']);
expect(await search('𠮟る'), ['𠮟る']);
expect(await search('t'), ['食べる', 'テレビ', '𠮟る']);
expect(await search('te'), ['テレビ']);
expect(await search('tel'), ['テレビ']);
expect(await search('tele'), ['テレビ']);
expect(await search('televisions'), isEmpty);
expect(await search('scold'), ['𠮟る']);
});
test('search keys go with their dictionary: replaced with it, and a '
'dictionary loaded before they had rows is searched as before '
'until it loads again', () async {
final file = write([taberu, terebi]);
await loadDictionaryFile(session, file);
// Every key and loose key, "to eat" with its space too.
expect(await DictionarySearchKey.db.count(session), 10);
write([terebi], head: header(version: '3.6.3 (2026-11-01)'));
await loadDictionaryFile(session, file);
expect(await search('taberu'), isEmpty);
expect(await DictionarySearchKey.db.count(session), 5);
await DictionarySearchKey.db.deleteWhere(
session,
where: (t) => t.id > 0,
);
expect(await search('tele'), ['テレビ']);
expect(await loadDictionaryFile(session, file), isTrue);
expect(await DictionarySearchKey.db.count(session), 5);
expect(await search('tele'), ['テレビ']);
expect(await loadDictionaryFile(session, file), isFalse);
});
test('loads asked for at once leave the dictionary loaded once, ' test('loads asked for at once leave the dictionary loaded once, '
'whole', () async { 'whole', () async {
write([taberu, terebi]); write([taberu, terebi]);
@ -449,6 +499,13 @@ void main() {
), ),
0, 0,
); );
expect(
await DictionarySearchKey.db.count(
session,
where: (t) => t.source.equals('other'),
),
0,
);
}); });
test('two files of one source are refused, and neither loads, as ' test('two files of one source are refused, and neither loads, as '