Skip to content

Commit

Permalink
feat(postgres): add relations
Browse files Browse the repository at this point in the history
  • Loading branch information
calebmer committed Oct 9, 2016
1 parent b288224 commit 807c93a
Show file tree
Hide file tree
Showing 12 changed files with 146 additions and 44 deletions.
15 changes: 12 additions & 3 deletions src/graphql/__tests__/fixtures/forumInventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,23 @@ const personType = new ObjectType({
})

const personIdKey: CollectionKey<number> = {
collection: null as any,
name: 'id',
keyType: integerType,
getKeyFromValue: unimplementedFn,
read: unimplementedFn,
}

const personNameKey: CollectionKey<string> = {
collection: null as any,
name: 'name',
keyType: stringType,
getKeyFromValue: unimplementedFn,
read: unimplementedFn,
}

const personEmailKey: CollectionKey<string> = {
collection: null as any,
name: 'email',
keyType: stringType,
getKeyFromValue: unimplementedFn,
Expand All @@ -71,11 +74,15 @@ const personPaginator: Paginator<ObjectType.Value, mixed> = {
const personCollection: Collection = {
name: 'people',
type: personType,
keys: new Set([personIdKey, personNameKey, personEmailKey]),
keys: [personIdKey, personNameKey, personEmailKey],
primaryKey: personIdKey,
paginator: personPaginator,
}

Object.assign(personIdKey, { collection: personCollection })
Object.assign(personNameKey, { collection: personCollection })
Object.assign(personEmailKey, { collection: personCollection })

const postStatusType = new EnumType({
name: 'postStatus',
variants: new Set(['unpublished', 'published']),
Expand All @@ -93,6 +100,7 @@ const postType = new ObjectType({
})

const postIdKey: CollectionKey<number> = {
collection: null as any,
name: 'id',
keyType: integerType,
getKeyFromValue: unimplementedFn,
Expand All @@ -118,15 +126,16 @@ const postPaginator: Paginator<ObjectType.Value, mixed> = {
const postCollection: Collection = {
name: 'posts',
type: postType,
keys: new Set([postIdKey]),
keys: [postIdKey],
primaryKey: postIdKey,
paginator: postPaginator,
}

Object.assign(postIdKey, { collection: postCollection })

const authorRelation: Relation<number> = {
name: 'author',
tailCollection: postCollection,
headCollection: personCollection,
headCollectionKey: personIdKey,
getHeadKeyFromTailValue: unimplementedFn,
}
Expand Down
2 changes: 1 addition & 1 deletion src/graphql/schema/collection/getCollectionType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,8 @@ function createCollectionType (buildToken: BuildToken, collection: Collection):
)
// Transform the relation into a field entry.
.map(<THeadValue, TKey>(relation: Relation<TKey>): [string, GraphQLFieldConfig<GraphQLCollectionValue, GraphQLCollectionValue>] => {
const headCollection = relation.headCollection
const headCollectionKey = relation.headCollectionKey
const headCollection = headCollectionKey.collection

return [formatName.field(`${headCollection.type.name}-by-${relation.name}`), {
// TODO: description
Expand Down
9 changes: 3 additions & 6 deletions src/interface/Inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,16 +128,13 @@ class Inventory {
* members of this inventory we fail with an error.
*/
public addRelation (relation: Relation<mixed>): this {
const { name, tailCollection, headCollection, headCollectionKey } = relation

if (headCollectionKey && !headCollection.keys.has(headCollectionKey))
throw new Error(`Head collection key '${headCollectionKey.name}' is not valid for head collection '${headCollection.name}'.`)
const { name, tailCollection, headCollectionKey } = relation

if (!this.hasCollection(tailCollection))
throw new Error(`Tail collection named '${tailCollection.name}' is not in this inventory.`)

if (!this.hasCollection(headCollection))
throw new Error(`Head collection named '${headCollection.name}' is not in this inventory.`)
if (!this.hasCollection(headCollectionKey.collection))
throw new Error(`Head collection named '${headCollectionKey.collection.name}' is not in this inventory.`)

if (this._relations.has(name))
throw new Error(`Relation of name '${name}' already exists in the inventory.`)
Expand Down
21 changes: 4 additions & 17 deletions src/interface/__tests__/Inventory-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,25 +51,12 @@ test('hasCollection will return if the exact collection exists in the inventory'
expect(inventory.hasCollection(collection2)).toBe(false)
})

test('addRelation will fail if a key is not in the head collection keys', () => {
const inventory = new Inventory()
const collectionKey1 = { name: 'a' }
const collectionKey2 = { name: 'b' }
const collection1 = { name: 'a', keys: new Set([collectionKey1]) }
const collection2 = { name: 'b', keys: new Set([collectionKey2]) }
const relation1 = { name: 'a', tailCollection: collection1, headCollection: collection2, headCollectionKey: collectionKey1 }
const relation2 = { name: 'b', tailCollection: collection2, headCollection: collection1, headCollectionKey: collectionKey1 }
inventory.addCollection(collection1).addCollection(collection2)
expect(() => inventory.addRelation(relation1)).toThrow()
expect(() => inventory.addRelation(relation2)).not.toThrow()
})

test('addRelation will fail unless both the head and tail collections exist in the inventory', () => {
const inventory = new Inventory()
const collection1 = { name: 'a' }
const collection2 = { name: 'b' }
const relation1 = { name: 'a', tailCollection: collection1, headCollection: collection2 }
const relation2 = { name: 'b', tailCollection: collection2, headCollection: collection1 }
const relation1 = { name: 'a', tailCollection: collection1, headCollectionKey: { collection: collection2 } }
const relation2 = { name: 'b', tailCollection: collection2, headCollectionKey: { collection: collection1 } }
expect(() => inventory.addRelation(relation1)).toThrow()
expect(() => inventory.addRelation(relation2)).toThrow()
inventory.addCollection(collection1)
Expand All @@ -84,8 +71,8 @@ test('getRelations will get all of the relations that have been added to the inv
const inventory = new Inventory()
const collection1 = { name: 'a' }
const collection2 = { name: 'b' }
const relation1 = { name: 'a', tailCollection: collection1, headCollection: collection2 }
const relation2 = { name: 'b', tailCollection: collection2, headCollection: collection1 }
const relation1 = { name: 'a', tailCollection: collection1, headCollectionKey: { collection: collection2 } }
const relation2 = { name: 'b', tailCollection: collection2, headCollectionKey: { collection: collection1 } }
expect(inventory.getRelations()).toEqual([])
expect(() => inventory.addRelation(relation1)).toThrow()
expect(() => inventory.addRelation(relation2)).toThrow()
Expand Down
10 changes: 10 additions & 0 deletions src/interface/collection/CollectionKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ import Collection from './Collection'
interface CollectionKey<TKeyValue> {
/**
* The collection this key is for.
*
* An instance of `CollectionKey` will almost always need to have an instance
* of the collection it is for. This is so that it can correctly implement
* methods like `getKeyFromValue`, `update`, and so on which need information
* about the collection object type itself.
*
* Establishing this circular dependency is also helpful in that `Relation`
* instances can use this.
*
* Everyone wins, so we add it to the interface.
*/
readonly collection: Collection

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ type CompoundKey implements Node {
__id: ID!
personId2: Int!
personId1: Int!
personByPersonId2: Person
personByPersonId1: Person
}
scalar Email
Expand Down Expand Up @@ -34,6 +36,7 @@ type Post implements Node {
headline: String!
body: String
authorId: Int
personByAuthorId: Person
}
type Query {
Expand Down Expand Up @@ -65,6 +68,8 @@ type CompoundKey implements Node {
id: ID!
personId2: Int!
personId1: Int!
personByPersonId2: Person
personByPersonId1: Person
}
scalar Email
Expand Down Expand Up @@ -92,6 +97,7 @@ type Post implements Node {
headline: String!
body: String
authorId: Int
personByAuthorId: Person
}
type Query {
Expand Down
15 changes: 12 additions & 3 deletions src/postgres/introspection/PGCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,18 @@ class PGCatalog {

/**
* Gets all of the attributes for a single class.
*/
public getClassAttributes (classId: string): Array<PGCatalogAttribute> {
return Array.from(this._attributes.values()).filter(attribute => attribute.classId === classId)
*
* If provided an array of `nums`, we will get only those attributes in the
* enumerated order. Otherwise we get all attributes in the order of their
* definition.
*/
public getClassAttributes (classId: string, nums?: Array<number>): Array<PGCatalogAttribute> {
// Currently if we get a `nums` array we use a completely different
// implementation to preserve the `nums` order..
if (nums)
return nums.map(num => this.assertGetAttribute(classId, num))

return Array.from(this._attributes.values()).filter(pgAttribute => pgAttribute.classId === classId)
}

/**
Expand Down
19 changes: 13 additions & 6 deletions src/postgres/introspection/__tests__/PGCatalog-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ const mockClasses = [
]

const mockAttributes = [
{ kind: 'attribute', classId: '2', num: '0', name: 'a' },
{ kind: 'attribute', classId: '2', num: '1', name: 'b' },
{ kind: 'attribute', classId: '2', num: '2', name: 'c' },
{ kind: 'attribute', classId: '3', num: '0', name: 'a' },
{ kind: 'attribute', classId: '4', num: '0', name: 'b' },
{ kind: 'attribute', classId: '4', num: '1', name: 'c' },
{ kind: 'attribute', classId: '2', num: 0, name: 'a' },
{ kind: 'attribute', classId: '2', num: 1, name: 'b' },
{ kind: 'attribute', classId: '2', num: 2, name: 'c' },
{ kind: 'attribute', classId: '3', num: 0, name: 'a' },
{ kind: 'attribute', classId: '4', num: 0, name: 'b' },
{ kind: 'attribute', classId: '4', num: 1, name: 'c' },
]

const mockTypes = [
Expand Down Expand Up @@ -125,6 +125,13 @@ test('getClassAttributes will get all the attributes for a class', () => {
expect(catalog.getClassAttributes('4')).toEqual([mockAttributes[4], mockAttributes[5]])
})

test('getClassAttributes will only get class attributes of certain positions if passed an extra argument', () => {
expect(catalog.getClassAttributes('2', [])).toEqual([])
expect(catalog.getClassAttributes('2', [0, 2])).toEqual([mockAttributes[0], mockAttributes[2]])
expect(catalog.getClassAttributes('2', [2, 0])).toEqual([mockAttributes[2], mockAttributes[0]])
expect(catalog.getClassAttributes('2', [1])).toEqual([mockAttributes[1]])
})

test('getAttributeByName will get an attribute by its name', () => {
expect(catalog.getAttributeByName('a', 'a', 'a')).toBe(mockAttributes[0])
expect(catalog.getAttributeByName('a', 'a', 'b')).toBe(mockAttributes[1])
Expand Down
43 changes: 40 additions & 3 deletions src/postgres/inventory/addPGToInventory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Inventory } from '../../interface'
import { PGCatalog, PGCatalogAttribute } from '../introspection'
import PGCollection from './collection/PGCollection'
import PGRelation from './collection/PGRelation'
import Options from './Options'

/**
Expand All @@ -19,9 +20,45 @@ export default function addPGToInventory (
renameIdToRowId: config.renameIdToRowId || false,
}

// We save a reference to all our collections by their class’s id so that we
// can reference them again later.
const collectionByClassId = new Map<string, PGCollection>()

// Add all of our collections. If a class is not selectable, it is probably a
// compound type and we shouldn’t add a collection for it to our inventory.
for (const pgClass of pgCatalog.getClasses())
if (pgClass.isSelectable)
inventory.addCollection(new PGCollection(options, pgCatalog, pgClass))
for (const pgClass of pgCatalog.getClasses()) {
if (pgClass.isSelectable) {
const collection = new PGCollection(options, pgCatalog, pgClass)
inventory.addCollection(collection)
collectionByClassId.set(pgClass.id, collection)
}
}

// Add all of the relations that exist in our database to the inventory. We
// discover relations by looking at foreign key constraints in Postgres.
for (const pgConstraint of pgCatalog.getConstraints()) {
if (pgConstraint.type === 'f') {
// TODO: This implementation of relation could be better…
inventory.addRelation(new PGRelation(
collectionByClassId.get(pgConstraint.classId)!,

// Here we get the collection key for our foreign table that has the
// same key attribute numbers we are looking for.
collectionByClassId.get(pgConstraint.foreignClassId)!.keys
.find(key => {
const numsA = pgConstraint.foreignKeyAttributeNums
const numsB = key._pgConstraint.keyAttributeNums

// Make sure that the length of `numsA` and `numsB` are the same.
if (numsA.length !== numsB.length) return false

// Make sure all of the items in `numsA` are also in `numsB` (order
// does not matter).
return numsA.reduce((last, num) => last && numsB.indexOf(num) !== -1, true)
})!,

pgConstraint,
))
}
}
}
6 changes: 1 addition & 5 deletions src/postgres/inventory/collection/PGCollectionKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,7 @@ class PGCollectionKey implements CollectionKey<PGObjectType.Value> {
private _pgCatalog = this.collection._pgCatalog
private _pgClass = this._pgCatalog.assertGetClass(this._pgConstraint.classId)
private _pgNamespace = this._pgCatalog.assertGetNamespace(this._pgClass.namespaceId)

private _pgKeyAttributes = (
this._pgConstraint.keyAttributeNums
.map(num => this._pgCatalog.assertGetAttribute(this._pgConstraint.classId, num))
)
private _pgKeyAttributes = this._pgCatalog.getClassAttributes(this._pgConstraint.classId, this._pgConstraint.keyAttributeNums)

/**
* A type used to represent a key value. Consumers can then use this
Expand Down
37 changes: 37 additions & 0 deletions src/postgres/inventory/collection/PGRelation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Relation } from '../../../interface'
import { PGCatalog, PGCatalogForeignKeyConstraint } from '../../introspection'
import PGObjectType from '../type/PGObjectType'
import PGCollection from './PGCollection'
import PGCollectionKey from './PGCollectionKey'

// TODO: This implementation is sketchy. Implement it better!
class PGRelation implements Relation<PGObjectType.Value> {
constructor (
public tailCollection: PGCollection,
public headCollectionKey: PGCollectionKey,
private _pgConstraint: PGCatalogForeignKeyConstraint,
) {}

private _pgCatalog = this.tailCollection._pgCatalog
private _pgTailAttributes = this._pgCatalog.getClassAttributes(this._pgConstraint.classId, this._pgConstraint.keyAttributeNums)
private _tailFieldNames = this._pgTailAttributes.map(pgAttribute => this.tailCollection.type.getPGAttributeFieldName(pgAttribute)!)
private _headFieldNames = Array.from(this.headCollectionKey.keyType.fields.keys())

/**
* Construct the name for this relation using the native Postgres foreign key
* constraint attributes.
*/
public readonly name = this._tailFieldNames.join('_and_')

/**
* Gets an instance of the head collection’s key type by just extracting some keys from
*/
public getHeadKeyFromTailValue (value: PGObjectType.Value): PGObjectType.Value {
return this._tailFieldNames.reduce((headKey, tailFieldName, i) => {
headKey.set(this._headFieldNames[i], value.get(tailFieldName))
return headKey
}, new Map())
}
}

export default PGRelation
7 changes: 7 additions & 0 deletions src/postgres/inventory/type/PGObjectType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ class PGObjectType extends ObjectType {
),
})
}

// TODO: This was added for the sketchy `PGRelation` implementation.
// Implement it better!
public getPGAttributeFieldName (pgAttribute: PGCatalogAttribute): string | undefined {
const fieldEntry = Array.from(this.fields).find(([fieldName, field]) => field.pgAttribute === pgAttribute)
return fieldEntry && fieldEntry[0]
}
}

namespace PGObjectType {
Expand Down

0 comments on commit 807c93a

Please sign in to comment.