Refactor photo/db

This commit is contained in:
Sam Becker 2024-05-20 12:01:44 -05:00
parent baa3edcf9f
commit 12d051ae2c
15 changed files with 205 additions and 194 deletions

View File

@ -4,7 +4,7 @@ import SiteGrid from '@/components/SiteGrid';
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

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 { getPhotosMeta } from '@/photo/db';
import { getPhotosMeta } from '@/photo/db/query';
import AdminTagBadge from '@/admin/AdminTagBadge';
const MAX_PHOTO_TO_SHOW = 6;

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

@ -3,7 +3,7 @@ import {
INFINITE_SCROLL_GRID_PHOTO_MULTIPLE,
} from '@/photo';
import { getPhotosCached } from '@/photo/cache';
import { getPhotosMeta } from '@/photo/db';
import { getPhotosMeta } from '@/photo/db/query';
import StaggeredOgPhotos from '@/photo/StaggeredOgPhotos';
import StaggeredOgPhotosInfinite from '@/photo/StaggeredOgPhotosInfinite';

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, getPhotosMeta } from '@/photo/db';
import { getPhotos, getPhotosMeta } from '@/photo/db/query';
import PhotosLargeInfinite from '@/photo/PhotosLargeInfinite';
export const dynamic = 'force-static';

View File

@ -13,7 +13,7 @@ import {
import PhotoDetailPage from '@/photo/PhotoDetailPage';
import { getPhotosNearIdCached } from '@/photo/cache';
import { ReactNode, cache } from 'react';
import { getPhotosMeta } from '@/photo/db';
import { getPhotosMeta } from '@/photo/db/query';
const getPhotosNearIdCachedCached = cache((photoId: string, tag: string) =>
getPhotosNearIdCached(

View File

@ -7,7 +7,7 @@ import PhotoDetailPage from '@/photo/PhotoDetailPage';
import {
getPhotosNearIdCached,
} from '@/photo/cache';
import { getPhotosMeta } from '@/photo/db';
import { getPhotosMeta } from '@/photo/db/query';
import { PATH_ROOT, absolutePathForPhoto } from '@/site/paths';
import { TAG_HIDDEN } from '@/tag';
import { Metadata } from 'next';

View File

@ -3,7 +3,7 @@ import Banner from '@/components/Banner';
import SiteGrid from '@/components/SiteGrid';
import PhotoGrid from '@/photo/PhotoGrid';
import { getPhotosNoStore } from '@/photo/cache';
import { getPhotosMeta } 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';

View File

@ -1,7 +1,6 @@
'use server';
import {
GetPhotosOptions,
deletePhoto,
insertPhoto,
deletePhotoTagGlobally,
@ -9,7 +8,8 @@ import {
renamePhotoTagGlobally,
getPhoto,
getPhotos,
} from '@/photo/db';
} from '@/photo/db/query';
import { GetPhotosOptions } from './db';
import {
PhotoFormData,
convertFormDataToPhotoDbInsert,

View File

@ -5,7 +5,6 @@ import {
unstable_noStore,
} from 'next/cache';
import {
GetPhotosOptions,
getPhoto,
getPhotos,
getUniqueCameras,
@ -15,7 +14,8 @@ import {
getPhotosNearId,
getPhotosMostRecentUpdate,
getPhotosMeta,
} from '@/photo/db';
} from '@/photo/db/query';
import { GetPhotosOptions } from './db';
import { parseCachedPhotoDates, parseCachedPhotosDates } from '@/photo';
import { createCameraKey } from '@/camera';
import {

View File

@ -9,7 +9,7 @@ import {
getUniqueCameras,
getUniqueFilmSimulations,
getUniqueTags,
} from '@/photo/db';
} from '@/photo/db/query';
import { SHOW_FILM_SIMULATIONS } from '@/site/config';
import { sortTagsObject } from '@/tag';

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],
};
};

View File

@ -11,28 +11,16 @@ import {
Photo,
PhotoDateRange,
} from '@/photo';
import { Camera, Cameras, createCameraKey } from '@/camera';
import { parameterize } from '@/utility/string';
import { Cameras, createCameraKey } from '@/camera';
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;
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'
}
import { SHOULD_DEBUG_SQL } from '@/site/config';
import {
GetPhotosOptions,
getLimitAndOffsetFromOptions,
getOrderByFromOptions,
} from '.';
import { getWheresFromOptions } from '.';
const createPhotosTable = () =>
sql`
@ -76,6 +64,55 @@ const runMigration01 = () =>
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`
@ -181,16 +218,15 @@ export const renamePhotoTagGlobally = (tag: string, updatedTag: string) =>
`, 'renamePhotoTagGlobally');
export const deletePhoto = (id: string) =>
safelyQueryPhotos(
() => sql`DELETE FROM photos WHERE id=${id}`,
'deletePhoto',
);
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');
`.then(({ rows }) => rows[0] ? rows[0].updated_at as Date : undefined)
, 'getPhotosMostRecentUpdate');
export const getUniqueTags = async () =>
safelyQueryPhotos(() => sql`
@ -202,8 +238,8 @@ export const getUniqueTags = async () =>
`.then(({ rows }): TagsWithMeta => rows.map(({ tag, count }) => ({
tag: tag as string,
count: parseInt(count, 10),
}))),
'getUniqueTags');
})))
, 'getUniqueTags');
export const getUniqueTagsHidden = async () =>
safelyQueryPhotos(() => sql`
@ -214,8 +250,8 @@ export const getUniqueTagsHidden = async () =>
`.then(({ rows }): TagsWithMeta => rows.map(({ tag, count }) => ({
tag: tag as string,
count: parseInt(count, 10),
}))),
'getUniqueTagsHidden');
})))
, 'getUniqueTagsHidden');
export const getUniqueCameras = async () =>
safelyQueryPhotos(() => sql`
@ -230,8 +266,8 @@ export const getUniqueCameras = async () =>
cameraKey: createCameraKey({ make, model }),
camera: { make, model },
count: parseInt(count, 10),
}))),
'getUniqueCameras');
})))
, 'getUniqueCameras');
export const getUniqueFilmSimulations = async () =>
safelyQueryPhotos(() => sql`
@ -244,151 +280,8 @@ export const getUniqueFilmSimulations = async () =>
.map(({ film_simulation, count }) => ({
simulation: film_simulation as FilmSimulation,
count: parseInt(count, 10),
}))),
'getUniqueFilmSimulations');
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;
};
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,
};
};
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';
}
};
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],
};
};
})))
, 'getUniqueFilmSimulations');
export const getPhotos = async (options: GetPhotosOptions = {}) =>
safelyQueryPhotos(async () => {
@ -483,8 +376,8 @@ 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');
.then(({ rows }) => rows.map(({ id }) => id as string))
, 'getPhotoIds');
export const getPhoto = async (
id: string,