Correct schema

Meaningful queries
Proper transactional reads
This commit is contained in:
genki
2025-12-24 22:48:34 -05:00
parent c10cbf373f
commit c458e08075
23 changed files with 759 additions and 66 deletions

View File

@@ -0,0 +1,47 @@
package com.placeholder.sherpai2.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
import com.placeholder.sherpai2.data.local.dao.EventDao
import com.placeholder.sherpai2.data.local.dao.ImageAggregateDao
import com.placeholder.sherpai2.data.local.dao.ImageDao
import com.placeholder.sherpai2.data.local.dao.ImageEventDao
import com.placeholder.sherpai2.data.local.dao.ImagePersonDao
import com.placeholder.sherpai2.data.local.dao.ImageTagDao
import com.placeholder.sherpai2.data.local.dao.PersonDao
import com.placeholder.sherpai2.data.local.dao.TagDao
import com.placeholder.sherpai2.data.local.entity.EventEntity
import com.placeholder.sherpai2.data.local.entity.ImageEntity
import com.placeholder.sherpai2.data.local.entity.ImageEventEntity
import com.placeholder.sherpai2.data.local.entity.ImagePersonEntity
import com.placeholder.sherpai2.data.local.entity.ImageTagEntity
import com.placeholder.sherpai2.data.local.entity.PersonEntity
import com.placeholder.sherpai2.data.local.entity.TagEntity
@Database(
entities = [
ImageEntity::class,
TagEntity::class,
PersonEntity::class,
EventEntity::class,
ImageTagEntity::class,
ImagePersonEntity::class,
ImageEventEntity::class
],
version = 1,
exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
abstract fun imageDao(): ImageDao
abstract fun tagDao(): TagDao
abstract fun personDao(): PersonDao
abstract fun eventDao(): EventDao
abstract fun imageTagDao(): ImageTagDao
abstract fun imagePersonDao(): ImagePersonDao
abstract fun imageEventDao(): ImageEventDao
abstract fun imageAggregateDao(): ImageAggregateDao
}

View File

@@ -0,0 +1,26 @@
package com.placeholder.sherpai2.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.placeholder.sherpai2.data.local.entity.EventEntity
@Dao
interface EventDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(event: EventEntity)
/**
* Find events covering a timestamp.
*
* This is the backbone of auto-tagging by date.
*/
@Query("""
SELECT * FROM events
WHERE :timestamp BETWEEN startDate AND endDate
AND isHidden = 0
""")
suspend fun findEventsForTimestamp(timestamp: Long): List<EventEntity>
}

View File

@@ -0,0 +1,25 @@
package com.placeholder.sherpai2.data.local.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import com.placeholder.sherpai2.data.local.model.ImageWithEverything
import kotlinx.coroutines.flow.Flow
@Dao
interface ImageAggregateDao {
/**
* Observe a fully-hydrated image object.
*
* This is what your detail screen should use.
*/
@Transaction
@Query("""
SELECT * FROM images
WHERE imageId = :imageId
""")
fun observeImageWithEverything(
imageId: String
): Flow<ImageWithEverything>
}

View File

@@ -0,0 +1,56 @@
package com.placeholder.sherpai2.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.placeholder.sherpai2.data.local.entity.ImageEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface ImageDao {
/**
* Insert images.
*
* IGNORE prevents duplicate insertion
* when sha256 or imageUri already exists.
*/
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertImages(images: List<ImageEntity>)
/**
* Get image by ID.
*/
@Query("SELECT * FROM images WHERE imageId = :imageId")
suspend fun getImageById(imageId: String): ImageEntity?
/**
* Stream images ordered by capture time (newest first).
*
* Flow is critical:
* - UI auto-updates
* - No manual refresh
*/
@Query("""
SELECT * FROM images
ORDER BY capturedAt DESC
""")
fun observeAllImages(): Flow<List<ImageEntity>>
/**
* Fetch images in a time range.
* Used for:
* - event auto-assignment
* - timeline views
*/
@Query("""
SELECT * FROM images
WHERE capturedAt BETWEEN :start AND :end
ORDER BY capturedAt ASC
""")
suspend fun getImagesInRange(
start: Long,
end: Long
): List<ImageEntity>
}

View File

@@ -0,0 +1,23 @@
package com.placeholder.sherpai2.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.placeholder.sherpai2.data.local.entity.ImageEventEntity
@Dao
interface ImageEventDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(entity: ImageEventEntity)
/**
* Images associated with an event.
*/
@Query("""
SELECT imageId FROM image_events
WHERE eventId = :eventId
""")
suspend fun findImagesForEvent(eventId: String): List<String>
}

View File

@@ -0,0 +1,25 @@
package com.placeholder.sherpai2.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.placeholder.sherpai2.data.local.entity.ImagePersonEntity
@Dao
interface ImagePersonDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(entity: ImagePersonEntity)
/**
* All images containing a specific person.
*/
@Query("""
SELECT imageId FROM image_persons
WHERE personId = :personId
AND visibility = 'PUBLIC'
AND confirmed = 1
""")
suspend fun findImagesForPerson(personId: String): List<String>
}

View File

@@ -0,0 +1,41 @@
package com.placeholder.sherpai2.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.placeholder.sherpai2.data.local.entity.ImageTagEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface ImageTagDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(imageTag: ImageTagEntity)
/**
* Observe tags for an image.
*/
@Query("""
SELECT * FROM image_tags
WHERE imageId = :imageId
AND visibility != 'HIDDEN'
""")
fun observeTagsForImage(imageId: String): Flow<List<ImageTagEntity>>
/**
* Find images by tag.
*
* This is your primary tag-search query.
*/
@Query("""
SELECT imageId FROM image_tags
WHERE tagId = :tagId
AND visibility = 'PUBLIC'
AND confidence >= :minConfidence
""")
suspend fun findImagesByTag(
tagId: String,
minConfidence: Float = 0.5f
): List<String>
}

View File

@@ -0,0 +1,24 @@
package com.placeholder.sherpai2.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.placeholder.sherpai2.data.local.entity.PersonEntity
@Dao
interface PersonDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(person: PersonEntity)
@Query("SELECT * FROM persons WHERE personId = :personId")
suspend fun getById(personId: String): PersonEntity?
@Query("""
SELECT * FROM persons
WHERE isHidden = 0
ORDER BY displayName
""")
suspend fun getVisiblePeople(): List<PersonEntity>
}

View File

@@ -0,0 +1,24 @@
package com.placeholder.sherpai2.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.placeholder.sherpai2.data.local.entity.TagEntity
@Dao
interface TagDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(tag: TagEntity)
/**
* Resolve a tag by value.
* Example: "park"
*/
@Query("SELECT * FROM tags WHERE value = :value LIMIT 1")
suspend fun getByValue(value: String): TagEntity?
@Query("SELECT * FROM tags")
suspend fun getAll(): List<TagEntity>
}

View File

@@ -0,0 +1,44 @@
package com.placeholder.sherpai2.data.local.entity
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* Represents a meaningful event spanning a time range.
*
* Events allow auto-association of images by timestamp.
*/
@Entity(
tableName = "events",
indices = [
Index(value = ["startDate"]),
Index(value = ["endDate"])
]
)
data class EventEntity(
@PrimaryKey
val eventId: String,
val name: String,
/**
* Inclusive start date (UTC millis).
*/
val startDate: Long,
/**
* Inclusive end date (UTC millis).
*/
val endDate: Long,
val location: String?,
/**
* 0.0 1.0 importance weight
*/
val importance: Float,
val isHidden: Boolean
)

View File

@@ -0,0 +1,55 @@
package com.placeholder.sherpai2.data.local.entity
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* Represents a single image on the device.
*
* This entity is intentionally immutable:
* - imageUri identifies where the image lives
* - sha256 prevents duplicates
* - capturedAt is the EXIF timestamp
*
* This table should be append-only.
*/
@Entity(
tableName = "images",
indices = [
Index(value = ["imageUri"], unique = true),
Index(value = ["sha256"], unique = true),
Index(value = ["capturedAt"])
]
)
data class ImageEntity(
@PrimaryKey
val imageId: String,
val imageUri: String,
/**
* Cryptographic hash of image bytes.
* Used for deduplication and re-indexing.
*/
val sha256: String,
/**
* EXIF timestamp (UTC millis).
*/
val capturedAt: Long,
/**
* When this image was indexed into the app.
*/
val ingestedAt: Long,
val width: Int,
val height: Int,
/**
* CAMERA | SCREENSHOT | IMPORTED
*/
val source: String
)

View File

@@ -0,0 +1,42 @@
package com.placeholder.sherpai2.data.local.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
@Entity(
tableName = "image_events",
primaryKeys = ["imageId", "eventId"],
foreignKeys = [
ForeignKey(
entity = ImageEntity::class,
parentColumns = ["imageId"],
childColumns = ["imageId"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = EventEntity::class,
parentColumns = ["eventId"],
childColumns = ["eventId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index("eventId")
]
)
data class ImageEventEntity(
val imageId: String,
val eventId: String,
/**
* AUTO | MANUAL
*/
val source: String,
/**
* User override flag.
*/
val override: Boolean
)

View File

@@ -0,0 +1,40 @@
package com.placeholder.sherpai2.data.local.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
@Entity(
tableName = "image_persons",
primaryKeys = ["imageId", "personId"],
foreignKeys = [
ForeignKey(
entity = ImageEntity::class,
parentColumns = ["imageId"],
childColumns = ["imageId"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = PersonEntity::class,
parentColumns = ["personId"],
childColumns = ["personId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index("personId")
]
)
data class ImagePersonEntity(
val imageId: String,
val personId: String,
val confidence: Float,
val confirmed: Boolean,
/**
* PUBLIC | PRIVATE
*/
val visibility: String
)

View File

@@ -0,0 +1,56 @@
package com.placeholder.sherpai2.data.local.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
/**
* Join table linking images to tags.
*
* This is NOT optional.
* Do not inline tag lists on ImageEntity.
*/
@Entity(
tableName = "image_tags",
primaryKeys = ["imageId", "tagId"],
foreignKeys = [
ForeignKey(
entity = ImageEntity::class,
parentColumns = ["imageId"],
childColumns = ["imageId"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = TagEntity::class,
parentColumns = ["tagId"],
childColumns = ["tagId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index("tagId"),
Index("imageId")
]
)
data class ImageTagEntity(
val imageId: String,
val tagId: String,
/**
* AUTO | MANUAL
*/
val source: String,
/**
* ML confidence (01).
*/
val confidence: Float,
/**
* PUBLIC | PRIVATE | HIDDEN
*/
val visibility: String,
val createdAt: Long
)

View File

@@ -0,0 +1,30 @@
package com.placeholder.sherpai2.data.local.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Represents a known person.
*
* People are separate from generic tags because:
* - face embeddings
* - privacy rules
* - identity merging
*/
@Entity(tableName = "persons")
data class PersonEntity(
@PrimaryKey
val personId: String,
val displayName: String,
/**
* Reference to face embedding storage (ML layer).
*/
val faceEmbeddingId: String?,
val isHidden: Boolean,
val createdAt: Long
)

View File

@@ -0,0 +1,30 @@
package com.placeholder.sherpai2.data.local.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Represents a conceptual tag.
*
* Tags are normalized so that:
* - "park" exists once
* - many images can reference it
*/
@Entity(tableName = "tags")
data class TagEntity(
@PrimaryKey
val tagId: String,
/**
* GENERIC | SYSTEM | HIDDEN
*/
val type: String,
/**
* Human-readable value, e.g. "park", "sunset"
*/
val value: String,
val createdAt: Long
)

View File

@@ -0,0 +1,29 @@
package com.placeholder.sherpai2.data.local.model
import androidx.room.Embedded
import androidx.room.Relation
import com.placeholder.sherpai2.data.local.entity.*
data class ImageWithEverything(
@Embedded
val image: ImageEntity,
@Relation(
parentColumn = "imageId",
entityColumn = "imageId"
)
val tags: List<ImageTagEntity>,
@Relation(
parentColumn = "imageId",
entityColumn = "imageId"
)
val persons: List<ImagePersonEntity>,
@Relation(
parentColumn = "imageId",
entityColumn = "imageId"
)
val events: List<ImageEventEntity>
)

View File

@@ -0,0 +1,18 @@
package com.placeholder.sherpai2.data.local.model
import androidx.room.Embedded
import androidx.room.Relation
import com.placeholder.sherpai2.data.local.entity.ImageEntity
import com.placeholder.sherpai2.data.local.entity.ImageTagEntity
data class ImageWithTags(
@Embedded
val image: ImageEntity,
@Relation(
parentColumn = "imageId",
entityColumn = "imageId"
)
val tags: List<ImageTagEntity>
)

View File

@@ -0,0 +1,28 @@
package com.placeholder.sherpai2.di
import android.content.Context
import androidx.room.Room
import com.placeholder.sherpai2.data.local.AppDatabase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(
@ApplicationContext context: Context
): AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java,
"sherpai.db"
).build()
}
}