2021-06-17 16:17:15 +02:00
'use strict' ;
const _ = require ( 'lodash/fp' ) ;
const types = require ( '../types' ) ;
2021-06-30 20:00:03 +02:00
const { createField } = require ( '../fields' ) ;
2021-06-17 16:17:15 +02:00
const GROUP _OPERATORS = [ '$and' , '$or' ] ;
const OPERATORS = [
'$not' ,
'$in' ,
'$nin' ,
'$eq' ,
'$ne' ,
'$gt' ,
'$gte' ,
'$lt' ,
'$lte' ,
'$null' ,
'$between' ,
'$like' ,
'$regexp' ,
'$startsWith' ,
'$endsWith' ,
'$contains' ,
] ;
const createPivotJoin = ( qb , joinTable , alias , tragetMeta ) => {
const joinAlias = qb . getAlias ( ) ;
qb . join ( {
alias : joinAlias ,
referencedTable : joinTable . name ,
referencedColumn : joinTable . joinColumn . name ,
rootColumn : joinTable . joinColumn . referencedColumn ,
rootTable : alias ,
on : joinTable . on ,
} ) ;
const subAlias = qb . getAlias ( ) ;
qb . join ( {
alias : subAlias ,
referencedTable : tragetMeta . tableName ,
referencedColumn : joinTable . inverseJoinColumn . referencedColumn ,
rootColumn : joinTable . inverseJoinColumn . name ,
rootTable : joinAlias ,
} ) ;
return subAlias ;
} ;
// TODO: cleanup & implement real joins
const createJoin = ( ctx , { alias , attributeName , attribute } ) => {
const { db , qb } = ctx ;
if ( attribute . type !== 'relation' ) {
throw new Error ( ` Cannot join on non relational field ${ attributeName } ` ) ;
}
const tragetMeta = db . metadata . get ( attribute . target ) ;
// TODO: inmplement joinColumn
const joinColumn = attribute . joinColumn ;
if ( joinColumn ) {
const subAlias = qb . getAlias ( ) ;
qb . join ( {
alias : subAlias ,
referencedTable : tragetMeta . tableName ,
referencedColumn : joinColumn . referencedColumn ,
rootColumn : joinColumn . name ,
rootTable : alias ,
} ) ;
return subAlias ;
}
const joinTable = attribute . joinTable ;
if ( joinTable ) {
return createPivotJoin ( qb , joinTable , alias , tragetMeta ) ;
}
// NOTE: using the joinColumn / joinTable syntax we don't really care about the relation type here
switch ( attribute . relation ) {
case 'oneToOne' : {
break ;
}
case 'oneToMany' : {
break ;
}
case 'manyToOne' : {
break ;
}
case 'manyToMany' : {
break ;
}
// TODO: polymorphic relations
// TODO: components -> they are converted to relation so not needed either
}
return alias ;
} ;
// TODO: convert field names to columns names
const processOrderBy = ( orderBy , ctx ) => {
const { db , uid , qb , alias = qb . alias } = ctx ;
if ( typeof orderBy === 'string' ) {
const attribute = db . metadata . get ( uid ) . attributes [ orderBy ] ;
if ( ! attribute ) {
throw new Error ( ` Attribute ${ orderBy } not found on model ${ uid } ` ) ;
}
return [ { column : ` ${ alias } . ${ orderBy } ` } ] ;
}
if ( Array . isArray ( orderBy ) ) {
return orderBy . flatMap ( value => processOrderBy ( value , ctx ) ) ;
}
if ( _ . isPlainObject ( orderBy ) ) {
return Object . entries ( orderBy ) . flatMap ( ( [ key , direction ] ) => {
const value = orderBy [ key ] ;
const attribute = db . metadata . get ( uid ) . attributes [ key ] ;
if ( ! attribute ) {
throw new Error ( ` Attribute ${ key } not found on model ${ uid } ` ) ;
}
if ( attribute . type === 'relation' ) {
// TODO: pass down some filters (e.g published at)
const subAlias = createJoin ( ctx , { alias , uid , attributeName : key , attribute } ) ;
return processOrderBy ( value , {
db ,
qb ,
alias : subAlias ,
uid : attribute . target ,
} ) ;
}
if ( types . isScalar ( attribute . type ) ) {
return { column : ` ${ alias } . ${ key } ` , order : direction } ;
}
throw new Error ( ` You cannot order on ${ attribute . type } types ` ) ;
} ) ;
}
throw new Error ( 'Invalid orderBy syntax' ) ;
} ;
const processWhere = ( where , ctx , depth = 0 ) => {
if ( depth === 0 && ! _ . isPlainObject ( where ) ) {
throw new Error ( 'Where must be an object' ) ;
}
const processNested = ( where , ctx ) => {
if ( ! _ . isPlainObject ( where ) ) {
return where ;
}
return processWhere ( where , ctx , depth + 1 ) ;
} ;
const { db , uid , qb , alias = qb . alias } = ctx ;
const filters = { } ;
// for each key in where
for ( const key in where ) {
const value = where [ key ] ;
const attribute = db . metadata . get ( uid ) . attributes [ key ] ;
// if operator $and $or then loop over them
if ( GROUP _OPERATORS . includes ( key ) ) {
filters [ key ] = value . map ( sub => processNested ( sub , ctx ) ) ;
continue ;
}
if ( key === '$not' ) {
filters [ key ] = processNested ( value , ctx ) ;
continue ;
}
if ( OPERATORS . includes ( key ) ) {
if ( depth == 0 ) {
throw new Error (
` Only $ and, $ or and $ not can by used as root level operators. Found ${ key } . `
) ;
}
filters [ key ] = processNested ( value , ctx ) ;
continue ;
}
if ( ! attribute ) {
// TODO: if targeting a column name instead of an attribute
// if key as an alias don't add one
if ( key . indexOf ( '.' ) >= 0 ) {
filters [ key ] = processNested ( value , ctx ) ;
} else {
filters [ ` ${ alias || qb . alias } . ${ key } ` ] = processNested ( value , ctx ) ;
}
continue ;
// throw new Error(`Attribute ${key} not found on model ${uid}`);
}
// move to if else to check for scalar / relation / components & throw for other types
if ( attribute . type === 'relation' ) {
2021-06-22 17:13:11 +02:00
// TODO: support shortcut like { role: X } => {role: { id: X }}
2021-06-17 16:17:15 +02:00
// TODO: pass down some filters (e.g published at)
2021-06-22 17:13:11 +02:00
// attribute
2021-06-17 16:17:15 +02:00
const subAlias = createJoin ( ctx , { alias , uid , attributeName : key , attribute } ) ;
const nestedWhere = processNested ( value , {
db ,
qb ,
alias : subAlias ,
uid : attribute . target ,
} ) ;
// TODO: use a better merge logic (push to $and when collisions)
Object . assign ( filters , nestedWhere ) ;
continue ;
}
if ( types . isScalar ( attribute . type ) ) {
// TODO: convert attribute name to column name
2021-06-29 16:27:35 +02:00
// TODO: cast to DB type
2021-06-17 16:17:15 +02:00
filters [ ` ${ alias || qb . alias } . ${ key } ` ] = processNested ( value , ctx ) ;
continue ;
}
throw new Error ( ` You cannot filter on ${ attribute . type } types ` ) ;
}
return filters ;
} ;
const applyWhereToColumn = ( qb , column , columnWhere ) => {
if ( Array . isArray ( columnWhere ) ) {
return qb . whereIn ( column , columnWhere ) ;
}
if ( ! _ . isPlainObject ( columnWhere ) ) {
return qb . where ( column , columnWhere ) ;
}
// TODO: Transform into if has($in, value) then to handle cases with two keys doing one thing (like $contains with $case)
Object . keys ( columnWhere ) . forEach ( operator => {
const value = columnWhere [ operator ] ;
switch ( operator ) {
case '$not' : {
return qb . whereNot ( qb => applyWhereToColumn ( qb , column , value ) ) ;
}
case '$in' : {
return qb . whereIn ( column , value ) ;
}
case '$nin' : {
return qb . whereNotIn ( column , value ) ;
}
case '$eq' : {
if ( value === null ) {
return qb . whereNull ( column ) ;
}
return qb . where ( column , value ) ;
}
case '$ne' : {
if ( value === null ) {
return qb . whereNotNull ( column ) ;
}
return qb . where ( column , '<>' , value ) ;
}
case '$gt' : {
return qb . where ( column , '>' , value ) ;
}
case '$gte' : {
return qb . where ( column , '>=' , value ) ;
}
case '$lt' : {
return qb . where ( column , '<' , value ) ;
}
case '$lte' : {
return qb . where ( column , '<=' , value ) ;
}
case '$null' : {
2021-06-25 12:07:32 +02:00
return value === true ? qb . whereNull ( column ) : qb . whereNotNull ( column ) ;
2021-06-17 16:17:15 +02:00
}
case '$between' : {
return qb . whereBetween ( column , value ) ;
}
case '$regexp' : {
// TODO:
return ;
}
// string
// TODO: use $case to make it case insensitive
case '$like' : {
return qb . where ( column , 'like' , value ) ;
}
case '$startsWith' : {
return qb . where ( column , 'like' , ` ${ value } % ` ) ;
}
case '$endsWith' : {
return qb . where ( column , 'like' , ` % ${ value } ` ) ;
}
case '$contains' : {
return qb . where ( column , 'like' , ` % ${ value } % ` ) ;
}
// TODO: json operators
// TODO: relational operators every/some/exists/size ...
default : {
throw new Error ( ` Undefined operator ${ operator } ` ) ;
}
}
} ) ;
} ;
const applyWhere = ( qb , where ) => {
if ( Array . isArray ( where ) ) {
return where . forEach ( subWhere => applyWhere ( qb , subWhere ) ) ;
}
if ( ! _ . isPlainObject ( where ) ) {
throw new Error ( 'Where must be an object' ) ;
}
Object . keys ( where ) . forEach ( key => {
const value = where [ key ] ;
if ( key === '$and' ) {
return qb . where ( qb => {
value . forEach ( v => applyWhere ( qb , v ) ) ;
} ) ;
}
if ( key === '$or' ) {
return qb . where ( qb => {
value . forEach ( v => qb . orWhere ( inner => applyWhere ( inner , v ) ) ) ;
} ) ;
}
if ( key === '$not' ) {
return qb . whereNot ( qb => applyWhere ( qb , value ) ) ;
}
applyWhereToColumn ( qb , key , value ) ;
} ) ;
} ;
// TODO: allow for more conditions
const applyJoin = ( qb , join ) => {
const {
method = 'leftJoin' ,
alias ,
referencedTable ,
referencedColumn ,
rootColumn ,
rootTable = this . alias ,
on ,
} = join ;
qb [ method ] ( { [ alias ] : referencedTable } , inner => {
inner . on ( ` ${ rootTable } . ${ rootColumn } ` , ` ${ alias } . ${ referencedColumn } ` ) ;
if ( on ) {
2021-06-18 12:27:47 +02:00
for ( const key in on ) {
inner . onVal ( ` ${ alias } . ${ key } ` , on [ key ] ) ;
}
2021-06-17 16:17:15 +02:00
}
} ) ;
} ;
const applyJoins = ( qb , joins ) => joins . forEach ( join => applyJoin ( qb , join ) ) ;
2021-06-30 20:00:03 +02:00
/ * *
* Converts and prepares the query for populate
*
* @ param { boolean | string [ ] | object } populate populate param
* @ param { object } ctx query context
* @ param { object } ctx . db database instance
* @ param { object } ctx . qb query builder instance
* @ param { string } ctx . uid model uid
* /
2021-06-17 16:17:15 +02:00
const processPopulate = ( populate , ctx ) => {
2021-06-28 21:37:44 +02:00
const { qb , db , uid } = ctx ;
2021-06-17 16:17:15 +02:00
const meta = db . metadata . get ( uid ) ;
2021-06-30 20:00:03 +02:00
let populateMap = { } ;
2021-06-17 16:17:15 +02:00
2021-06-30 20:00:03 +02:00
if ( populate === false ) {
return null ;
}
2021-06-22 17:13:11 +02:00
2021-06-30 20:00:03 +02:00
if ( typeof populate === 'string' && Boolean ( populate ) ) {
for ( const key in meta . attributes ) {
const attribute = meta . attributes [ key ] ;
2021-06-22 17:13:11 +02:00
2021-06-30 20:00:03 +02:00
if ( attribute . type === 'relation' ) {
populateMap [ key ] = true ;
2021-06-22 17:13:11 +02:00
}
2021-06-30 20:00:03 +02:00
}
} else if ( Array . isArray ( populate ) ) {
for ( const key of populate ) {
populateMap [ key ] = true ;
}
} else {
populateMap = populate ;
2021-06-22 17:13:11 +02:00
}
2021-06-30 20:00:03 +02:00
if ( ! _ . isPlainObject ( populateMap ) ) {
2021-06-17 16:17:15 +02:00
throw new Error ( 'Populate must be an object' ) ;
}
const finalPopulate = { } ;
2021-06-30 20:00:03 +02:00
for ( const key in populateMap ) {
2021-06-17 16:17:15 +02:00
const attribute = meta . attributes [ key ] ;
if ( ! attribute ) {
throw new Error ( ` Cannot populate unknown field ${ key } ` ) ;
}
if ( ! types . isRelation ( attribute . type ) ) {
throw new Error ( ` Invalid populate field. Expected a relation, got ${ attribute . type } ` ) ;
}
2021-06-28 21:37:44 +02:00
// TODO: make sure necessary columns are present for future populate queries
qb . addSelect ( 'id' ) ;
2021-06-30 20:00:03 +02:00
finalPopulate [ key ] = populateMap [ key ] ;
2021-06-17 16:17:15 +02:00
}
return finalPopulate ;
} ;
const applyPopulate = async ( results , populate , ctx ) => {
2021-06-28 21:37:44 +02:00
// TODO: cleanup code -> factorise
2021-06-25 12:07:32 +02:00
// TODO: create aliases for pivot columns
// TODO: remove joinColumn
// TODO: optimize depth to avoid overfetching
2021-06-17 16:17:15 +02:00
// TODO: ⚠️ on join tables we might want to make one query to find all the xxx_id then one query instead of a join to avoid returning multiple times the same object
2021-06-28 21:37:44 +02:00
// TODO: do the mapResults after the aggregations
2021-06-30 20:00:03 +02:00
// TODO: limit / offset is per result not for all results so we need ot loop and populate each result independently
2021-06-28 21:37:44 +02:00
2021-06-17 16:17:15 +02:00
const { db , uid , qb } = ctx ;
const meta = db . metadata . get ( uid ) ;
for ( const key in populate ) {
2021-06-30 20:00:03 +02:00
// TODO: Disable limit & offset in v1 to avoid needing a query per result (too many queries would ipact the performances a lot)
const populateValue = _ . pick ( [ 'select' , 'where' , 'populate' , 'orderBy' ] , populate [ key ] ) ;
2021-06-17 16:17:15 +02:00
const attribute = meta . attributes [ key ] ;
const targetMeta = db . metadata . get ( attribute . target ) ;
if ( attribute . relation === 'oneToOne' || attribute . relation === 'manyToOne' ) {
if ( attribute . joinColumn ) {
const {
name : joinColumnName ,
referencedColumn : referencedColumnName ,
} = attribute . joinColumn ;
const rr = await db . entityManager
. createQueryBuilder ( targetMeta . uid )
. init ( populateValue )
. addSelect ( ` ${ qb . alias } . ${ referencedColumnName } ` )
. where ( { [ referencedColumnName ] : results . map ( r => r [ joinColumnName ] ) } )
2021-06-28 21:37:44 +02:00
. execute ( { mapResults : false } ) ;
2021-06-17 16:17:15 +02:00
const rrMap = _ . groupBy ( referencedColumnName , rr ) ;
results . forEach ( r => {
2021-06-30 20:00:03 +02:00
Object . assign ( r , {
[ key ] : fromRow ( targetMeta , _ . first ( rrMap [ r [ joinColumnName ] ] ) ) || null ,
} ) ;
2021-06-17 16:17:15 +02:00
} ) ;
continue ;
}
if ( attribute . joinTable ) {
const { joinTable } = attribute ;
// query the target through the join table
const qb = db . entityManager . createQueryBuilder ( targetMeta . uid ) ;
// TODO: create aliases for the columns
const alias = qb . getAlias ( ) ;
const rr = await qb
. init ( populateValue )
. join ( {
alias : alias ,
referencedTable : joinTable . name ,
referencedColumn : joinTable . inverseJoinColumn . name ,
rootColumn : joinTable . inverseJoinColumn . referencedColumn ,
rootTable : qb . alias ,
on : joinTable . on ,
} )
2021-06-23 15:37:20 +02:00
. addSelect ( ` ${ alias } . ${ joinTable . joinColumn . name } ` )
2021-06-17 16:17:15 +02:00
. where ( {
[ ` ${ alias } . ${ joinTable . joinColumn . name } ` ] : results . map (
r => r [ joinTable . joinColumn . referencedColumn ]
) ,
} )
2021-06-28 21:37:44 +02:00
. execute ( { mapResults : false } ) ;
2021-06-17 16:17:15 +02:00
const rrMap = _ . groupBy ( joinTable . joinColumn . name , rr ) ;
results . forEach ( r => {
Object . assign ( r , {
2021-06-30 20:00:03 +02:00
[ key ] :
fromRow ( targetMeta , _ . first ( rrMap [ r [ joinTable . joinColumn . referencedColumn ] ] ) ) || null ,
2021-06-17 16:17:15 +02:00
} ) ;
} ) ;
continue ;
}
continue ;
} else if ( attribute . relation === 'oneToMany' ) {
if ( attribute . joinColumn ) {
const {
name : joinColumnName ,
referencedColumn : referencedColumnName ,
} = attribute . joinColumn ;
const rr = await db . entityManager
. createQueryBuilder ( targetMeta . uid )
. init ( populateValue )
. addSelect ( ` ${ qb . alias } . ${ referencedColumnName } ` )
. where ( { [ referencedColumnName ] : results . map ( r => r [ joinColumnName ] ) } )
2021-06-28 21:37:44 +02:00
. execute ( { mapResults : false } ) ;
2021-06-17 16:17:15 +02:00
const rrMap = _ . groupBy ( referencedColumnName , rr ) ;
results . forEach ( r => {
2021-06-30 20:00:03 +02:00
Object . assign ( r , {
[ key ] : ( rrMap [ r [ joinColumnName ] ] || [ ] ) . map ( entry => fromRow ( targetMeta , entry ) ) ,
} ) ;
2021-06-17 16:17:15 +02:00
} ) ;
continue ;
}
if ( attribute . joinTable ) {
const { joinTable } = attribute ;
const qb = db . entityManager . createQueryBuilder ( targetMeta . uid ) ;
const alias = qb . getAlias ( ) ;
const rr = await qb
. init ( populateValue )
. join ( {
alias : alias ,
referencedTable : joinTable . name ,
referencedColumn : joinTable . inverseJoinColumn . name ,
rootColumn : joinTable . inverseJoinColumn . referencedColumn ,
rootTable : qb . alias ,
on : joinTable . on ,
} )
2021-06-23 15:37:20 +02:00
. addSelect ( ` ${ alias } . ${ joinTable . joinColumn . name } ` )
2021-06-17 16:17:15 +02:00
. where ( {
[ ` ${ alias } . ${ joinTable . joinColumn . name } ` ] : results . map (
r => r [ joinTable . joinColumn . referencedColumn ]
) ,
} )
2021-06-28 21:37:44 +02:00
. execute ( { mapResults : false } ) ;
2021-06-17 16:17:15 +02:00
const rrMap = _ . groupBy ( joinTable . joinColumn . name , rr ) ;
results . forEach ( r => {
Object . assign ( r , {
2021-06-30 20:00:03 +02:00
[ key ] : ( rrMap [ r [ joinTable . joinColumn . referencedColumn ] ] || [ ] ) . map ( entry =>
fromRow ( targetMeta , entry )
) ,
2021-06-17 16:17:15 +02:00
} ) ;
} ) ;
continue ;
}
continue ;
} else if ( attribute . relation === 'manyToMany' ) {
const { joinTable } = attribute ;
const qb = db . entityManager . createQueryBuilder ( targetMeta . uid ) ;
const alias = qb . getAlias ( ) ;
const rr = await qb
. init ( populateValue )
. join ( {
alias : alias ,
referencedTable : joinTable . name ,
referencedColumn : joinTable . inverseJoinColumn . name ,
rootColumn : joinTable . inverseJoinColumn . referencedColumn ,
rootTable : qb . alias ,
on : joinTable . on ,
} )
2021-06-23 15:37:20 +02:00
. addSelect ( ` ${ alias } . ${ joinTable . joinColumn . name } ` )
2021-06-17 16:17:15 +02:00
. where ( {
[ ` ${ alias } . ${ joinTable . joinColumn . name } ` ] : results . map (
r => r [ joinTable . joinColumn . referencedColumn ]
) ,
} )
2021-06-28 21:37:44 +02:00
. execute ( { mapResults : false } ) ;
2021-06-17 16:17:15 +02:00
const rrMap = _ . groupBy ( joinTable . joinColumn . name , rr ) ;
results . forEach ( r => {
Object . assign ( r , {
2021-06-30 20:00:03 +02:00
[ key ] : ( rrMap [ r [ joinTable . joinColumn . referencedColumn ] ] || [ ] ) . map ( entry =>
fromRow ( targetMeta , entry )
) ,
2021-06-17 16:17:15 +02:00
} ) ;
} ) ;
2021-06-25 12:07:32 +02:00
continue ;
2021-06-17 16:17:15 +02:00
}
}
} ;
2021-06-30 20:00:03 +02:00
const fromRow = ( metadata , row ) => {
const { attributes } = metadata ;
if ( _ . isNil ( row ) ) {
return null ;
}
const obj = { } ;
for ( const column in row ) {
// to field Name
const attributeName = column ;
if ( ! attributes [ attributeName ] ) {
// ignore value that are not related to an attribute (join columns ...)
continue ;
}
const attribute = attributes [ attributeName ] ;
if ( types . isScalar ( attribute . type ) ) {
// TODO: we convert to column name
// TODO: handle default value too
// TODO: format data & use dialect to know which type they support (json particularly)
const field = createField ( attribute . type , attribute ) ;
// TODO: validate data on creation
// field.validate(data[attributeName]);
const val = row [ column ] === null ? null : field . fromDB ( row [ column ] ) ;
obj [ attributeName ] = val ;
}
if ( types . isRelation ( attribute . type ) ) {
obj [ attributeName ] = row [ column ] ;
}
}
return obj ;
} ;
2021-06-17 16:17:15 +02:00
module . exports = {
applyWhere ,
processWhere ,
applyJoins ,
applyJoin ,
processOrderBy ,
processPopulate ,
applyPopulate ,
2021-06-30 20:00:03 +02:00
fromRow ,
2021-06-17 16:17:15 +02:00
} ;