Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/layer/layer.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
GetLayerSchema,
LayerMigrateBatchSchema,
LayerMigrateSchema,
SearchLayerOptionSchema,
UpdateLayerSchema
} from './layer.schema';
import { LayerService } from './layer.service';
Expand Down Expand Up @@ -89,6 +90,12 @@ export class LayerController {
return this.layerService.getAll();
};

search = async (request: AppRequest<typeof SearchLayerOptionSchema>) => {
const profils = request.user?.profils ?? [];
const { q, type = 'layer', limit = 10, page = 1 } = request.query;
return this.layerService.search(q, type, profils, limit, page);
};

getBaseLayers = async () => {
return this.layerService.getBaseLayers();
};
Expand Down
28 changes: 28 additions & 0 deletions src/layer/layer.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,34 @@ export interface SourceFieldsOptionsParams {
[key: string]: unknown;
}

export interface ILayerSearchItem {
score: number;
properties: {
name?: string;
title?: string;
abstract?: string;
keywords?: string[];
metadataUrl?: string;
minScaleDenom?: number;
maxScaleDenom?: number;
queryable?: boolean;
optionsFromCapabilities?: boolean;
type: 'layer' | 'group';
format: LayerType;
url: string;
sourceId: number;
id: string;
};
highlight: {
title?: string;
};
}

export interface ILayerSearchResult {
items: ILayerSearchItem[];
maxScore?: number;
}

type ILayerMigrateUpdate = { id: ILayerIn['id'] } & Partial<
Pick<ILayerIn, 'layerOptions' | 'sourceOptions'>
>;
Expand Down
8 changes: 8 additions & 0 deletions src/layer/layer.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
GetLayersSchema,
LayerMigrateBatchSchema,
LayerMigrateSchema,
SearchLayerOptionSchema,
UpdateLayerSchema
} from './layer.schema';

Expand All @@ -38,6 +39,13 @@ export const routes = (app: AppInstance) => {
schema: GetLayerAdminOptionSchema
});

app.route({
method: 'GET',
url: '/search',
handler: controller.search,
schema: SearchLayerOptionSchema
});

app.route({
method: 'GET',
url: '/:id',
Expand Down
49 changes: 49 additions & 0 deletions src/layer/layer.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,55 @@ const LayerKeyIdentifier = Type.Object({
layers: Type.Optional(Type.String())
});

const SearchLayerItemSchema = Type.Object({
score: Type.Number(),
properties: Type.Object(
{
name: Type.Optional(Type.String()),
title: Type.Optional(Type.String()),
abstract: Type.Optional(Type.String()),
keywords: Type.Optional(Type.Array(Type.String())),
metadataUrl: Type.Optional(Type.String()),
minScaleDenom: Type.Optional(Type.Number()),
maxScaleDenom: Type.Optional(Type.Number()),
queryable: Type.Optional(Type.Boolean()),
optionsFromCapabilities: Type.Optional(Type.Boolean()),
type: Type.Union([Type.Literal('layer'), Type.Literal('group')]),
format: Type.Enum(LayerType),
url: Type.String(),
sourceId: Type.Number(),
id: Type.String()
},
{ additionalProperties: false }
),
highlight: Type.Object(
{
title: Type.Optional(Type.String())
},
{ additionalProperties: false }
)
});

const SearchLayerResultSchema = Type.Object({
items: Type.Array(SearchLayerItemSchema),
maxScore: Type.Optional(Type.Number())
});

export const SearchLayerOptionSchema = {
description: 'Search layers.',
querystring: Type.Object({
q: Type.String(),
type: Type.Optional(
Type.Union([Type.Literal('layer'), Type.Literal('group')])
),
limit: Type.Optional(Type.Integer({ minimum: 1 })),
Comment thread
pelord marked this conversation as resolved.
page: Type.Optional(Type.Integer({ minimum: 1 }))
}),
response: {
200: SearchLayerResultSchema
}
} satisfies FastifySchema;

export const GetLayerOptionSchema = {
description: 'Get layer options by source.',
querystring: Type.Interface([LayerKeyIdentifier], {
Expand Down
200 changes: 199 additions & 1 deletion src/layer/layer.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { createHash } from 'node:crypto';

import { StringArray } from '@igo2/fastify';
import { and, eq, isNull, or, sql } from 'drizzle-orm';
import { AnyColumn, SQL, and, desc, eq, isNull, or, sql } from 'drizzle-orm';
import Value from 'typebox/value';

import { AppDatabase, AppInstance } from '../app.interface';
Expand All @@ -11,6 +13,8 @@ import {
ILayer,
ILayerIn,
ILayerMigrateBatch,
ILayerSearchItem,
ILayerSearchResult,
LayerOptions,
LayerType,
SourceOptions
Expand Down Expand Up @@ -119,6 +123,116 @@ export class LayerService {
});
}

async search(
originalQuery: string,
type: 'layer' | 'group' = 'layer',
profils: IProfils,
limit = 10,
page = 1
): Promise<ILayerSearchResult> {
/**
* Prevent to add unaccent extension on DB backend.
*/
const sqlTranslateStripAccents = (
Comment thread
pelord marked this conversation as resolved.
columnOrValue: AnyColumn | string | SQL
): SQL => {
const accented =
'áàâãäåāăąèééêëēĕėęěìíîïìĩīĭḩóôõöōŏőùúûüũūŭůäàáâãåæçćĉčöòóôõøüùúûßéèêëýñîìíïş';
const plain =
'aaaaaaaaaeeeeeeeeeeiiiiiiiihooooooouuuuuuuuaaaaaaeccccoooooouuuuseeeeyniiiis';

return sql`translate(${columnOrValue}, ${accented}, ${plain})`;
};
const toTextSearchString = (term: string): string => {
return term
.split(' ')
.filter(Boolean)
.map((term) => `${term}:*`)
Comment thread
pelord marked this conversation as resolved.
.join(' | ');
};
const normalizedQuery = this.normalizeSearchQuery(originalQuery);
if (!normalizedQuery) {
return { items: [] };
}
const tsQuery = toTextSearchString(normalizedQuery);

const offset = (page - 1) * limit;
const searchDocument = sql<string>`
to_tsvector(
'simple',
concat_ws(
' ',
coalesce(${layerModel.layers}, ''),
coalesce(${layerModel.url}, ''),
coalesce(${layerModel.type}::text, ''),
coalesce(${sqlTranslateStripAccents(sql`${layerModel.layerOptions}->>'title'`)}, ''),
coalesce(${layerModel.layerOptions}->>'name', ''),
coalesce(${sqlTranslateStripAccents(sql`${layerModel.layerOptions}->'metadata'->>'abstract'`)}, ''),
coalesce(${sqlTranslateStripAccents(sql`${layerModel.layerOptions}->'metadata'->>'keyword'`)}, '')
)
)
`;
const rank = sql<number>`ts_rank(${searchDocument}, to_tsquery('simple', ${tsQuery}))`;
const headline = sql<string>`
ts_headline(
'simple',
coalesce(${layerModel.layerOptions}->>'title', ${layerModel.layers}, ''),
to_tsquery('simple', ${toTextSearchString(originalQuery)}),
'StartSel=<strong>, StopSel=</strong>'
Comment thread
pelord marked this conversation as resolved.
)
`;
const typeFilter =
type === 'group'
? sql`${layerModel.type} = 'group'`
: sql`${layerModel.type} <> 'group'`;

const rows = await this.db
.select({
id: layerModel.id,
type: layerModel.type,
url: layerModel.url,
layers: layerModel.layers,
global: layerModel.global,
Comment thread
pelord marked this conversation as resolved.
layerOptions: layerModel.layerOptions,
sourceOptions: layerModel.sourceOptions,
score: rank,
headline: headline
})
.from(layerModel)
.where(
and(
typeFilter,
sql`${searchDocument} @@ to_tsquery('simple', ${tsQuery})`
)
)
.orderBy(desc(rank))
.limit(limit)
.offset(offset);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[COPILOT]: Pagination is applied before authorization filtering

The DB query fetches limit rows, then silently drops those the user can't access. This means a page can return fewer results than limit even when more valid rows exist, and results shift unpredictably across pages. Consider over-fetching or doing a two-phase approach (get IDs allowed for this profil first, then paginate).


const items = (
await Promise.all(
rows.map(async (row) => {
try {
await this.urlAllowed(row.url, profils);
return row;
} catch {
return null;
}
})
)
)
.filter((row): row is (typeof rows)[number] => row !== null)
.map((row) => this.mapSearchRow(row, type));

return {
items,
maxScore:
items.length > 0
? Math.max(...items.map((item) => item.score))
: undefined
};
}

async getById(id: number): Promise<ILayer | undefined> {
const [result] = await this.db
.select()
Expand Down Expand Up @@ -307,4 +421,88 @@ export class LayerService {
? this.update(existingLayer.id, values)
: this.create(layer);
}

private normalizeSearchQuery(query: string): string {
return query
.replaceAll(/(\(|\)|\*)/g, ' ')
.replaceAll(/\s+/g, ' ')
.trim()
.normalize('NFD')
.replaceAll(/[\u0300-\u036f]/g, '')
.toLowerCase();
}

private mapSearchRow(
row: {
id: number;
type: LayerType;
url: string;
layers: string | null;
layerOptions: ILayer['layerOptions'];
sourceOptions: ILayer['sourceOptions'];
score: number;
headline: string;
},
type: 'layer' | 'group'
): ILayerSearchItem {
const layerOptions =
row.layerOptions && typeof row.layerOptions === 'object'
? (row.layerOptions as Record<string, unknown>)
: {};
const sourceOptions =
row.sourceOptions && typeof row.sourceOptions === 'object'
? (row.sourceOptions as Record<string, unknown>)
: {};
const metadata =
layerOptions['metadata'] && typeof layerOptions['metadata'] === 'object'
? (layerOptions['metadata'] as Record<string, unknown>)
: {};
const identifier = createHash('md5')
.update(`${row.type}${row.url}${row.layers ?? ''}`)
.digest('hex');

return {
score: row.score,
properties: {
name: row.layers ?? undefined,
title: this.getOptionalString(layerOptions['title']),
abstract: this.getOptionalString(metadata['abstract']),
keywords: this.getOptionalStringArray(metadata['keyword']),
metadataUrl: this.getOptionalString(metadata['url']),
minScaleDenom: this.getOptionalNumber(layerOptions['minScaleDenom']),
maxScaleDenom: this.getOptionalNumber(layerOptions['maxScaleDenom']),
queryable: this.getOptionalBoolean(sourceOptions['queryable']),
optionsFromCapabilities: this.getOptionalBoolean(
sourceOptions['optionsFromCapabilities']
),
type,
format: row.type,
url: row.url,
sourceId: row.id,
id: identifier
},
highlight: {
title: row.headline || this.getOptionalString(layerOptions['title'])
}
};
}

private getOptionalString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}

private getOptionalNumber(value: unknown): number | undefined {
return typeof value === 'number' ? value : undefined;
}

private getOptionalBoolean(value: unknown): boolean | undefined {
return typeof value === 'boolean' ? value : undefined;
}

private getOptionalStringArray(value: unknown): string[] | undefined {
return Array.isArray(value) &&
value.every((item) => typeof item === 'string')
? value
: undefined;
}
}
Loading
Loading