@ -1,17 +1,15 @@
import { Inject , Injectable } from '@nestjs/common' ;
import { InjectDataSource , InjectRepository } from '@nestjs/typeorm' ;
import { getName } from 'i18n-iso-countries' ;
import { Expression , Kysely , sql , SqlBool } from 'kysely' ;
import { InjectKysely } from 'nestjs-kysely' ;
import { randomUUID } from 'node:crypto' ;
import { createReadStream , existsSync } from 'node:fs' ;
import { readFile } from 'node:fs/promises' ;
import readLine from 'node:readline' ;
import { citiesFile } from 'src/constants' ;
import { AssetEntity } from 'src/entities/asset.entity' ;
import { GeodataPlacesEntity , GeodataPlacesTempEntity } from 'src/entities/geodata-places.entity' ;
import {
NaturalEarthCountriesEntity ,
NaturalEarthCountriesTempEntity ,
} from 'src/entities/natural-earth-countries.entity' ;
import { DB , GeodataPlaces , NaturalearthCountries } from 'src/db' ;
import { AssetEntity , withExif } from 'src/entities/asset.entity' ;
import { NaturalEarthCountriesTempEntity } from 'src/entities/natural-earth-countries.entity' ;
import { LogLevel , SystemMetadataKey } from 'src/enum' ;
import { IConfigRepository } from 'src/interfaces/config.interface' ;
import { ILoggerRepository } from 'src/interfaces/logger.interface' ;
@ -23,21 +21,19 @@ import {
ReverseGeocodeResult ,
} from 'src/interfaces/map.interface' ;
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface' ;
import { OptionalBetween } from 'src/utils/database' ;
import { DataSource , In , IsNull , Not , Repository } from 'typeorm' ;
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js' ;
interface MapDB extends DB {
geodata_places_tmp : GeodataPlaces ;
naturalearth_countries_tmp : NaturalearthCountries ;
}
@Injectable ( )
export class MapRepository implements IMapRepository {
constructor (
@InjectRepository ( AssetEntity ) private assetRepository : Repository < AssetEntity > ,
@InjectRepository ( GeodataPlacesEntity ) private geodataPlacesRepository : Repository < GeodataPlacesEntity > ,
@InjectRepository ( NaturalEarthCountriesEntity )
private naturalEarthCountriesRepository : Repository < NaturalEarthCountriesEntity > ,
@InjectDataSource ( ) private dataSource : DataSource ,
@Inject ( IConfigRepository ) private configRepository : IConfigRepository ,
@Inject ( ISystemMetadataRepository ) private metadataRepository : ISystemMetadataRepository ,
@Inject ( ILoggerRepository ) private logger : ILoggerRepository ,
@InjectKysely ( ) private db : Kysely < MapDB > ,
) {
this . logger . setContext ( MapRepository . name ) ;
}
@ -70,39 +66,34 @@ export class MapRepository implements IMapRepository {
) : Promise < MapMarker [ ] > {
const { isArchived , isFavorite , fileCreatedAfter , fileCreatedBefore } = options ;
const where = {
isVisible : true ,
isArchived ,
exifInfo : {
latitude : Not ( IsNull ( ) ) ,
longitude : Not ( IsNull ( ) ) ,
} ,
isFavorite ,
fileCreatedAt : OptionalBetween ( fileCreatedAfter , fileCreatedBefore ) ,
} ;
const assets = await this . assetRepository . find ( {
select : {
id : true ,
exifInfo : {
city : true ,
state : true ,
country : true ,
latitude : true ,
longitude : true ,
} ,
} ,
where : [
{ . . . where , ownerId : In ( [ . . . ownerIds ] ) } ,
{ . . . where , albums : { id : In ( [ . . . albumIds ] ) } } ,
] ,
relations : {
exifInfo : true ,
} ,
order : {
fileCreatedAt : 'DESC' ,
} ,
} ) ;
const assets = ( await this . db
. selectFrom ( 'assets' )
. $call ( withExif )
. select ( 'id' )
. leftJoin ( 'albums_assets_assets' , ( join ) = > join . onRef ( 'assets.id' , '=' , 'albums_assets_assets.assetsId' ) )
. where ( 'isVisible' , '=' , true )
. $if ( isArchived !== undefined , ( q ) = > q . where ( 'isArchived' , '=' , isArchived ! ) )
. $if ( isFavorite !== undefined , ( q ) = > q . where ( 'isFavorite' , '=' , isFavorite ! ) )
. $if ( fileCreatedAfter !== undefined , ( q ) = > q . where ( 'fileCreatedAt' , '>=' , fileCreatedAfter ! ) )
. $if ( fileCreatedBefore !== undefined , ( q ) = > q . where ( 'fileCreatedAt' , '<=' , fileCreatedBefore ! ) )
. where ( 'deletedAt' , 'is' , null )
. where ( 'exif.latitude' , 'is not' , null )
. where ( 'exif.longitude' , 'is not' , null )
. where ( ( eb ) = > {
const ors : Expression < SqlBool > [ ] = [ ] ;
if ( ownerIds . length > 0 ) {
ors . push ( eb ( 'ownerId' , 'in' , ownerIds ) ) ;
}
if ( albumIds . length > 0 ) {
ors . push ( eb ( 'albums_assets_assets.albumsId' , 'in' , albumIds ) ) ;
}
return eb . or ( ors ) ;
} )
. orderBy ( 'fileCreatedAt' , 'desc' )
. execute ( ) ) as any as AssetEntity [ ] ;
return assets . map ( ( asset ) = > ( {
id : asset.id ,
@ -117,15 +108,19 @@ export class MapRepository implements IMapRepository {
async reverseGeocode ( point : GeoPoint ) : Promise < ReverseGeocodeResult > {
this . logger . debug ( ` Request: ${ point . latitude } , ${ point . longitude } ` ) ;
const response = await this . geodataPlacesRepository
. createQueryBuilder ( 'geoplaces' )
const response = await this . db
. selectFrom ( 'geodata_places' )
. selectAll ( )
. where (
'earth_box(ll_to_earth_public(:latitude, :longitude), 25000) @> ll_to_earth_public(latitude, longitude)' ,
point ,
sql ` earth_box(ll_to_earth_public( ${ point . latitude } , ${ point . longitude } ), 25000) ` ,
'@>' ,
sql ` ll_to_earth_public(latitude, longitude) ` ,
)
. orderBy (
sql ` (earth_distance(ll_to_earth_public( ${ point . latitude } , ${ point . longitude } ), ll_to_earth_public(latitude, longitude))) ` ,
)
. orderBy ( 'earth_distance(ll_to_earth_public(:latitude, :longitude), ll_to_earth_public(latitude, longitude))' )
. limit ( 1 )
. getOne ( ) ;
. execu teTakeFirst ( ) ;
if ( response ) {
if ( this . logger . isLevelEnabled ( LogLevel . VERBOSE ) ) {
@ -143,11 +138,12 @@ export class MapRepository implements IMapRepository {
` Response from database for reverse geocoding latitude: ${ point . latitude } , longitude: ${ point . longitude } was null ` ,
) ;
const ne_response = await this . naturalEarthCountriesRepository
. createQueryBuilder ( 'naturalearth_countries' )
. where ( 'coordinates @> point (:longitude, :latitude)' , point )
const ne_response = await this . db
. selectFrom ( 'naturalearth_countries' )
. selectAll ( )
. where ( 'coordinates' , '@>' , sql < string > ` point( ${ point . longitude } , ${ point . latitude } ) ` )
. limit ( 1 )
. g etOn e( ) ;
. execu teTakeFirst ( ) ;
if ( ! ne_response ) {
this . logger . warn (
@ -176,10 +172,11 @@ export class MapRepository implements IMapRepository {
return ;
}
await this . d ataSource. query ( 'DROP TABLE IF EXISTS naturalearth_countries_tmp' ) ;
await this . dataSource . query (
'CREATE TABLE naturalearth_countries_tmp (LIKE naturalearth_countries INCLUDING ALL EXCLUDING INDEXES)' ,
await this . d b. schema . dropTable ( 'naturalearth_countries_tmp' ) . ifExists ( ) . execute ( ) ;
await sql ` CREATE TABLE naturalearth_countries_tmp (LIKE naturalearth_countries INCLUDING ALL EXCLUDING INDEXES) ` . execute (
this . db ,
) ;
const entities : Omit < NaturalEarthCountriesTempEntity , ' id ' > [ ] = [ ] ;
for ( const feature of geoJSONData . features ) {
for ( const entry of feature . geometry . coordinates ) {
@ -196,14 +193,14 @@ export class MapRepository implements IMapRepository {
}
}
}
await this . d ataSource. manager . insert ( NaturalEarthCountriesTempEntity , entities ) ;
await this . d b. insertInto ( 'naturalearth_countries_tmp' ) . values ( entities ) . execute ( ) ;
await this . dataSource . query ( ` ALTER TABLE naturalearth_countries_tmp ADD PRIMARY KEY (id) WITH (FILLFACTOR = 100) ` ) ;
await sql ` ALTER TABLE naturalearth_countries_tmp ADD PRIMARY KEY (id) WITH (FILLFACTOR = 100) ` . execute ( this . db ) ;
await this . d ataSource. transaction ( async ( manager ) = > {
await manager . query( 'ALTER TABLE naturalearth_countries RENAME TO naturalearth_countries_old' ) ;
await manager . query( 'ALTER TABLE naturalearth_countries_tmp RENAME TO naturalearth_countries' ) ;
await manager . query( 'DROP TABLE naturalearth_countries_old' ) ;
await this . d b. transaction ( ) . execute ( async ( manager ) = > {
await manager . schema. alterTable ( 'naturalearth_countries' ) . renameTo ( 'naturalearth_countries_old' ) . execute ( ) ;
await manager . schema. alterTable ( 'naturalearth_countries_tmp' ) . renameTo ( 'naturalearth_countries' ) . execute ( ) ;
await manager . schema. dropTable ( 'naturalearth_countries_old' ) . execute ( ) ;
} ) ;
}
@ -214,17 +211,15 @@ export class MapRepository implements IMapRepository {
this . loadAdmin ( resourcePaths . geodata . admin2 ) ,
] ) ;
await this . dataSource . query ( 'DROP TABLE IF EXISTS geodata_places_tmp' ) ;
await this . dataSource . query (
'CREATE TABLE geodata_places_tmp (LIKE geodata_places INCLUDING ALL EXCLUDING INDEXES)' ,
) ;
await this . db . schema . dropTable ( 'geodata_places_tmp' ) . ifExists ( ) . execute ( ) ;
await sql ` CREATE TABLE geodata_places_tmp (LIKE geodata_places INCLUDING ALL EXCLUDING INDEXES) ` . execute ( this . db ) ;
await this . loadCities500 ( admin1 , admin2 ) ;
await this . createGeodataIndices ( ) ;
await this . d ataSource. transaction ( async ( manager ) = > {
await manager . query( 'ALTER TABLE geodata_places RENAME TO geodata_places_old' ) ;
await manager . query( 'ALTER TABLE geodata_places_tmp RENAME TO geodata_places' ) ;
await manager . query( 'DROP TABLE geodata_places_old' ) ;
await this . d b. transaction ( ) . execute ( async ( manager ) = > {
await manager . schema. alterTable ( 'geodata_places' ) . renameTo ( 'geodata_places_old' ) . execute ( ) ;
await manager . schema. alterTable ( 'geodata_places_tmp' ) . renameTo ( 'geodata_places' ) . execute ( ) ;
await manager . schema. dropTable ( 'geodata_places_old' ) . execute ( ) ;
} ) ;
}
@ -236,7 +231,7 @@ export class MapRepository implements IMapRepository {
}
const input = createReadStream ( cities500 , { highWaterMark : 512 * 1024 * 1024 } ) ;
let bufferGeodata : QueryDeepPartialEntity < GeodataPlacesTempEntity > [ ] = [ ] ;
let bufferGeodata = [ ] ;
const lineReader = readLine . createInterface ( { input } ) ;
let count = 0 ;
@ -257,19 +252,23 @@ export class MapRepository implements IMapRepository {
admin1Code : lineSplit [ 10 ] ,
admin2Code : lineSplit [ 11 ] ,
modificationDate : lineSplit [ 18 ] ,
admin1Name : admin1Map.get ( ` ${ lineSplit [ 8 ] } . ${ lineSplit [ 10 ] } ` ) ,
admin2Name : admin2Map.get ( ` ${ lineSplit [ 8 ] } . ${ lineSplit [ 10 ] } . ${ lineSplit [ 11 ] } ` ) ,
admin1Name : admin1Map.get ( ` ${ lineSplit [ 8 ] } . ${ lineSplit [ 10 ] } ` ) ? ? null ,
admin2Name : admin2Map.get ( ` ${ lineSplit [ 8 ] } . ${ lineSplit [ 10 ] } . ${ lineSplit [ 11 ] } ` ) ? ? null ,
} ;
bufferGeodata . push ( geoData ) ;
if ( bufferGeodata . length >= 5000 ) {
const curLength = bufferGeodata . length ;
futures . push (
this . dataSource . manager . insert ( GeodataPlacesTempEntity , bufferGeodata ) . then ( ( ) = > {
count += curLength ;
if ( count % 10 _000 === 0 ) {
this . logger . log ( ` ${ count } geodata records imported ` ) ;
}
} ) ,
this . db
. insertInto ( 'geodata_places_tmp' )
. values ( bufferGeodata )
. execute ( )
. then ( ( ) = > {
count += curLength ;
if ( count % 10 _000 === 0 ) {
this . logger . log ( ` ${ count } geodata records imported ` ) ;
}
} ) ,
) ;
bufferGeodata = [ ] ;
// leave spare connection for other queries
@ -280,7 +279,7 @@ export class MapRepository implements IMapRepository {
}
}
await this . d ataSource. manager . insert ( GeodataPlacesTempEntity , bufferGeodata ) ;
await this . d b. insertInto ( 'geodata_places_tmp' ) . values ( bufferGeodata ) . execute ( ) ;
}
private async loadAdmin ( filePath : string ) {
@ -303,24 +302,28 @@ export class MapRepository implements IMapRepository {
private createGeodataIndices() {
return Promise . all ( [
this . dataSource . query ( ` ALTER TABLE geodata_places_tmp ADD PRIMARY KEY (id) WITH (FILLFACTOR = 100) ` ) ,
this . dataSource . query ( `
CREATE INDEX IDX_geodata_gist_earthcoord_ $ { randomUUID ( ) . replaceAll ( '-' , '_' ) }
ON geodata_places_tmp
USING gist ( ll_to_earth_public ( latitude , longitude ) )
WITH ( fillfactor = 100 ) ` ),
this . dataSource . query ( `
CREATE INDEX idx_geodata_places_name_ $ { randomUUID ( ) . replaceAll ( '-' , '_' ) }
ON geodata_places_tmp
USING gin ( f_unaccent ( name ) gin_trgm_ops ) ` ),
this . dataSource . query ( `
CREATE INDEX idx_geodata_places_admin1_name_ $ { randomUUID ( ) . replaceAll ( '-' , '_' ) }
ON geodata_places_tmp
USING gin ( f_unaccent ( "admin1Name" ) gin_trgm_ops ) ` ),
this . dataSource . query ( `
CREATE INDEX idx_geodata_places_admin2_name_ $ { randomUUID ( ) . replaceAll ( '-' , '_' ) }
ON geodata_places_tmp
USING gin ( f_unaccent ( "admin2Name" ) gin_trgm_ops ) ` ),
sql ` ALTER TABLE geodata_places_tmp ADD PRIMARY KEY (id) WITH (FILLFACTOR = 100) ` . execute ( this . db ) ,
sql `
CREATE INDEX IDX_geodata_gist_earthcoord_ $ { sql . raw ( randomUUID ( ) . replaceAll ( '-' , '_' ) ) }
ON geodata_places_tmp
USING gist ( ll_to_earth_public ( latitude , longitude ) )
WITH ( fillfactor = 100 )
` .execute(this.db),
this . db . schema
. createIndex ( ` idx_geodata_places_country_code_ ${ randomUUID ( ) . replaceAll ( '-' , '_' ) } ` )
. on ( 'geodata_places_tmp' )
. using ( 'gin (f_unaccent(name) gin_trgm_ops)' )
. execute ( ) ,
this . db . schema
. createIndex ( ` idx_geodata_places_country_code_ ${ randomUUID ( ) . replaceAll ( '-' , '_' ) } ` )
. on ( 'geodata_places_tmp' )
. using ( 'gin (f_unaccent("admin1Name") gin_trgm_ops)' )
. execute ( ) ,
this . db . schema
. createIndex ( ` idx_geodata_places_admin2_name_ ${ randomUUID ( ) . replaceAll ( '-' , '_' ) } ` )
. on ( 'geodata_places_tmp' )
. using ( 'gin (f_unaccent("admin2Name") gin_trgm_ops)' )
. execute ( ) ,
] ) ;
}
}