Implement full text search

This commit is contained in:
Kirill Kamakin
2022-11-13 09:52:52 +01:00
parent 21abf38282
commit 79631a7eb0
12 changed files with 285 additions and 12 deletions

View File

@@ -12,4 +12,6 @@ interface RecipeRepo {
suspend fun refreshRecipeInfo(recipeSlug: String): Result<Unit>
suspend fun loadRecipeInfo(recipeId: String): FullRecipeEntity?
fun setSearchName(name: String?)
}

View File

@@ -9,7 +9,7 @@ import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
interface RecipeStorage {
suspend fun saveRecipes(recipes: List<RecipeSummaryInfo>)
fun queryRecipes(): PagingSource<Int, RecipeSummaryEntity>
fun queryRecipes(query: String?): PagingSource<Int, RecipeSummaryEntity>
suspend fun refreshAll(recipes: List<RecipeSummaryInfo>)

View File

@@ -35,9 +35,11 @@ class RecipeStorageImpl @Inject constructor(
}
override fun queryRecipes(): PagingSource<Int, RecipeSummaryEntity> {
logger.v { "queryRecipes() called" }
return recipeDao.queryRecipesByPages()
override fun queryRecipes(query: String?): PagingSource<Int, RecipeSummaryEntity> {
logger.v { "queryRecipes() called with: query = $query" }
return if (query == null) recipeDao.queryRecipesByPages()
else recipeDao.queryRecipesByPages(query)
}
override suspend fun refreshAll(recipes: List<RecipeSummaryInfo>) {

View File

@@ -0,0 +1,8 @@
package gq.kirmanak.mealient.data.recipes.impl
import androidx.paging.PagingSource
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
interface RecipePagingSourceFactory : () -> PagingSource<Int, RecipeSummaryEntity> {
fun setQuery(newQuery: String?)
}

View File

@@ -0,0 +1,29 @@
package gq.kirmanak.mealient.data.recipes.impl
import androidx.paging.PagingSource
import gq.kirmanak.mealient.data.recipes.db.RecipeStorage
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.logging.Logger
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RecipePagingSourceFactoryImpl @Inject constructor(
private val recipeStorage: RecipeStorage,
private val logger: Logger,
) : RecipePagingSourceFactory {
private val query = AtomicReference<String>(null)
override fun invoke(): PagingSource<Int, RecipeSummaryEntity> {
val currentQuery = query.get()
logger.d { "Creating paging source, query is $currentQuery" }
return recipeStorage.queryRecipes(currentQuery)
}
override fun setQuery(newQuery: String?) {
logger.v { "setQuery() called with: newQuery = $newQuery" }
query.set(newQuery)
}
}

View File

@@ -19,10 +19,12 @@ import javax.inject.Singleton
class RecipeRepoImpl @Inject constructor(
private val mediator: RecipesRemoteMediator,
private val storage: RecipeStorage,
private val pagingSourceFactory: InvalidatingPagingSourceFactory<Int, RecipeSummaryEntity>,
private val pagingSourceFactory: RecipePagingSourceFactory,
private val invalidatingPagingSourceFactory: InvalidatingPagingSourceFactory<Int, RecipeSummaryEntity>,
private val dataSource: RecipeDataSource,
private val logger: Logger,
) : RecipeRepo {
override fun createPager(): Pager<Int, RecipeSummaryEntity> {
logger.v { "createPager() called" }
val pagingConfig = PagingConfig(pageSize = 5, enablePlaceholders = true)
@@ -53,4 +55,10 @@ class RecipeRepoImpl @Inject constructor(
logger.v { "loadRecipeInfo() returned: $recipeInfo" }
return recipeInfo
}
override fun setSearchName(name: String?) {
logger.v { "setSearchName() called with: name = $name" }
pagingSourceFactory.setQuery(name)
invalidatingPagingSourceFactory.invalidate()
}
}

View File

@@ -13,9 +13,7 @@ import gq.kirmanak.mealient.data.network.MealieDataSourceWrapper
import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.data.recipes.db.RecipeStorage
import gq.kirmanak.mealient.data.recipes.db.RecipeStorageImpl
import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProvider
import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProviderImpl
import gq.kirmanak.mealient.data.recipes.impl.RecipeRepoImpl
import gq.kirmanak.mealient.data.recipes.impl.*
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.ui.recipes.images.RecipeModelLoaderFactory
@@ -46,13 +44,17 @@ interface RecipeModule {
@Singleton
fun bindModelLoaderFactory(recipeModelLoaderFactory: RecipeModelLoaderFactory): ModelLoaderFactory<RecipeSummaryEntity, InputStream>
@Binds
@Singleton
fun bindRecipePagingSourceFactory(recipePagingSourceFactoryImpl: RecipePagingSourceFactoryImpl): RecipePagingSourceFactory
companion object {
@Provides
@Singleton
fun provideRecipePagingSourceFactory(
recipeStorage: RecipeStorage
) = InvalidatingPagingSourceFactory { recipeStorage.queryRecipes() }
factory: RecipePagingSourceFactory,
) = InvalidatingPagingSourceFactory(factory)
@Provides
@Singleton

View File

@@ -6,6 +6,7 @@ import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage
import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -18,6 +19,7 @@ class MainActivityViewModel @Inject constructor(
private val logger: Logger,
private val disclaimerStorage: DisclaimerStorage,
private val serverInfoRepo: ServerInfoRepo,
private val recipeRepo: RecipeRepo,
) : ViewModel() {
private val _uiState = MutableLiveData(MainActivityUiState())
@@ -55,5 +57,6 @@ class MainActivityViewModel @Inject constructor(
fun onSearchQuery(query: String) {
logger.v { "onSearchQuery() called with: query = $query" }
recipeRepo.setSearchName(query)
}
}

View File

@@ -0,0 +1,201 @@
{
"formatVersion": 1,
"database": {
"version": 7,
"identityHash": "1def22b22cb1f09a27de1b3188b857d2",
"entities": [
{
"tableName": "recipe_summaries",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`remote_id` TEXT NOT NULL, `name` TEXT NOT NULL, `slug` TEXT NOT NULL, `description` TEXT NOT NULL, `date_added` INTEGER NOT NULL, `date_updated` INTEGER NOT NULL, `image_id` TEXT, PRIMARY KEY(`remote_id`))",
"fields": [
{
"fieldPath": "remoteId",
"columnName": "remote_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "slug",
"columnName": "slug",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "dateAdded",
"columnName": "date_added",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dateUpdated",
"columnName": "date_updated",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "imageId",
"columnName": "image_id",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"remote_id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "recipe",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`remote_id` TEXT NOT NULL, `recipe_yield` TEXT NOT NULL, PRIMARY KEY(`remote_id`))",
"fields": [
{
"fieldPath": "remoteId",
"columnName": "remote_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "recipeYield",
"columnName": "recipe_yield",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"remote_id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "recipe_ingredient",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`local_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `recipe_id` TEXT NOT NULL, `note` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "localId",
"columnName": "local_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "recipeId",
"columnName": "recipe_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "note",
"columnName": "note",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"local_id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "recipe_instruction",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`local_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `recipe_id` TEXT NOT NULL, `text` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "localId",
"columnName": "local_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "recipeId",
"columnName": "recipe_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"local_id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"ftsVersion": "FTS4",
"ftsOptions": {
"tokenizer": "simple",
"tokenizerArgs": [],
"contentTable": "recipe_summaries",
"languageIdColumnName": "",
"matchInfo": "FTS4",
"notIndexedColumns": [],
"prefixSizes": [],
"preferredOrder": "ASC"
},
"contentSyncTriggers": [
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_recipe_summaries_fts_BEFORE_UPDATE BEFORE UPDATE ON `recipe_summaries` BEGIN DELETE FROM `recipe_summaries_fts` WHERE `docid`=OLD.`rowid`; END",
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_recipe_summaries_fts_BEFORE_DELETE BEFORE DELETE ON `recipe_summaries` BEGIN DELETE FROM `recipe_summaries_fts` WHERE `docid`=OLD.`rowid`; END",
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_recipe_summaries_fts_AFTER_UPDATE AFTER UPDATE ON `recipe_summaries` BEGIN INSERT INTO `recipe_summaries_fts`(`docid`, `remote_id`, `name`) VALUES (NEW.`rowid`, NEW.`remote_id`, NEW.`name`); END",
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_recipe_summaries_fts_AFTER_INSERT AFTER INSERT ON `recipe_summaries` BEGIN INSERT INTO `recipe_summaries_fts`(`docid`, `remote_id`, `name`) VALUES (NEW.`rowid`, NEW.`remote_id`, NEW.`name`); END"
],
"tableName": "recipe_summaries_fts",
"createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`remote_id` TEXT NOT NULL, `name` TEXT NOT NULL, content=`recipe_summaries`)",
"fields": [
{
"fieldPath": "remoteId",
"columnName": "remote_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1def22b22cb1f09a27de1b3188b857d2')"
]
}
}

View File

@@ -6,12 +6,13 @@ import gq.kirmanak.mealient.database.recipe.RecipeDao
import gq.kirmanak.mealient.database.recipe.entity.*
@Database(
version = 6,
version = 7,
entities = [
RecipeSummaryEntity::class,
RecipeEntity::class,
RecipeIngredientEntity::class,
RecipeInstructionEntity::class
RecipeInstructionEntity::class,
RecipeSummaryFtsEntity::class,
],
exportSchema = true,
autoMigrations = [
@@ -19,6 +20,7 @@ import gq.kirmanak.mealient.database.recipe.entity.*
AutoMigration(from = 3, to = 4),
AutoMigration(from = 4, to = 5, spec = AppDb.From4To5Migration::class),
AutoMigration(from = 5, to = 6, spec = AppDb.From5To6Migration::class),
AutoMigration(from = 6, to = 7),
]
)
@TypeConverters(RoomTypeConverters::class)

View File

@@ -9,6 +9,9 @@ interface RecipeDao {
@Query("SELECT * FROM recipe_summaries ORDER BY date_added DESC")
fun queryRecipesByPages(): PagingSource<Int, RecipeSummaryEntity>
@Query("SELECT * FROM recipe_summaries JOIN recipe_summaries_fts ON recipe_summaries_fts.remote_id == recipe_summaries.remote_id WHERE recipe_summaries_fts.name MATCH :query ORDER BY date_added DESC")
fun queryRecipesByPages(query: String): PagingSource<Int, RecipeSummaryEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertRecipe(recipeSummaryEntity: RecipeSummaryEntity)

View File

@@ -0,0 +1,13 @@
package gq.kirmanak.mealient.database.recipe.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Fts4
@Entity(tableName = "recipe_summaries_fts")
@Fts4(contentEntity = RecipeSummaryEntity::class)
data class RecipeSummaryFtsEntity(
@ColumnInfo(name = "remote_id") val remoteId: String,
@ColumnInfo(name = "name") val name: String,
)