Merge pull request #97 from sambecker/meta-queries

Streamline photo meta queries
This commit is contained in:
Sam Becker 2024-05-20 12:22:18 -05:00 committed by GitHub
commit 3b06fd3a16
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 599 additions and 676 deletions

View File

@ -1,6 +1,6 @@
import { getStorageUploadUrlsNoStore } from '@/services/storage/cache';
import {
getPhotosCountIncludingHiddenCached,
getPhotosMetaCached,
getPhotosMostRecentUpdateCached,
getUniqueTagsCached,
} from '@/photo/cache';
@ -18,7 +18,9 @@ export default async function AdminNav() {
countTags,
mostRecentPhotoUpdateTime,
] = await Promise.all([
getPhotosCountIncludingHiddenCached().catch(() => 0),
getPhotosMetaCached({ hidden: 'include' })
.then(({ count }) => count)
.catch(() => 0),
getStorageUploadUrlsNoStore()
.then(urls => urls.length)
.catch(e => {

View File

@ -1,15 +1,15 @@
import PhotoUpload from '@/photo/PhotoUpload';
import { clsx } from 'clsx/lite';
import SiteGrid from '@/components/SiteGrid';
import { getPhotosCountIncludingHiddenCached } from '@/photo/cache';
import AdminUploadsTable from '@/admin/AdminUploadsTable';
import { PRO_MODE_ENABLED } from '@/site/config';
import { getStoragePhotoUrlsNoStore } from '@/services/storage/cache';
import { getPhotos } from '@/photo/db';
import { getPhotos } from '@/photo/db/query';
import { revalidatePath } from 'next/cache';
import AdminPhotosTable from '@/admin/AdminPhotosTable';
import AdminPhotosTableInfinite from
'@/admin/AdminPhotosTableInfinite';
import { getPhotosMetaCached } from '@/photo/cache';
const DEBUG_PHOTO_BLOBS = false;
@ -27,7 +27,9 @@ export default async function AdminPhotosPage() {
sortBy: 'createdAt',
limit: INFINITE_SCROLL_INITIAL_ADMIN_PHOTOS,
}).catch(() => []),
getPhotosCountIncludingHiddenCached().catch(() => 0),
getPhotosMetaCached({ hidden: 'include'})
.then(({ count }) => count)
.catch(() => 0),
DEBUG_PHOTO_BLOBS
? getStoragePhotoUrlsNoStore()
: [],

View File

@ -4,7 +4,7 @@ import { getPhotosCached } from '@/photo/cache';
import TagForm from '@/tag/TagForm';
import { PATH_ADMIN, PATH_ADMIN_TAGS, pathForTag } from '@/site/paths';
import PhotoLightbox from '@/photo/PhotoLightbox';
import { getPhotosTagMeta } from '@/photo/db';
import { getPhotosMeta } from '@/photo/db/query';
import AdminTagBadge from '@/admin/AdminTagBadge';
const MAX_PHOTO_TO_SHOW = 6;
@ -22,7 +22,7 @@ export default async function PhotoPageEdit({
{ count },
photos,
] = await Promise.all([
getPhotosTagMeta(tag),
getPhotosMeta({ tag }),
getPhotosCached({ tag, limit: MAX_PHOTO_TO_SHOW }),
]);

View File

@ -14,7 +14,7 @@ import PhotoDetailPage from '@/photo/PhotoDetailPage';
import { ReactNode, cache } from 'react';
import { FilmSimulation } from '@/simulation';
import {
getPhotosFilmSimulationMetaCached,
getPhotosMetaCached,
getPhotosNearIdCached,
} from '@/photo/cache';
@ -70,8 +70,7 @@ export default async function PhotoFilmSimulationPage({
if (!photo) { redirect(PATH_ROOT); }
const { count, dateRange } =
await getPhotosFilmSimulationMetaCached(simulation);
const { count, dateRange } = await getPhotosMetaCached({ simulation });
return <>
{children}

View File

@ -6,7 +6,7 @@ import PhotosEmptyState from '@/photo/PhotosEmptyState';
import { Metadata } from 'next/types';
import PhotoGridSidebar from '@/photo/PhotoGridSidebar';
import { getPhotoSidebarData } from '@/photo/data';
import { getPhotos } from '@/photo/db';
import { getPhotos } from '@/photo/db/query';
import { cache } from 'react';
import PhotoGridPage from '@/photo/PhotoGridPage';
import { PATH_GRID } from '@/site/paths';

View File

@ -2,7 +2,8 @@ import {
INFINITE_SCROLL_GRID_PHOTO_INITIAL,
INFINITE_SCROLL_GRID_PHOTO_MULTIPLE,
} from '@/photo';
import { getPhotosCached, getPhotosCountCached } from '@/photo/cache';
import { getPhotosCached } from '@/photo/cache';
import { getPhotosMeta } from '@/photo/db/query';
import StaggeredOgPhotos from '@/photo/StaggeredOgPhotos';
import StaggeredOgPhotosInfinite from '@/photo/StaggeredOgPhotosInfinite';
@ -12,7 +13,9 @@ export default async function GridPage() {
count,
] = await Promise.all([
getPhotosCached({ limit: INFINITE_SCROLL_GRID_PHOTO_INITIAL }),
getPhotosCountCached(),
getPhotosMeta()
.then(({ count }) => count)
.catch(() => 0),
]);
return (

View File

@ -5,7 +5,8 @@ import { getIBMPlexMonoMedium } from '@/site/font';
import { ImageResponse } from 'next/og';
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
import { IS_PRODUCTION, STATICALLY_OPTIMIZED_OG_IMAGES } from '@/site/config';
import { GENERATE_STATIC_PARAMS_LIMIT, getPhotoIds } from '@/photo/db';
import { getPhotoIds } from '@/photo/db/query';
import { GENERATE_STATIC_PARAMS_LIMIT } from '@/photo/db';
import { isNextImageReadyBasedOnPhotos } from '@/photo';
export let generateStaticParams:

View File

@ -13,7 +13,8 @@ import {
import PhotoDetailPage from '@/photo/PhotoDetailPage';
import { getPhotosNearIdCached } from '@/photo/cache';
import { IS_PRODUCTION, STATICALLY_OPTIMIZED_PAGES } from '@/site/config';
import { GENERATE_STATIC_PARAMS_LIMIT, getPhotoIds } from '@/photo/db';
import { getPhotoIds } from '@/photo/db/query';
import { GENERATE_STATIC_PARAMS_LIMIT } from '@/photo/db';
import { ReactNode, cache } from 'react';
const getPhotosNearIdCachedCached = cache((photoId: string) =>

View File

@ -8,7 +8,7 @@ import { Metadata } from 'next/types';
import { MAX_PHOTOS_TO_SHOW_OG } from '@/image-response';
import PhotosLarge from '@/photo/PhotosLarge';
import { cache } from 'react';
import { getPhotos, getPhotosCount } from '@/photo/db';
import { getPhotos, getPhotosMeta } from '@/photo/db/query';
import PhotosLargeInfinite from '@/photo/PhotosLargeInfinite';
export const dynamic = 'force-static';
@ -32,7 +32,8 @@ export default async function HomePage() {
limit: INFINITE_SCROLL_LARGE_PHOTO_INITIAL,
})
.catch(() => []),
getPhotosCount()
getPhotosMeta()
.then(({ count }) => count)
.catch(() => 0),
]);

View File

@ -12,7 +12,7 @@ import {
} from '@/site/paths';
import PhotoDetailPage from '@/photo/PhotoDetailPage';
import {
getPhotosCameraMetaCached,
getPhotosMetaCached,
getPhotosNearIdCached,
} from '@/photo/cache';
import {
@ -79,7 +79,7 @@ export default async function PhotoCameraPage({
const camera = cameraFromPhoto(photo, { make, model });
const { count, dateRange } = await getPhotosCameraMetaCached(camera);
const { count, dateRange } = await getPhotosMetaCached({ camera });
return <>
{children}

View File

@ -11,11 +11,9 @@ import {
absolutePathForPhotoImage,
} from '@/site/paths';
import PhotoDetailPage from '@/photo/PhotoDetailPage';
import {
getPhotosNearIdCached,
getPhotosTagMetaCached,
} from '@/photo/cache';
import { getPhotosNearIdCached } from '@/photo/cache';
import { ReactNode, cache } from 'react';
import { getPhotosMeta } from '@/photo/db/query';
const getPhotosNearIdCachedCached = cache((photoId: string, tag: string) =>
getPhotosNearIdCached(
@ -66,7 +64,7 @@ export default async function PhotoTagPage({
if (!photo) { redirect(PATH_ROOT); }
const { count, dateRange } = await getPhotosTagMetaCached(tag);
const { count, dateRange } = await getPhotosMeta({ tag });
return <>
{children}

View File

@ -6,8 +6,8 @@ import {
import PhotoDetailPage from '@/photo/PhotoDetailPage';
import {
getPhotosNearIdCached,
getPhotosTagHiddenMetaCached,
} from '@/photo/cache';
import { getPhotosMeta } from '@/photo/db/query';
import { PATH_ROOT, absolutePathForPhoto } from '@/site/paths';
import { TAG_HIDDEN } from '@/tag';
import { Metadata } from 'next';
@ -59,7 +59,7 @@ export default async function PhotoTagHiddenPage({
if (!photo) { redirect(PATH_ROOT); }
const { count, dateRange } = await getPhotosTagHiddenMetaCached();
const { count, dateRange } = await getPhotosMeta({ hidden: 'only' });
return (
<PhotoDetailPage {...{

View File

@ -3,17 +3,18 @@ import Banner from '@/components/Banner';
import SiteGrid from '@/components/SiteGrid';
import PhotoGrid from '@/photo/PhotoGrid';
import { getPhotosNoStore } from '@/photo/cache';
import { getPhotosTagHiddenMeta } from '@/photo/db';
import { getPhotosMeta } from '@/photo/db/query';
import { absolutePathForTag } from '@/site/paths';
import { TAG_HIDDEN, descriptionForTaggedPhotos, titleForTag } from '@/tag';
import HiddenHeader from '@/tag/HiddenHeader';
import { Metadata } from 'next';
import { cache } from 'react';
const getPhotosTagHiddenMetaCached = cache(getPhotosTagHiddenMeta);
const getPhotosHiddenMetaCached = cache(() =>
getPhotosMeta({ hidden: 'only' }));
export async function generateMetadata(): Promise<Metadata> {
const { count, dateRange } = await getPhotosTagHiddenMetaCached();
const { count, dateRange } = await getPhotosHiddenMetaCached();
if (count === 0) { return {}; }
@ -47,7 +48,7 @@ export default async function HiddenTagPage() {
{ count, dateRange },
] = await Promise.all([
getPhotosNoStore({ hidden: 'only' }),
getPhotosTagHiddenMetaCached(),
getPhotosHiddenMetaCached(),
]);
return (

View File

@ -1,7 +1,7 @@
import { cameraFromPhoto, getCameraFromParams } from '.';
import {
getPhotosCached,
getPhotosCameraMetaCached,
getPhotosMetaCached,
} from '@/photo/cache';
export const getPhotosCameraDataCached = async (
@ -12,7 +12,7 @@ export const getPhotosCameraDataCached = async (
const camera = getCameraFromParams({ make, model });
return Promise.all([
getPhotosCached({ camera, limit }),
getPhotosCameraMetaCached(camera),
getPhotosMetaCached({ camera }),
])
.then(([photos, meta]) => [
photos,

View File

@ -1,15 +1,15 @@
'use server';
import {
GetPhotosOptions,
sqlDeletePhoto,
sqlInsertPhoto,
sqlDeletePhotoTagGlobally,
sqlUpdatePhoto,
sqlRenamePhotoTagGlobally,
deletePhoto,
insertPhoto,
deletePhotoTagGlobally,
updatePhoto,
renamePhotoTagGlobally,
getPhoto,
getPhotos,
} from '@/photo/db';
} from '@/photo/db/query';
import { GetPhotosOptions } from './db';
import {
PhotoFormData,
convertFormDataToPhotoDbInsert,
@ -22,7 +22,7 @@ import {
} from '@/services/storage';
import {
getPhotosCached,
getPhotosTagHiddenMetaCached,
getPhotosMetaCached,
revalidateAdminPaths,
revalidateAllKeysAndPaths,
revalidatePhoto,
@ -53,7 +53,7 @@ export const createPhotoAction = async (formData: FormData) =>
if (updatedUrl) {
photo.url = updatedUrl;
await sqlInsertPhoto(photo);
await insertPhoto(photo);
revalidateAllKeysAndPaths();
redirect(PATH_ADMIN_PHOTOS);
}
@ -71,7 +71,7 @@ export const updatePhotoAction = async (formData: FormData) =>
if (url) { photo.url = url; }
}
await sqlUpdatePhoto(photo);
await updatePhoto(photo);
revalidatePhoto(photo.id);
@ -89,7 +89,7 @@ export const toggleFavoritePhotoAction = async (
photo.tags = tags.some(tag => tag === TAG_FAVS)
? tags.filter(tag => !isTagFavs(tag))
: [...tags, TAG_FAVS];
await sqlUpdatePhoto(convertPhotoToPhotoDbInsert(photo));
await updatePhoto(convertPhotoToPhotoDbInsert(photo));
revalidateAllKeysAndPaths();
if (shouldRedirect) {
redirect(pathForPhoto(photoId));
@ -103,7 +103,7 @@ export const deletePhotoAction = async (
shouldRedirect?: boolean,
) =>
runAuthenticatedAdminServerAction(async () => {
await sqlDeletePhoto(photoId).then(() => deleteStorageUrl(photoUrl));
await deletePhoto(photoId).then(() => deleteStorageUrl(photoUrl));
revalidateAllKeysAndPaths();
if (shouldRedirect) {
redirect(PATH_ROOT);
@ -122,7 +122,7 @@ export const deletePhotoTagGloballyAction = async (formData: FormData) =>
runAuthenticatedAdminServerAction(async () => {
const tag = formData.get('tag') as string;
await sqlDeletePhotoTagGlobally(tag);
await deletePhotoTagGlobally(tag);
revalidatePhotosKey();
revalidateAdminPaths();
@ -134,7 +134,7 @@ export const renamePhotoTagGloballyAction = async (formData: FormData) =>
const updatedTag = formData.get('updatedTag') as string;
if (tag && updatedTag && tag !== updatedTag) {
await sqlRenamePhotoTagGlobally(tag, updatedTag);
await renamePhotoTagGlobally(tag, updatedTag);
revalidatePhotosKey();
revalidateTagsKey();
redirect(PATH_ADMIN_TAGS);
@ -185,7 +185,7 @@ export const syncPhotoExifDataAction = async (formData: FormData) =>
...convertPhotoToFormData(photo),
...photoFormExif,
});
await sqlUpdatePhoto(photoFormDbInsert);
await updatePhoto(photoFormDbInsert);
revalidatePhotosKey();
}
}
@ -205,21 +205,20 @@ export const streamAiImageQueryAction = async (
export const getImageBlurAction = async (url: string) =>
runAuthenticatedAdminServerAction(() => blurImageFromUrl(url));
export const getPhotosTagHiddenMetaCachedAction = async () =>
runAuthenticatedAdminServerAction(getPhotosTagHiddenMetaCached);
export const getPhotosHiddenMetaCachedAction = async () =>
runAuthenticatedAdminServerAction(() =>
getPhotosMetaCached({ hidden: 'only' }));
// Public/Private actions
export const getPhotosAction = async (options: GetPhotosOptions) =>
(options.hidden === 'include' || options.hidden === 'only')
? runAuthenticatedAdminServerAction(() =>
getPhotos(options))
? runAuthenticatedAdminServerAction(() => getPhotos(options))
: getPhotos(options);
export const getPhotosCachedAction = async (options: GetPhotosOptions) =>
(options.hidden === 'include' || options.hidden === 'only')
? runAuthenticatedAdminServerAction(() =>
getPhotosCached (options))
? runAuthenticatedAdminServerAction(() => getPhotosCached (options))
: getPhotosCached(options);
// Public actions

View File

@ -5,23 +5,17 @@ import {
unstable_noStore,
} from 'next/cache';
import {
GetPhotosOptions,
getPhoto,
getPhotos,
getPhotosCount,
getPhotosCountIncludingHidden,
getUniqueCameras,
getUniqueTags,
getPhotosTagMeta,
getPhotosCameraMeta,
getUniqueTagsHidden,
getUniqueFilmSimulations,
getPhotosFilmSimulationMeta,
getPhotosDateRange,
getPhotosNearId,
getPhotosMostRecentUpdate,
getPhotosTagHiddenMeta,
} from '@/photo/db';
getPhotosMeta,
} from '@/photo/db/query';
import { GetPhotosOptions } from './db';
import { parseCachedPhotoDates, parseCachedPhotosDates } from '@/photo';
import { createCameraKey } from '@/camera';
import {
@ -159,23 +153,12 @@ export const getPhotosNearIdCached = (
};
});
export const getPhotosDateRangeCached =
unstable_cache(
getPhotosDateRange,
[KEY_PHOTOS, KEY_DATE_RANGE],
);
export const getPhotosCountCached =
unstable_cache(
getPhotosCount,
[KEY_PHOTOS, KEY_COUNT],
);
export const getPhotosCountIncludingHiddenCached =
unstable_cache(
getPhotosCountIncludingHidden,
[KEY_PHOTOS, KEY_COUNT, KEY_HIDDEN],
);
export const getPhotosMetaCached = (
...args: Parameters<typeof getPhotosMeta>
) => unstable_cache(
getPhotosMeta,
[KEY_PHOTOS, KEY_COUNT, KEY_DATE_RANGE, ...getPhotosCacheKeys(...args)],
)(...args);
export const getPhotosMostRecentUpdateCached =
unstable_cache(
@ -183,30 +166,6 @@ export const getPhotosMostRecentUpdateCached =
[KEY_PHOTOS, KEY_COUNT, KEY_DATE_RANGE],
);
export const getPhotosTagMetaCached =
unstable_cache(
getPhotosTagMeta,
[KEY_PHOTOS, KEY_TAGS, KEY_DATE_RANGE],
);
export const getPhotosTagHiddenMetaCached =
unstable_cache(
getPhotosTagHiddenMeta,
[KEY_PHOTOS, KEY_TAGS, KEY_HIDDEN, KEY_DATE_RANGE],
);
export const getPhotosCameraMetaCached =
unstable_cache(
getPhotosCameraMeta,
[KEY_PHOTOS, KEY_CAMERAS, KEY_DATE_RANGE],
);
export const getPhotosFilmSimulationMetaCached =
unstable_cache(
getPhotosFilmSimulationMeta,
[KEY_PHOTOS, KEY_FILM_SIMULATIONS, KEY_DATE_RANGE],
);
export const getPhotoCached = (...args: Parameters<typeof getPhoto>) =>
unstable_cache(
getPhoto,

View File

@ -1,20 +1,22 @@
import {
getPhotosCountCached,
getPhotosMetaCached,
getUniqueCamerasCached,
getUniqueFilmSimulationsCached,
getUniqueTagsCached,
} from '@/photo/cache';
import {
getPhotosCount,
getPhotosMeta,
getUniqueCameras,
getUniqueFilmSimulations,
getUniqueTags,
} from '@/photo/db';
} from '@/photo/db/query';
import { SHOW_FILM_SIMULATIONS } from '@/site/config';
import { sortTagsObject } from '@/tag';
export const getPhotoSidebarData = () => [
getPhotosCount().catch(() => 0),
getPhotosMeta()
.then(({ count }) => count)
.catch(() => 0),
getUniqueTags().then(sortTagsObject).catch(() => []),
getUniqueCameras().catch(() => []),
SHOW_FILM_SIMULATIONS
@ -23,7 +25,9 @@ export const getPhotoSidebarData = () => [
] as const;
export const getPhotoSidebarDataCached = () => [
getPhotosCountCached(),
getPhotosMetaCached()
.then(({ count }) => count)
.catch(() => 0),
getUniqueTagsCached().then(sortTagsObject),
getUniqueCamerasCached(),
SHOW_FILM_SIMULATIONS ? getUniqueFilmSimulationsCached() : [],

View File

@ -1,560 +0,0 @@
import {
sql,
query,
convertArrayToPostgresString,
} from '@/services/postgres';
import {
PhotoDb,
PhotoDbInsert,
translatePhotoId,
parsePhotoFromDb,
Photo,
PhotoDateRange,
} from '@/photo';
import { Camera, Cameras, createCameraKey } from '@/camera';
import { parameterize } from '@/utility/string';
import { TagsWithMeta } from '@/tag';
import { FilmSimulation, FilmSimulations } from '@/simulation';
import { SHOULD_DEBUG_SQL, PRIORITY_ORDER_ENABLED } from '@/site/config';
export const GENERATE_STATIC_PARAMS_LIMIT = 1000;
const PHOTO_DEFAULT_LIMIT = 100;
const sqlCreatePhotosTable = () =>
sql`
CREATE TABLE IF NOT EXISTS photos (
id VARCHAR(8) PRIMARY KEY,
url VARCHAR(255) NOT NULL,
extension VARCHAR(255) NOT NULL,
aspect_ratio REAL DEFAULT 1.5,
blur_data TEXT,
title VARCHAR(255),
caption TEXT,
semantic_description TEXT,
tags VARCHAR(255)[],
make VARCHAR(255),
model VARCHAR(255),
focal_length SMALLINT,
focal_length_in_35mm_format SMALLINT,
f_number REAL,
iso SMALLINT,
exposure_time DOUBLE PRECISION,
exposure_compensation REAL,
location_name VARCHAR(255),
latitude DOUBLE PRECISION,
longitude DOUBLE PRECISION,
film_simulation VARCHAR(255),
priority_order REAL,
taken_at TIMESTAMP WITH TIME ZONE NOT NULL,
taken_at_naive VARCHAR(255) NOT NULL,
hidden BOOLEAN,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
)
`;
// Migration 01
const MIGRATION_FIELDS_01 = ['caption', 'semantic_description'];
const sqlRunMigration01 = () =>
sql`
ALTER TABLE photos
ADD COLUMN IF NOT EXISTS caption TEXT,
ADD COLUMN IF NOT EXISTS semantic_description TEXT
`;
// Must provide id as 8-character nanoid
export const sqlInsertPhoto = (photo: PhotoDbInsert) =>
safelyQueryPhotos(() => sql`
INSERT INTO photos (
id,
url,
extension,
aspect_ratio,
blur_data,
title,
caption,
semantic_description,
tags,
make,
model,
focal_length,
focal_length_in_35mm_format,
f_number,
iso,
exposure_time,
exposure_compensation,
location_name,
latitude,
longitude,
film_simulation,
priority_order,
hidden,
taken_at,
taken_at_naive
)
VALUES (
${photo.id},
${photo.url},
${photo.extension},
${photo.aspectRatio},
${photo.blurData},
${photo.title},
${photo.caption},
${photo.semanticDescription},
${convertArrayToPostgresString(photo.tags)},
${photo.make},
${photo.model},
${photo.focalLength},
${photo.focalLengthIn35MmFormat},
${photo.fNumber},
${photo.iso},
${photo.exposureTime},
${photo.exposureCompensation},
${photo.locationName},
${photo.latitude},
${photo.longitude},
${photo.filmSimulation},
${photo.priorityOrder},
${photo.hidden},
${photo.takenAt},
${photo.takenAtNaive}
)
`, 'sqlInsertPhoto');
export const sqlUpdatePhoto = (photo: PhotoDbInsert) =>
safelyQueryPhotos(() => sql`
UPDATE photos SET
url=${photo.url},
extension=${photo.extension},
aspect_ratio=${photo.aspectRatio},
blur_data=${photo.blurData},
title=${photo.title},
caption=${photo.caption},
semantic_description=${photo.semanticDescription},
tags=${convertArrayToPostgresString(photo.tags)},
make=${photo.make},
model=${photo.model},
focal_length=${photo.focalLength},
focal_length_in_35mm_format=${photo.focalLengthIn35MmFormat},
f_number=${photo.fNumber},
iso=${photo.iso},
exposure_time=${photo.exposureTime},
exposure_compensation=${photo.exposureCompensation},
location_name=${photo.locationName},
latitude=${photo.latitude},
longitude=${photo.longitude},
film_simulation=${photo.filmSimulation},
priority_order=${photo.priorityOrder || null},
hidden=${photo.hidden},
taken_at=${photo.takenAt},
taken_at_naive=${photo.takenAtNaive},
updated_at=${(new Date()).toISOString()}
WHERE id=${photo.id}
`, 'sqlUpdatePhoto');
export const sqlDeletePhotoTagGlobally = (tag: string) =>
safelyQueryPhotos(() => sql`
UPDATE photos
SET tags=ARRAY_REMOVE(tags, ${tag})
WHERE ${tag}=ANY(tags)
`, 'sqlDeletePhotoTagGlobally');
export const sqlRenamePhotoTagGlobally = (tag: string, updatedTag: string) =>
safelyQueryPhotos(() => sql`
UPDATE photos
SET tags=ARRAY_REPLACE(tags, ${tag}, ${updatedTag})
WHERE ${tag}=ANY(tags)
`, 'sqlRenamePhotoTagGlobally');
export const sqlDeletePhoto = (id: string) =>
safelyQueryPhotos(
() => sql`DELETE FROM photos WHERE id=${id}`,
'sqlDeletePhoto',
);
const sqlGetPhoto = (id: string, includeHidden?: boolean) => includeHidden
? sql<PhotoDb>`SELECT * FROM photos WHERE id=${id} LIMIT 1`
// eslint-disable-next-line max-len
: sql<PhotoDb>`SELECT * FROM photos WHERE id=${id} AND hidden IS NOT TRUE LIMIT 1`;
const sqlGetPhotosCount = async () => sql`
SELECT COUNT(*) FROM photos
WHERE hidden IS NOT TRUE
`.then(({ rows }) => parseInt(rows[0].count, 10));
const sqlGetPhotosCountIncludingHidden = async () => sql`
SELECT COUNT(*) FROM photos
`.then(({ rows }) => parseInt(rows[0].count, 10));
const sqlGetPhotosMostRecentUpdate = async () => sql`
SELECT updated_at FROM photos ORDER BY updated_at DESC LIMIT 1
`.then(({ rows }) => rows[0] ? rows[0].updated_at as Date : undefined);
const sqlGetPhotosDateRange = async () => sql`
SELECT MIN(taken_at_naive) as start, MAX(taken_at_naive) as end
FROM photos
WHERE hidden IS NOT TRUE
`.then(({ rows }) => rows[0]?.start && rows[0]?.end
? rows[0] as PhotoDateRange
: undefined);
const sqlGetPhotosTagMeta = async (tag: string) => sql`
SELECT COUNT(*), MIN(taken_at_naive) as start, MAX(taken_at_naive) as end
FROM photos
WHERE ${tag}=ANY(tags) AND
hidden IS NOT TRUE
`.then(({ rows }) => ({
count: parseInt(rows[0].count, 10),
...rows[0]?.start && rows[0]?.end
? { dateRange: rows[0] as PhotoDateRange }
: undefined,
}));
const sqlGetPhotosTagHiddenMeta = async () => sql`
SELECT COUNT(*), MIN(taken_at_naive) as start, MAX(taken_at_naive) as end
FROM photos
WHERE hidden IS TRUE
`.then(({ rows }) => ({
count: parseInt(rows[0].count, 10),
...rows[0]?.start && rows[0]?.end
? { dateRange: rows[0] as PhotoDateRange }
: undefined,
}));
const sqlGetPhotosCameraMeta = async (camera: Camera) => sql`
SELECT COUNT(*), MIN(taken_at_naive) as start, MAX(taken_at_naive) as end
FROM photos
WHERE
LOWER(REPLACE(make, ' ', '-'))=${parameterize(camera.make, true)} AND
LOWER(REPLACE(model, ' ', '-'))=${parameterize(camera.model, true)} AND
hidden IS NOT TRUE
`.then(({ rows }) => ({
count: parseInt(rows[0].count, 10),
...rows[0]?.start && rows[0]?.end
? { dateRange: rows[0] as PhotoDateRange }
: undefined,
}));
const sqlGetPhotosFilmSimulationMeta = async (
simulation: FilmSimulation,
) => sql`
SELECT COUNT(*), MIN(taken_at_naive) as start, MAX(taken_at_naive) as end
FROM photos
WHERE film_simulation=${simulation} AND
hidden IS NOT TRUE
`.then(({ rows }) => ({
count: parseInt(rows[0].count, 10),
...rows[0]?.start && rows[0]?.end
? { dateRange: rows[0] as PhotoDateRange }
: undefined,
}));
const sqlGetUniqueTags = async () => sql`
SELECT DISTINCT unnest(tags) as tag, COUNT(*)
FROM photos
WHERE hidden IS NOT TRUE
GROUP BY tag
ORDER BY tag ASC
`.then(({ rows }): TagsWithMeta => rows.map(({ tag, count }) => ({
tag: tag as string,
count: parseInt(count, 10),
})));
const sqlGetUniqueTagsHidden = async () => sql`
SELECT DISTINCT unnest(tags) as tag, COUNT(*)
FROM photos
GROUP BY tag
ORDER BY tag ASC
`.then(({ rows }): TagsWithMeta => rows.map(({ tag, count }) => ({
tag: tag as string,
count: parseInt(count, 10),
})));
const sqlGetUniqueCameras = async () => sql`
SELECT DISTINCT make||' '||model as camera, make, model, COUNT(*)
FROM photos
WHERE hidden IS NOT TRUE
AND trim(make) <> ''
AND trim(model) <> ''
GROUP BY make, model
ORDER BY camera ASC
`.then(({ rows }): Cameras => rows.map(({ make, model, count }) => ({
cameraKey: createCameraKey({ make, model }),
camera: { make, model },
count: parseInt(count, 10),
})));
const sqlGetUniqueFilmSimulations = async () => sql`
SELECT DISTINCT film_simulation, COUNT(*)
FROM photos
WHERE hidden IS NOT TRUE AND film_simulation IS NOT NULL
GROUP BY film_simulation
ORDER BY film_simulation ASC
`.then(({ rows }): FilmSimulations => rows
.map(({ film_simulation, count }) => ({
simulation: film_simulation as FilmSimulation,
count: parseInt(count, 10),
})));
export type GetPhotosOptions = {
sortBy?: 'createdAt' | 'takenAt' | 'priority'
limit?: number
offset?: number
query?: string
tag?: string
camera?: Camera
simulation?: FilmSimulation
takenBefore?: Date
takenAfterInclusive?: Date
hidden?: 'exclude' | 'include' | 'only'
}
const safelyQueryPhotos = async <T>(
callback: () => Promise<T>,
debugMessage: string
): Promise<T> => {
let result: T;
const start = new Date();
try {
result = await callback();
} catch (e: any) {
if (MIGRATION_FIELDS_01.some(field => new RegExp(
`column "${field}" of relation "photos" does not exist`,
'i',
).test(e.message))) {
console.log('Running migration 01 ...');
await sqlRunMigration01();
result = await callback();
} else if (/relation "photos" does not exist/i.test(e.message)) {
// If the table does not exist, create it
console.log('Creating photos table ...');
await sqlCreatePhotosTable();
result = await callback();
} else if (/endpoint is in transition/i.test(e.message)) {
console.log('sql get error: endpoint is in transition (setting timeout)');
// Wait 5 seconds and try again
await new Promise(resolve => setTimeout(resolve, 5000));
try {
result = await callback();
} catch (e: any) {
console.log(`sql get error on retry (after 5000ms): ${e.message} `);
throw e;
}
} else {
console.log(`sql get error: ${e.message} `);
throw e;
}
}
if (SHOULD_DEBUG_SQL && debugMessage) {
const time =
(((new Date()).getTime() - start.getTime()) / 1000).toFixed(2);
console.log(`Executing sql query: ${debugMessage} (${time} seconds)`);
}
return result;
};
const getWheresFromOptions = (options: GetPhotosOptions) => {
const {
hidden = 'exclude',
takenBefore,
takenAfterInclusive,
query,
tag,
camera,
simulation,
} = options;
const wheres = [] as string[];
const values = [] as (string | number)[];
let valuesIndex = 1;
switch (hidden) {
case 'exclude':
wheres.push('hidden IS NOT TRUE');
break;
case 'only':
wheres.push('hidden IS TRUE');
break;
}
if (takenBefore) {
wheres.push(`taken_at > $${valuesIndex++}`);
values.push(takenBefore.toISOString());
}
if (takenAfterInclusive) {
wheres.push(`taken_at <= $${valuesIndex++}`);
values.push(takenAfterInclusive.toISOString());
}
if (query) {
// eslint-disable-next-line max-len
wheres.push(`CONCAT(title, ' ', caption, ' ', semantic_description) ILIKE $${valuesIndex++}`);
values.push(`%${query.toLocaleLowerCase()}%`);
}
if (tag) {
wheres.push(`$${valuesIndex++}=ANY(tags)`);
values.push(tag);
}
if (camera) {
wheres.push(`LOWER(REPLACE(make, ' ', '-'))=$${valuesIndex++}`);
wheres.push(`LOWER(REPLACE(model, ' ', '-'))=$${valuesIndex++}`);
values.push(parameterize(camera.make, true));
values.push(parameterize(camera.model, true));
}
if (simulation) {
wheres.push(`film_simulation=$${valuesIndex++}`);
values.push(simulation);
}
return {
wheres: wheres.length > 0
? `WHERE ${wheres.join(' AND ')}`
: '',
values,
lastValuesIndex: valuesIndex,
};
};
export const getPhotos = async (options: GetPhotosOptions = {}) => {
const {
sortBy = PRIORITY_ORDER_ENABLED ? 'priority' : 'takenAt',
limit = PHOTO_DEFAULT_LIMIT,
offset = 0,
} = options;
let sql = ['SELECT * FROM photos'];
const { wheres, values, lastValuesIndex } = getWheresFromOptions(options);
let valuesIndex = lastValuesIndex;
if (wheres) { sql.push(wheres); }
// ORDER BY
switch (sortBy) {
case 'createdAt':
sql.push('ORDER BY created_at DESC');
break;
case 'takenAt':
sql.push('ORDER BY taken_at DESC');
break;
case 'priority':
sql.push('ORDER BY priority_order ASC, taken_at DESC');
break;
}
// LIMIT + OFFSET
sql.push(`LIMIT $${valuesIndex++} OFFSET $${valuesIndex++}`);
values.push(limit, offset);
return safelyQueryPhotos(async () => {
return query(sql.join(' '), values);
}, sql.join(' '))
.then(({ rows }) => rows.map(parsePhotoFromDb));
};
export const getPhotosNearId = async (
photoId: string,
options: GetPhotosOptions,
) => safelyQueryPhotos(async () => {
const { limit } = options;
const orderBy = PRIORITY_ORDER_ENABLED
? 'ORDER BY priority_order ASC, taken_at DESC'
: 'ORDER BY taken_at DESC';
const { wheres, values, lastValuesIndex } = getWheresFromOptions(options);
let valuesIndex = lastValuesIndex;
return query(
`
WITH twi AS (
SELECT *, row_number()
OVER (${orderBy}) as row_number
FROM photos
${wheres}
),
current AS (SELECT row_number FROM twi WHERE id = $${valuesIndex++})
SELECT twi.*
FROM twi, current
WHERE twi.row_number >= current.row_number - 1
LIMIT $${valuesIndex++}
`,
[...values, photoId, limit]
);
}, `getPhotosNearId: ${photoId}`)
.then(({ rows }) => {
const photo = rows.find(({ id }) => id === photoId);
const indexNumber = photo ? parseInt(photo.row_number) : undefined;
return {
photos: rows.map(parsePhotoFromDb),
indexNumber,
};
});
export const getPhotoIds = async ({ limit }: { limit?: number }) => {
return safelyQueryPhotos(() => limit
? sql`SELECT id FROM photos LIMIT ${limit}`
: sql`SELECT id FROM photos`,
'getPhotoIds')
.then(({ rows }) => rows.map(({ id }) => id as string));
};
export const getPhoto = async (
id: string,
includeHidden?: boolean,
): Promise<Photo | undefined> => {
// Check for photo id forwarding
// and convert short ids to uuids
const photoId = translatePhotoId(id);
return safelyQueryPhotos(() =>
sqlGetPhoto(photoId, includeHidden), 'sqlGetPhoto')
.then(({ rows }) => rows.map(parsePhotoFromDb))
.then(photos => photos.length > 0 ? photos[0] : undefined);
};
export const getPhotosDateRange = () =>
safelyQueryPhotos(sqlGetPhotosDateRange, 'getPhotosDateRange');
export const getPhotosCount = () =>
safelyQueryPhotos(sqlGetPhotosCount, 'getPhotosCount');
export const getPhotosCountIncludingHidden = () =>
safelyQueryPhotos(
sqlGetPhotosCountIncludingHidden,
'getPhotosCountIncludingHidden',
);
export const getPhotosMostRecentUpdate = () =>
safelyQueryPhotos(
sqlGetPhotosMostRecentUpdate,
'getPhotosMostRecentUpdate',
);
// TAGS
export const getUniqueTags = () =>
safelyQueryPhotos(sqlGetUniqueTags, 'getUniqueTags');
export const getUniqueTagsHidden = () =>
safelyQueryPhotos(sqlGetUniqueTagsHidden, 'getUniqueTagsHidden');
export const getPhotosTagMeta = (tag: string) =>
safelyQueryPhotos(() => sqlGetPhotosTagMeta(tag), 'getPhotosTagMeta');
export const getPhotosTagHiddenMeta = () =>
safelyQueryPhotos(sqlGetPhotosTagHiddenMeta, 'sqlGetPhotosTagHiddenMeta');
// CAMERAS
export const getUniqueCameras = () =>
safelyQueryPhotos(sqlGetUniqueCameras, 'getUniqueCameras');
export const getPhotosCameraMeta = (camera: Camera) =>
safelyQueryPhotos(
() => sqlGetPhotosCameraMeta(camera),
'getPhotosCameraMeta',
);
// FILM SIMULATIONS
export const getUniqueFilmSimulations = () =>
safelyQueryPhotos(sqlGetUniqueFilmSimulations, 'getUniqueFilmSimulations');
export const getPhotosFilmSimulationMeta =
(simulation: FilmSimulation) => safelyQueryPhotos(
() => sqlGetPhotosFilmSimulationMeta(simulation),
'getPhotosFilmSimulationMeta',
);

116
src/photo/db/index.ts Normal file
View File

@ -0,0 +1,116 @@
import { Camera } from '@/camera';
import { FilmSimulation } from '@/simulation';
import { PRIORITY_ORDER_ENABLED } from '@/site/config';
import { parameterize } from '@/utility/string';
export const GENERATE_STATIC_PARAMS_LIMIT = 1000;
export const PHOTO_DEFAULT_LIMIT = 100;
export type GetPhotosOptions = {
sortBy?: 'createdAt' | 'takenAt' | 'priority';
limit?: number;
offset?: number;
query?: string;
tag?: string;
camera?: Camera;
simulation?: FilmSimulation;
takenBefore?: Date;
takenAfterInclusive?: Date;
hidden?: 'exclude' | 'include' | 'only';
};
export const getWheresFromOptions = (
options: GetPhotosOptions,
initialValuesIndex = 1
) => {
const {
hidden = 'exclude',
takenBefore,
takenAfterInclusive,
query,
tag,
camera,
simulation,
} = options;
const wheres = [] as string[];
const wheresValues = [] as (string | number)[];
let valuesIndex = initialValuesIndex;
switch (hidden) {
case 'exclude':
wheres.push('hidden IS NOT TRUE');
break;
case 'only':
wheres.push('hidden IS TRUE');
break;
}
if (takenBefore) {
wheres.push(`taken_at > $${valuesIndex++}`);
wheresValues.push(takenBefore.toISOString());
}
if (takenAfterInclusive) {
wheres.push(`taken_at <= $${valuesIndex++}`);
wheresValues.push(takenAfterInclusive.toISOString());
}
if (query) {
// eslint-disable-next-line max-len
wheres.push(`CONCAT(title, ' ', caption, ' ', semantic_description) ILIKE $${valuesIndex++}`);
wheresValues.push(`%${query.toLocaleLowerCase()}%`);
}
if (tag) {
wheres.push(`$${valuesIndex++}=ANY(tags)`);
wheresValues.push(tag);
}
if (camera) {
wheres.push(`LOWER(REPLACE(make, ' ', '-'))=$${valuesIndex++}`);
wheres.push(`LOWER(REPLACE(model, ' ', '-'))=$${valuesIndex++}`);
wheresValues.push(parameterize(camera.make, true));
wheresValues.push(parameterize(camera.model, true));
}
if (simulation) {
wheres.push(`film_simulation=$${valuesIndex++}`);
wheresValues.push(simulation);
}
return {
wheres: wheres.length > 0
? `WHERE ${wheres.join(' AND ')}`
: '',
wheresValues,
lastValuesIndex: valuesIndex,
};
};
export const getOrderByFromOptions = (options: GetPhotosOptions) => {
const {
sortBy = PRIORITY_ORDER_ENABLED ? 'priority' : 'takenAt',
} = options;
switch (sortBy) {
case 'createdAt':
return 'ORDER BY created_at DESC';
case 'takenAt':
return 'ORDER BY taken_at DESC';
case 'priority':
return 'ORDER BY priority_order ASC, taken_at DESC';
}
};
export const getLimitAndOffsetFromOptions = (
options: GetPhotosOptions,
initialValuesIndex = 1,
) => {
const {
limit = PHOTO_DEFAULT_LIMIT,
offset = 0,
} = options;
let valuesIndex = initialValuesIndex;
return {
limitAndOffset: `LIMIT $${valuesIndex++} OFFSET $${valuesIndex++}`,
limitAndOffsetValues: [limit, offset],
};
};

395
src/photo/db/query.ts Normal file
View File

@ -0,0 +1,395 @@
import {
sql,
query,
convertArrayToPostgresString,
} from '@/services/postgres';
import {
PhotoDb,
PhotoDbInsert,
translatePhotoId,
parsePhotoFromDb,
Photo,
PhotoDateRange,
} from '@/photo';
import { Cameras, createCameraKey } from '@/camera';
import { TagsWithMeta } from '@/tag';
import { FilmSimulation, FilmSimulations } from '@/simulation';
import { SHOULD_DEBUG_SQL } from '@/site/config';
import {
GetPhotosOptions,
getLimitAndOffsetFromOptions,
getOrderByFromOptions,
} from '.';
import { getWheresFromOptions } from '.';
const createPhotosTable = () =>
sql`
CREATE TABLE IF NOT EXISTS photos (
id VARCHAR(8) PRIMARY KEY,
url VARCHAR(255) NOT NULL,
extension VARCHAR(255) NOT NULL,
aspect_ratio REAL DEFAULT 1.5,
blur_data TEXT,
title VARCHAR(255),
caption TEXT,
semantic_description TEXT,
tags VARCHAR(255)[],
make VARCHAR(255),
model VARCHAR(255),
focal_length SMALLINT,
focal_length_in_35mm_format SMALLINT,
f_number REAL,
iso SMALLINT,
exposure_time DOUBLE PRECISION,
exposure_compensation REAL,
location_name VARCHAR(255),
latitude DOUBLE PRECISION,
longitude DOUBLE PRECISION,
film_simulation VARCHAR(255),
priority_order REAL,
taken_at TIMESTAMP WITH TIME ZONE NOT NULL,
taken_at_naive VARCHAR(255) NOT NULL,
hidden BOOLEAN,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
)
`;
// Migration 01
const MIGRATION_FIELDS_01 = ['caption', 'semantic_description'];
const runMigration01 = () =>
sql`
ALTER TABLE photos
ADD COLUMN IF NOT EXISTS caption TEXT,
ADD COLUMN IF NOT EXISTS semantic_description TEXT
`;
// Wrapper for most queries for JIT table creation/migration running
const safelyQueryPhotos = async <T>(
callback: () => Promise<T>,
debugMessage: string
): Promise<T> => {
let result: T;
const start = new Date();
try {
result = await callback();
} catch (e: any) {
if (MIGRATION_FIELDS_01.some(field => new RegExp(
`column "${field}" of relation "photos" does not exist`,
'i',
).test(e.message))) {
console.log('Running migration 01 ...');
await runMigration01();
result = await callback();
} else if (/relation "photos" does not exist/i.test(e.message)) {
// If the table does not exist, create it
console.log('Creating photos table ...');
await createPhotosTable();
result = await callback();
} else if (/endpoint is in transition/i.test(e.message)) {
console.log('sql get error: endpoint is in transition (setting timeout)');
// Wait 5 seconds and try again
await new Promise(resolve => setTimeout(resolve, 5000));
try {
result = await callback();
} catch (e: any) {
console.log(`sql get error on retry (after 5000ms): ${e.message} `);
throw e;
}
} else {
console.log(`sql get error: ${e.message} `);
throw e;
}
}
if (SHOULD_DEBUG_SQL && debugMessage) {
const time =
(((new Date()).getTime() - start.getTime()) / 1000).toFixed(2);
console.log(`Executing sql query: ${debugMessage} (${time} seconds)`);
}
return result;
};
// Must provide id as 8-character nanoid
export const insertPhoto = (photo: PhotoDbInsert) =>
safelyQueryPhotos(() => sql`
INSERT INTO photos (
id,
url,
extension,
aspect_ratio,
blur_data,
title,
caption,
semantic_description,
tags,
make,
model,
focal_length,
focal_length_in_35mm_format,
f_number,
iso,
exposure_time,
exposure_compensation,
location_name,
latitude,
longitude,
film_simulation,
priority_order,
hidden,
taken_at,
taken_at_naive
)
VALUES (
${photo.id},
${photo.url},
${photo.extension},
${photo.aspectRatio},
${photo.blurData},
${photo.title},
${photo.caption},
${photo.semanticDescription},
${convertArrayToPostgresString(photo.tags)},
${photo.make},
${photo.model},
${photo.focalLength},
${photo.focalLengthIn35MmFormat},
${photo.fNumber},
${photo.iso},
${photo.exposureTime},
${photo.exposureCompensation},
${photo.locationName},
${photo.latitude},
${photo.longitude},
${photo.filmSimulation},
${photo.priorityOrder},
${photo.hidden},
${photo.takenAt},
${photo.takenAtNaive}
)
`, 'insertPhoto');
export const updatePhoto = (photo: PhotoDbInsert) =>
safelyQueryPhotos(() => sql`
UPDATE photos SET
url=${photo.url},
extension=${photo.extension},
aspect_ratio=${photo.aspectRatio},
blur_data=${photo.blurData},
title=${photo.title},
caption=${photo.caption},
semantic_description=${photo.semanticDescription},
tags=${convertArrayToPostgresString(photo.tags)},
make=${photo.make},
model=${photo.model},
focal_length=${photo.focalLength},
focal_length_in_35mm_format=${photo.focalLengthIn35MmFormat},
f_number=${photo.fNumber},
iso=${photo.iso},
exposure_time=${photo.exposureTime},
exposure_compensation=${photo.exposureCompensation},
location_name=${photo.locationName},
latitude=${photo.latitude},
longitude=${photo.longitude},
film_simulation=${photo.filmSimulation},
priority_order=${photo.priorityOrder || null},
hidden=${photo.hidden},
taken_at=${photo.takenAt},
taken_at_naive=${photo.takenAtNaive},
updated_at=${(new Date()).toISOString()}
WHERE id=${photo.id}
`, 'updatePhoto');
export const deletePhotoTagGlobally = (tag: string) =>
safelyQueryPhotos(() => sql`
UPDATE photos
SET tags=ARRAY_REMOVE(tags, ${tag})
WHERE ${tag}=ANY(tags)
`, 'deletePhotoTagGlobally');
export const renamePhotoTagGlobally = (tag: string, updatedTag: string) =>
safelyQueryPhotos(() => sql`
UPDATE photos
SET tags=ARRAY_REPLACE(tags, ${tag}, ${updatedTag})
WHERE ${tag}=ANY(tags)
`, 'renamePhotoTagGlobally');
export const deletePhoto = (id: string) =>
safelyQueryPhotos(() => sql`
DELETE FROM photos WHERE id=${id}
`, 'deletePhoto');
export const getPhotosMostRecentUpdate = async () =>
safelyQueryPhotos(() => sql`
SELECT updated_at FROM photos ORDER BY updated_at DESC LIMIT 1
`.then(({ rows }) => rows[0] ? rows[0].updated_at as Date : undefined)
, 'getPhotosMostRecentUpdate');
export const getUniqueTags = async () =>
safelyQueryPhotos(() => sql`
SELECT DISTINCT unnest(tags) as tag, COUNT(*)
FROM photos
WHERE hidden IS NOT TRUE
GROUP BY tag
ORDER BY tag ASC
`.then(({ rows }): TagsWithMeta => rows.map(({ tag, count }) => ({
tag: tag as string,
count: parseInt(count, 10),
})))
, 'getUniqueTags');
export const getUniqueTagsHidden = async () =>
safelyQueryPhotos(() => sql`
SELECT DISTINCT unnest(tags) as tag, COUNT(*)
FROM photos
GROUP BY tag
ORDER BY tag ASC
`.then(({ rows }): TagsWithMeta => rows.map(({ tag, count }) => ({
tag: tag as string,
count: parseInt(count, 10),
})))
, 'getUniqueTagsHidden');
export const getUniqueCameras = async () =>
safelyQueryPhotos(() => sql`
SELECT DISTINCT make||' '||model as camera, make, model, COUNT(*)
FROM photos
WHERE hidden IS NOT TRUE
AND trim(make) <> ''
AND trim(model) <> ''
GROUP BY make, model
ORDER BY camera ASC
`.then(({ rows }): Cameras => rows.map(({ make, model, count }) => ({
cameraKey: createCameraKey({ make, model }),
camera: { make, model },
count: parseInt(count, 10),
})))
, 'getUniqueCameras');
export const getUniqueFilmSimulations = async () =>
safelyQueryPhotos(() => sql`
SELECT DISTINCT film_simulation, COUNT(*)
FROM photos
WHERE hidden IS NOT TRUE AND film_simulation IS NOT NULL
GROUP BY film_simulation
ORDER BY film_simulation ASC
`.then(({ rows }): FilmSimulations => rows
.map(({ film_simulation, count }) => ({
simulation: film_simulation as FilmSimulation,
count: parseInt(count, 10),
})))
, 'getUniqueFilmSimulations');
export const getPhotos = async (options: GetPhotosOptions = {}) =>
safelyQueryPhotos(async () => {
const sql = ['SELECT * FROM photos'];
const values = [] as (string | number)[];
const {
wheres,
wheresValues,
lastValuesIndex,
} = getWheresFromOptions(options);
let valuesIndex = lastValuesIndex;
if (wheres) {
sql.push(wheres);
values.push(...wheresValues);
}
sql.push(getOrderByFromOptions(options));
const {
limitAndOffset,
limitAndOffsetValues,
} = getLimitAndOffsetFromOptions(options, valuesIndex);
// LIMIT + OFFSET
sql.push(limitAndOffset);
values.push(...limitAndOffsetValues);
return query(sql.join(' '), values)
.then(({ rows }) => rows.map(parsePhotoFromDb));
}, 'getPhotos');
export const getPhotosNearId = async (
photoId: string,
options: GetPhotosOptions,
) =>
safelyQueryPhotos(async () => {
const { limit } = options;
const {
wheres,
wheresValues,
lastValuesIndex,
} = getWheresFromOptions(options);
let valuesIndex = lastValuesIndex;
return query(
`
WITH twi AS (
SELECT *, row_number()
OVER (${getOrderByFromOptions(options)}) as row_number
FROM photos
${wheres}
),
current AS (SELECT row_number FROM twi WHERE id = $${valuesIndex++})
SELECT twi.*
FROM twi, current
WHERE twi.row_number >= current.row_number - 1
LIMIT $${valuesIndex++}
`,
[...wheresValues, photoId, limit]
)
.then(({ rows }) => {
const photo = rows.find(({ id }) => id === photoId);
const indexNumber = photo ? parseInt(photo.row_number) : undefined;
return {
photos: rows.map(parsePhotoFromDb),
indexNumber,
};
});
}, `getPhotosNearId: ${photoId}`);
export const getPhotosMeta = (options: GetPhotosOptions = {}) =>
safelyQueryPhotos(async () => {
// eslint-disable-next-line max-len
let sql = 'SELECT COUNT(*), MIN(taken_at_naive) as start, MAX(taken_at_naive) as end FROM photos';
const { wheres, wheresValues } = getWheresFromOptions(options);
if (wheres) { sql += ` ${wheres}`; }
return query(sql, wheresValues)
.then(({ rows }) => ({
count: parseInt(rows[0].count, 10),
...rows[0]?.start && rows[0]?.end
? { dateRange: rows[0] as PhotoDateRange }
: undefined,
}));
}, 'getPhotosMeta');
export const getPhotoIds = async ({ limit }: { limit?: number }) =>
safelyQueryPhotos(() => (limit
? sql`SELECT id FROM photos LIMIT ${limit}`
: sql`SELECT id FROM photos`)
.then(({ rows }) => rows.map(({ id }) => id as string))
, 'getPhotoIds');
export const getPhoto = async (
id: string,
includeHidden?: boolean,
): Promise<Photo | undefined> =>
safelyQueryPhotos(async () => {
// Check for photo id forwarding and convert short ids to uuids
const photoId = translatePhotoId(id);
return (includeHidden
? sql<PhotoDb>`SELECT * FROM photos WHERE id=${photoId} LIMIT 1`
// eslint-disable-next-line max-len
: sql<PhotoDb>`SELECT * FROM photos WHERE id=${photoId} AND hidden IS NOT TRUE LIMIT 1`)
.then(({ rows }) => rows.map(parsePhotoFromDb))
.then(photos => photos.length > 0 ? photos[0] : undefined);
}, 'getPhoto');

View File

@ -1,6 +1,6 @@
import {
getPhotosCached,
getPhotosFilmSimulationMetaCached,
getPhotosMetaCached,
} from '@/photo/cache';
import { FilmSimulation } from '.';
@ -13,5 +13,5 @@ export const getPhotosFilmSimulationDataCached = ({
}) =>
Promise.all([
getPhotosCached({ simulation, limit }),
getPhotosFilmSimulationMetaCached(simulation),
getPhotosMetaCached({ simulation }),
]);

View File

@ -1,6 +1,6 @@
import CommandKClient, { CommandKSection } from '@/components/CommandKClient';
import {
getPhotosCountCached,
getPhotosMetaCached,
getUniqueCamerasCached,
getUniqueFilmSimulationsCached,
getUniqueTagsCached,
@ -24,7 +24,9 @@ export default async function CommandK() {
cameras,
filmSimulations,
] = await Promise.all([
getPhotosCountCached().catch(() => 0),
getPhotosMetaCached()
.then(({ count }) => count)
.catch(() => 0),
getUniqueTagsCached().catch(() => [] as TagsWithMeta),
getUniqueCamerasCached().catch(() => []),
SHOW_FILM_SIMULATIONS

View File

@ -7,7 +7,7 @@ import usePathnames from '@/utility/usePathnames';
import { getAuthAction, logClientAuthUpdate } from '@/auth/actions';
import useSWR from 'swr';
import { MATTE_PHOTOS } from '@/site/config';
import { getPhotosTagHiddenMetaCachedAction } from '@/photo/actions';
import { getPhotosHiddenMetaCachedAction } from '@/photo/actions';
export default function AppStateProvider({
children,
@ -53,7 +53,7 @@ export default function AppStateProvider({
useEffect(() => {
if (isUserSignedIn) {
const timeout = setTimeout(() =>
getPhotosTagHiddenMetaCachedAction().then(({ count }) =>
getPhotosHiddenMetaCachedAction().then(({ count }) =>
setHiddenPhotosCount(count))
, 100);
return () => clearTimeout(timeout);

View File

@ -1,6 +1,6 @@
import {
getPhotosCached,
getPhotosTagMetaCached,
getPhotosMetaCached,
} from '@/photo/cache';
export const getPhotosTagDataCached = ({
@ -12,6 +12,6 @@ export const getPhotosTagDataCached = ({
}) =>
Promise.all([
getPhotosCached({ tag, limit }),
getPhotosTagMetaCached(tag),
getPhotosMetaCached({ tag }),
]);