Skip to content

Commit

Permalink
feat(minato): support left join in two-table joins (#89)
Browse files Browse the repository at this point in the history
  • Loading branch information
Hieuzest committed Apr 26, 2024
1 parent fe8c717 commit 06d6562
Show file tree
Hide file tree
Showing 5 changed files with 106 additions and 17 deletions.
6 changes: 5 additions & 1 deletion packages/core/src/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,12 +244,16 @@ export class Database<S = {}, N = {}, C extends Context = Context> extends Servi
if (Array.isArray(oldTables)) {
tables = Object.fromEntries(oldTables.map((name) => [name, this.select(name)]))
}
const sels = mapValues(tables, (t: TableLike<S>) => {
let sels = mapValues(tables, (t: TableLike<S>) => {
return typeof t === 'string' ? this.select(t) : t
})
if (Object.keys(sels).length === 0) throw new Error('no tables to join')
const drivers = new Set(Object.values(sels).map(sel => sel.driver))
if (drivers.size !== 1) throw new Error('cannot join tables from different drivers')
if (Object.keys(sels).length === 2 && (optional?.[0] || optional?.[Object.keys(sels)[0]])) {
if (optional[1] || optional[Object.keys(sels)[1]]) throw new Error('full join is not supported')
sels = Object.fromEntries(Object.entries(sels).reverse())
}
const sel = new Selection([...drivers][0], sels)
if (Array.isArray(oldTables)) {
sel.args[0].having = Eval.and(query(...oldTables.map(name => sel.row[name])))
Expand Down
12 changes: 9 additions & 3 deletions packages/memory/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export class MemoryDriver extends Driver<MemoryDriver.Config> {
}

const { ref, query, table, args, model } = sel
const { fields, group, having } = sel.args[0]
const { fields, group, having, optional = {} } = sel.args[0]

let data: any[]

Expand All @@ -42,9 +42,15 @@ export class MemoryDriver extends Driver<MemoryDriver.Config> {
if (!entries.length) return []
const [[name, rows], ...tail] = entries
if (!tail.length) return rows.map(row => ({ [name]: row }))
return rows.flatMap(row => catesian(tail).map(tail => ({ ...tail, [name]: row })))
return rows.flatMap(row => {
let res = catesian(tail).map(tail => ({ ...tail, [name]: row }))
if (Object.keys(table).length === tail.length + 1) {
res = res.map(row => ({ ...env, [ref]: row })).filter(data => executeEval(data, having)).map(x => x[ref])
}
return !optional[tail[0]?.[0]] || res.length ? res : [{ [name]: row }]
})
}
data = catesian(entries).map(x => ({ ...env, [ref]: x })).filter(data => executeEval(data, having)).map(x => x[ref])
data = catesian(entries)
} else {
data = this.table(table, env).filter(row => executeQuery(row, query, ref, env))
}
Expand Down
36 changes: 29 additions & 7 deletions packages/mongo/src/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export class Builder {
public evalKey?: string
private refTables: string[] = []
private refVirtualKeys: Dict<string> = {}
private joinTables: Dict<string> = {}
public aggrDefault: any

private evalOperators: EvalOperators
Expand All @@ -102,6 +103,12 @@ export class Builder {
if (typeof arg === 'string') {
this.walkedKeys.push(this.getActualKey(arg))
return this.recursivePrefix + this.getActualKey(arg)
}
const [joinRoot, ...rest] = (arg[1] as string).split('.')
if (this.tables.includes(`${arg[0]}.${joinRoot}`)) {
return this.recursivePrefix + rest.join('.')
} else if (`${arg[0]}.${joinRoot}` in this.joinTables) {
return `$$${this.joinTables[`${arg[0]}.${joinRoot}`]}.` + rest.join('.')
} else if (this.tables.includes(arg[0])) {
this.walkedKeys.push(this.getActualKey(arg[1]))
return this.recursivePrefix + this.getActualKey(arg[1])
Expand Down Expand Up @@ -376,6 +383,7 @@ export class Builder {
protected createSubquery(sel: Selection.Immutable) {
const predecessor = new Builder(this.driver, Object.keys(sel.tables))
predecessor.refTables = [...this.refTables, ...this.tables]
predecessor.joinTables = { ...this.joinTables }
predecessor.refVirtualKeys = this.refVirtualKeys
return predecessor.select(sel)
}
Expand All @@ -391,30 +399,44 @@ export class Builder {
this.table = predecessor.table
this.pipeline.push(...predecessor.flushLookups(), ...predecessor.pipeline)
} else {
for (const [name, subtable] of Object.entries(table)) {
const refs: Dict<string> = {}
Object.entries(table).forEach(([name, subtable], i) => {
const predecessor = this.createSubquery(subtable)
if (!predecessor) return
if (!this.table) {
this.table = predecessor.table
this.pipeline.push(...predecessor.flushLookups(), ...predecessor.pipeline, {
$replaceRoot: { newRoot: { [name]: '$$ROOT' } },
})
continue
refs[name] = subtable.ref
return
}
if (sel.args[0].having['$and'].length && i === Object.keys(table).length - 1) {
const thisTables = this.tables, thisJoinedTables = this.joinTables
this.tables = [...this.tables, `${sel.ref}.${name}`]
this.joinTables = {
...this.joinTables,
[`${sel.ref}.${name}`]: sel.ref,
...Object.fromEntries(Object.entries(refs).map(([name, ref]) => [`${sel.ref}.${name}`, ref])),
}
const $expr = this.eval(sel.args[0].having['$and'][0])
predecessor.pipeline.push(...this.flushLookups(), { $match: { $expr } })
this.tables = thisTables
this.joinTables = thisJoinedTables
}
const $lookup = {
from: predecessor.table,
as: name,
let: Object.fromEntries(Object.entries(refs).map(([name, ref]) => [ref, `$$ROOT.${name}`])),
pipeline: predecessor.pipeline,
}
const $unwind = {
path: `$${name}`,
preserveNullAndEmptyArrays: !!sel.args[0].optional?.[name],
}
this.pipeline.push({ $lookup }, { $unwind })
}
if (sel.args[0].having['$and'].length) {
const $expr = this.eval(sel.args[0].having)
this.pipeline.push(...this.flushLookups(), { $match: { $expr } })
}
refs[name] = subtable.ref
})
}

// where
Expand Down
14 changes: 10 additions & 4 deletions packages/sql-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,15 +483,21 @@ export class Builder {
if (!prefix) return
} else {
this.state.innerTables = Object.fromEntries(Object.values(table).map(t => [t.ref, t.model]))
const joins: string[] = Object.entries(table).map(([key, table]) => {
const joins: [string, string][] = Object.entries(table).map(([key, table]) => {
const restore = this.saveState({ tables: { ...table.tables } })
const t = `${this.get(table, true, false, false)} AS ${this.escapeId(table.ref)}`
restore()
return t
return [key, t]
})

// the leading space is to prevent from being parsed as bracketed and added ref
prefix = ' ' + joins[0] + joins.slice(1, -1).map(join => ` JOIN ${join} ON ${this.$true}`).join(' ') + ` JOIN ` + joins.at(-1)
prefix = [
// the leading space is to prevent from being parsed as bracketed and added ref
' ',
joins[0][1],
...joins.slice(1, -1).map(([key, join]) => `${args[0].optional?.[key] ? 'LEFT' : ''} JOIN ${join} ON ${this.$true}`),
`${args[0].optional?.[joins.at(-1)![0]] ? 'LEFT ' : ''}JOIN`,
joins.at(-1)![1],
].join(' ')
const filter = this.parseEval(args[0].having)
prefix += ` ON ${filter}`
}
Expand Down
55 changes: 53 additions & 2 deletions packages/tests/src/selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,42 @@ namespace SelectionTests {
).to.eventually.have.length(2)
})

it('left join', async () => {
await expect(database
.join(['foo', 'bar'], (foo, bar) => $.eq(foo.value, bar.value), [false, true])
.execute()
).to.eventually.have.shape([
{
foo: { value: 0, id: 1 },
bar: { uid: 1, pid: 1, value: 0, id: 1 },
},
{
foo: { value: 0, id: 1 },
bar: { uid: 1, pid: 2, value: 0, id: 3 },
},
{ foo: { value: 2, id: 2 }, bar: {} },
{ foo: { value: 2, id: 3 }, bar: {} },
])

await expect(database
.join(['foo', 'bar'], (foo, bar) => $.eq(foo.value, bar.value), [true, false])
.execute()
).to.eventually.have.shape([
{
bar: { uid: 1, pid: 1, value: 0, id: 1 },
foo: { value: 0, id: 1 },
},
{ bar: { uid: 1, pid: 1, value: 1, id: 2 }, foo: {} },
{
bar: { uid: 1, pid: 2, value: 0, id: 3 },
foo: { value: 0, id: 1 },
},
{ bar: { uid: 1, pid: 3, value: 1, id: 4 }, foo: {} },
{ bar: { uid: 2, pid: 1, value: 1, id: 5 }, foo: {} },
{ bar: { uid: 2, pid: 1, value: 1, id: 6 }, foo: {} },
])
})

it('group', async () => {
await expect(database.join(['foo', 'bar'], (foo, bar) => $.eq(foo.id, bar.pid))
.groupBy('foo', { count: row => $.sum(row.bar.uid) })
Expand Down Expand Up @@ -323,9 +359,9 @@ namespace SelectionTests {
t1: database.select('bar').where(row => $.gt(row.pid, 1)),
t2: database.select('bar').where(row => $.gt(row.uid, 1)),
t3: database.select('bar').where(row => $.gt(row.id, 4)),
}, ({ t1, t2, t3 }) => $.gt($.add(t1.id, t2.id, t3.id), 0))
}, ({ t1, t2, t3 }) => $.gt($.add(t1.id, t2.id, t3.id), 14))
.execute()
).to.eventually.have.length(8)
).to.eventually.have.length(4)
})

it('aggregate', async () => {
Expand Down Expand Up @@ -442,6 +478,21 @@ namespace SelectionTests {
).to.eventually.have.length(6)
})

it('selections', async () => {
const w = x => database.join(['bar', 'foo']).evaluate(row => $.add($.count(row.bar.id), -6, x))
await expect(database
.join({
t1: database.select('bar').where(row => $.gt(w(row.pid), 1)),
t2: database.select('bar').where(row => $.gt(row.uid, 1)),
t3: database.select('bar').where(row => $.gt(row.id, w(4))),
}, ({ t1, t2, t3 }) => $.gt($.add(t1.id, t2.id, w(t3.id)), 14))
.project({
val: row => $.add(row.t1.id, row.t2.id, w(row.t3.id)),
})
.execute()
).to.eventually.have.length(4)
})

it('access from join', async () => {
const w = x => database.select('bar').evaluate(row => $.add($.count(row.id), -6, x))
await expect(database
Expand Down

0 comments on commit 06d6562

Please sign in to comment.