Folder search lowercases the query term, but album_path is VARBINARY and compared byte-exact (case-sensitive) on MySQL/MariaDB, so a lowercased term no longer matched uppercase folder paths. A parent folder still appeared via its case-insensitive title, while child folders - matchable only by path - dropped out of the results. Add a dialect-aware PathLike helper: MySQL converts album_path to a case-insensitive collation for the LIKE, SQLite and other dialects fall back to a plain LIKE (SQLite is already ASCII case-insensitive). Only the folder search filter is affected; byte-exact album_path identity lookups are unchanged, so emoji and case-distinct folders stay distinct. |
||
|---|---|---|
| .. | ||
| legacy | ||
| migrate | ||
| query | ||
| schema | ||
| search | ||
| sortby | ||
| testdata/album | ||
| album.go | ||
| album_cache.go | ||
| album_cache_test.go | ||
| album_fixtures.go | ||
| album_fixtures_test.go | ||
| album_test.go | ||
| album_user.go | ||
| album_yaml.go | ||
| album_yaml_test.go | ||
| auth_client.go | ||
| auth_client_add.go | ||
| auth_client_add_test.go | ||
| auth_client_data.go | ||
| auth_client_fixtures.go | ||
| auth_client_fixtures_test.go | ||
| auth_client_test.go | ||
| auth_session.go | ||
| auth_session_cache.go | ||
| auth_session_cache_test.go | ||
| auth_session_client.go | ||
| auth_session_client_test.go | ||
| auth_session_data.go | ||
| auth_session_data_test.go | ||
| auth_session_delete.go | ||
| auth_session_delete_test.go | ||
| auth_session_fixtures.go | ||
| auth_session_fixtures_test.go | ||
| auth_session_jwt.go | ||
| auth_session_jwt_test.go | ||
| auth_session_login.go | ||
| auth_session_login_test.go | ||
| auth_session_report.go | ||
| auth_session_report_test.go | ||
| auth_session_test.go | ||
| auth_tokens.go | ||
| auth_tokens_test.go | ||
| auth_user.go | ||
| auth_user_add.go | ||
| auth_user_cli.go | ||
| auth_user_default.go | ||
| auth_user_details.go | ||
| auth_user_details_test.go | ||
| auth_user_fixtures.go | ||
| auth_user_fixtures_test.go | ||
| auth_user_legacy.go | ||
| auth_user_legacy_test.go | ||
| auth_user_report.go | ||
| auth_user_report_test.go | ||
| auth_user_settings.go | ||
| auth_user_settings_test.go | ||
| auth_user_share.go | ||
| auth_user_share_fixtures.go | ||
| auth_user_share_fixtures_test.go | ||
| auth_user_share_test.go | ||
| auth_user_test.go | ||
| camera.go | ||
| camera_cache.go | ||
| camera_fixtures.go | ||
| camera_fixtures_test.go | ||
| camera_makes.go | ||
| camera_models.go | ||
| camera_test.go | ||
| camera_types.go | ||
| camera_types_test.go | ||
| category.go | ||
| category_fixtures.go | ||
| category_test.go | ||
| cell.go | ||
| cell_fixtures.go | ||
| cell_fixtures_test.go | ||
| cell_test.go | ||
| clip.go | ||
| clip_test.go | ||
| country.go | ||
| country_cache.go | ||
| country_fixtures.go | ||
| country_test.go | ||
| db.go | ||
| db_conn.go | ||
| db_conn_test.go | ||
| deprecated.go | ||
| details.go | ||
| details_fixtures.go | ||
| details_fixtures_test.go | ||
| details_test.go | ||
| duplicate.go | ||
| duplicate_test.go | ||
| entity.go | ||
| entity_const.go | ||
| entity_count.go | ||
| entity_count_test.go | ||
| entity_counts.go | ||
| entity_counts_test.go | ||
| entity_errors.go | ||
| entity_fixtures.go | ||
| entity_init.go | ||
| entity_save.go | ||
| entity_save_test.go | ||
| entity_tables.go | ||
| entity_test.go | ||
| entity_time.go | ||
| entity_time_test.go | ||
| entity_update.go | ||
| entity_update_test.go | ||
| entity_values.go | ||
| entity_values_test.go | ||
| error.go | ||
| face.go | ||
| face_fixtures.go | ||
| face_fixtures_test.go | ||
| face_test.go | ||
| faces.go | ||
| faces_test.go | ||
| file.go | ||
| file_fixtures.go | ||
| file_fixtures_test.go | ||
| file_json.go | ||
| file_json_test.go | ||
| file_metadata.go | ||
| file_share.go | ||
| file_share_fixtures.go | ||
| file_share_fixtures_test.go | ||
| file_share_test.go | ||
| file_sync.go | ||
| file_sync_fixtures.go | ||
| file_sync_fixtures_test.go | ||
| file_sync_test.go | ||
| file_test.go | ||
| fixtures.go | ||
| folder.go | ||
| folder_album_slug.go | ||
| folder_album_slug_test.go | ||
| folder_fixtures.go | ||
| folder_test.go | ||
| keyword.go | ||
| keyword_cache.go | ||
| keyword_cache_test.go | ||
| keyword_fixtures.go | ||
| keyword_fixtures_test.go | ||
| keyword_test.go | ||
| label.go | ||
| label_cache.go | ||
| label_cache_test.go | ||
| label_fixtures.go | ||
| label_fixtures_test.go | ||
| label_search.go | ||
| label_search_test.go | ||
| label_slug.go | ||
| label_slug_test.go | ||
| label_test.go | ||
| lens.go | ||
| lens_cache.go | ||
| lens_fixtures.go | ||
| lens_fixtures_test.go | ||
| lens_models.go | ||
| lens_test.go | ||
| link.go | ||
| link_fixtures.go | ||
| link_test.go | ||
| marker.go | ||
| marker_fixtures.go | ||
| marker_fixtures_test.go | ||
| marker_json.go | ||
| marker_json_test.go | ||
| marker_test.go | ||
| markers.go | ||
| markers_test.go | ||
| mysql8_test.go | ||
| passcode.go | ||
| passcode_fixtures.go | ||
| passcode_fixtures_test.go | ||
| passcode_json.go | ||
| passcode_json_test.go | ||
| passcode_test.go | ||
| password.go | ||
| password_fixtures.go | ||
| password_fixtures_test.go | ||
| password_test.go | ||
| photo.go | ||
| photo_album.go | ||
| photo_album_fixtures.go | ||
| photo_album_fixtures_test.go | ||
| photo_album_test.go | ||
| photo_caption.go | ||
| photo_caption_test.go | ||
| photo_datetime.go | ||
| photo_datetime_test.go | ||
| photo_estimate.go | ||
| photo_estimate_test.go | ||
| photo_fixtures.go | ||
| photo_fixtures_test.go | ||
| photo_interface.go | ||
| photo_keyword.go | ||
| photo_keyword_fixtures.go | ||
| photo_keyword_test.go | ||
| photo_label.go | ||
| photo_label_test.go | ||
| photo_location.go | ||
| photo_location_test.go | ||
| photo_merge.go | ||
| photo_merge_test.go | ||
| photo_optimize.go | ||
| photo_optimize_test.go | ||
| photo_quality.go | ||
| photo_quality_test.go | ||
| photo_reset.go | ||
| photo_reset_test.go | ||
| photo_scope_test.go | ||
| photo_test.go | ||
| photo_title.go | ||
| photo_title_test.go | ||
| photo_user.go | ||
| photo_vision.go | ||
| photo_yaml.go | ||
| photo_yaml_test.go | ||
| photos.go | ||
| photos_test.go | ||
| place.go | ||
| place_fixtures.go | ||
| place_fixtures_test.go | ||
| place_test.go | ||
| reaction.go | ||
| reaction_fixtures.go | ||
| reaction_test.go | ||
| README.md | ||
| service.go | ||
| service_fixtures.go | ||
| service_test.go | ||
| src.go | ||
| src_test.go | ||
| string_keyvalue.go | ||
| string_map.go | ||
| string_map_test.go | ||
| subject.go | ||
| subject_fixtures.go | ||
| subject_fixtures_test.go | ||
| subject_names.go | ||
| subject_person.go | ||
| subject_person_test.go | ||
| subject_test.go | ||
| subjects.go | ||
| subjects_test.go | ||
PhotoPrism — Database Entities
Last Updated: June 1, 2026
Overview
internal/entity holds the GORM models (Photo, File, Album, Label, Face, User, Client, Session, Service, Marker, …), their query and create/update helpers, the test fixtures (*_fixtures.go), and the migration helpers under migrate/. Models map to the database via GORM v1 (github.com/jinzhu/gorm) and are shared by the API, workers, and CLI.
Timestamps
Created and updated timestamps are stored as SQL DATETIME without fractional seconds (DATETIME_PRECISION = 0). To keep in-memory and persisted values in sync, the package sets GORM's timestamp source to second precision in db.go:
gorm.NowFunc = Now // entity.Now() == UTC().Truncate(time.Second)
Time helpers in entity_time.go:
UTC()— current time in UTC, full sub-second precision. Use for elapsed-time measurements, not for values that get persisted.Now()— UTC truncated to whole seconds. This is what GORM writes tocreated_at/updated_at.TimeStamp()— pointer toNow(), for nullable*time.Timecolumns.Time(s)— parses an RFC 3339 string to a second-precision UTC time, ornil.
Implications:
- Do not rely on sub-second ordering of persisted timestamps. Two rows created and updated within the same wall-clock second compare equal, so
created_at/updated_atcannot disambiguate them. There is no monotonic auto-increment ID on UID-keyed models (e.g.Client), so there is no reliable intra-second tiebreaker — give rows distinct times when ordering must be deterministic. - Because both SQLite and MariaDB now receive second-precision values, timestamp behavior is identical across drivers. A timestamp assertion that passes on SQLite will pass on MariaDB.
When a test needs to prove a write advanced a timestamp, prefer one of:
-
Seed the starting value clearly in the past (e.g.
Now().Add(-time.Hour)) and assert the new value is greater. This stays meaningful and distinguishes a real bump from a no-op. -
Compare with
Time.Sub()and assert the difference falls in a sane range, rather than a strictBefore/After. A same-second save legitimately yields a zero delta:elapsed := after.Sub(before) assert.GreaterOrEqual(t, elapsed, time.Duration(0)) assert.Less(t, elapsed, time.Minute)
Testing
Tests default to SQLite. To exercise the models against MariaDB (which is stricter and is the production database for some subsystems such as the cluster registry):
mysql < scripts/sql/reset-acceptance.sql
PHOTOPRISM_TEST_DRIVER="mysql" \
PHOTOPRISM_TEST_DSN="root:photoprism@tcp(mariadb:4001)/acceptance?charset=utf8mb4,utf8&collation=utf8mb4_unicode_ci&parseTime=true" \
go test ./internal/entity/... -count=1 -tags="slow,develop"
MariaDB strict mode rejects inserts that SQLite quietly accepts, so a test that only ran on SQLite can fail here:
- Primary keys must be set. An empty PK (
""UID, zero ID) triggersError 1364: Field '<col>' doesn't have a default value. Use a valid ID/UID, not a placeholder like"1234". - Values must fit the column. Oversized strings give
Error 1406: Data too long; out-of-range integers giveError 1264: Out of range value(e.g.photo_idisINT UNSIGNED, max 4294967295). - UID format (see
pkg/rnd/uid.go): a one-byte prefix + 6 base36 time chars + 9 base36 random, 16 chars total (p…photo,a…album,c…client,u…user,l…label). Reuse existing fixtures for foreign-key safety; use a throwaway but in-range value only where a real reference would overwrite seeded data (e.g. a syntheticphoto_idso a Details row does not attach to a real photo). - Fixtures live in
*_fixtures.go, but some join rows are created indirectly from a parent fixture's embedded slice (e.g. aphotos_labelsrow from aPhotofixture'sLabels). Verify a combination is free against the seeded database, not just the fixtures file. List-style global queries (WHERE … <> ''with no per-test scope) are not isolated on the sharedacceptancedatabase: rows from other tests in the same run leak in, so alen(list) == Nassertion that holds on a per-test SQLite file can fail on MariaDB.
Collation & Emoji
MariaDB's utf8mb4_unicode_ci assigns most emoji the same collation weight, so an SQL =, <>, or LIKE on a utf8mb4 column treats distinct emoji as equal (e.g. test/🪞 matches test/🎃). SQLite compares text byte-exact, so this only reproduces on MariaDB.
utf8mb4columns that collapse:albums.album_title, display/name text (*_name,*_title).VARBINARYcolumns that stay byte-exact:albums.album_slug,albums.album_filter,albums.album_path,photos.photo_path, and every*_uid. Autf8mb4column compared against aVARBINARYcolumn is byte-exact (the binary operand wins).
The durable fix for an identity/path column is to make it VARBINARY — album_path is VARBINARY(1024) so it matches photos.photo_path and album_path = ? lookups are byte-exact at the database. Where a utf8mb4 column must stay, keep the SQL but re-verify the match byte-exact in Go before accepting it (see FindFolderAlbum / findFolderAlbumByPath, whose Go re-check is retained as defense-in-depth even now that album_path is VARBINARY). For self-join SQL where a Go re-check is awkward, HEX(col) = HEX(col) compares byte-exact on both MariaDB and SQLite. Legacy folder slugs drop emoji entirely (slug.Make("ins/🪞") == "ins") and long paths truncate to ClipSlug runes, so distinct folders can still collide on album_slug; folder albums are therefore deduplicated by album_filter (the byte-exact serialized path), not by slug (see query.RemoveDuplicateMoments).
VARBINARY Index Prefix Limit
InnoDB caps an index key prefix at 767 bytes on the COMPACT/REDUNDANT row formats, and only allows up to 3072 bytes on DYNAMIC/COMPRESSED. On a VARBINARY column the prefix is counted in bytes (on utf8mb4 it is counted in characters, i.e. up to 4 bytes each), so converting a long text column to VARBINARY can push an existing prefix index over the limit on older or non-DYNAMIC installs. Keep prefix indexes on long VARBINARY path/filter columns at ≤ 767 bytes; the project convention is 512 (albums.album_filter(512), albums.album_path(512)). A prefix index only narrows candidate rows — the full-column comparison stays exact — so a shorter prefix costs nothing for correctness.