Repository: frictionlessdata/goodtables-py

Issue:
Add support for foreign keys?
Below is my datapackage.json

`{ "name": "testing", "resources": [ { "name": "data", "path": "data.csv", "schema": { "fields": [ { "name": "id", "type": "string", "constraints": { "required": true } }, { "name": "name", "type": "string" }, { "name": "description", "type": "string" }, { "name": "amount", "type": "number" } ], "primaryKey": ["id"] } }, { "name": "data2", "path": "data2.csv", "schema": { "fields": [ { "name": "id" }, { "type": "string", "name": "comment" } ], "foreignKeys": [ { "fields": ["id"], "reference": { "resource": "data", "fields": ["id"] } } ] } } ] }`

**data.csv**
id,name,description,amount
A999,Taxes,Taxes we collect,10000.5
A5032,Parking Fees,Parking fees we collect,2000.5

**data2.csv**

id,comment
A3001,comment1
A3001,comment2
A5032,comment3

As you can see data2.csv has A3001 in id column but data.csv does not but when I validate
report = validate('datapackage.json',preset='datapackage')

The report does not show any error

Can foreign key constraint check be added to goodtables-py.

Discussion:

Patch:
diff --git a/README.md b/README.md
index 4514cc7..1bbedfe 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,7 @@ of your data (e.g. all rows have the same number of columns), and its contents
     - [Advanced checks](#advanced-checks)
       - [blacklisted-value](#blacklisted-value)
       - [deviated-value](#deviated-value)
+      - [foreign-key](#foreign-key)
       - [sequential-value](#sequential-value)
       - [truncated-value](#truncated-value)
       - [custom-constraint](#custom-constraint)
@@ -146,6 +147,7 @@ Lastly, if the order of the fields in the data is different than in your schema,
 | --- | --- |
 | [blacklisted-value](#blacklisted-value) | Ensure there are no cells with the blacklisted values. |
 | [deviated-value](#deviated-value) | Ensure numbers are within a number of standard deviations from the average. |
+| [foreign-key](#foreign-key) | Ensure foreign keys are valid within a data package |
 | [sequential-value](#sequential-value) | Ensure numbers are sequential. |
 | [truncated-value](#truncated-value) | Detect values that were potentially truncated. |
 | [custom-constraint](#custom-constraint) | Defines a constraint based on the values of other columns (e.g. `value * quantity == total`). |
@@ -217,6 +219,107 @@ report = validate('data.csv', checks=[
 # error on row 10 with code "deviated-value"
 ```
 
+#### foreign-key
+
+> We support here relative paths. It MUST be used only for trusted data sources.
+
+This check validate foreign keys within a data package. Consider we have a data package defined below:
+
+```python
+DESCRIPTOR = {
+  'resources': [
+    {
+      'name': 'cities',
+      'data': [
+        ['id', 'name', 'next_id'],
+        [1, 'london', 2],
+        [2, 'paris', 3],
+        [3, 'rome', 4],
+        # [4, 'rio', None],
+      ],
+      'schema': {
+        'fields': [
+          {'name': 'id', 'type': 'integer'},
+          {'name': 'name', 'type': 'string'},
+          {'name': 'next_id', 'type': 'integer'},
+        ],
+        'foreignKeys': [
+          {
+            'fields': 'next_id',
+            'reference': {'resource': '', 'fields': 'id'},
+          },
+          {
+            'fields': 'id',
+            'reference': {'resource': 'people', 'fields': 'label'},
+          },
+        ],
+      },
+    }, {
+      'name': 'people',
+      'data': [
+        ['label', 'population'],
+        [1, 8],
+        [2, 2],
+        # [3, 3],
+        # [4, 6],
+      ],
+    },
+  ],
+}
+```
+
+Running `goodtables` on it will raise a few `foreign-key` errors because we have commented some rows in the data package's data:
+
+```python
+report = validate(DESCRIPTOR, checks=['structure', 'schema', 'foreign-key'])
+print(report)
+```
+
+```
+{'error-count': 2,
+ 'preset': 'datapackage',
+ 'table-count': 2,
+ 'tables': [{'datapackage': '...',
+             'error-count': 2,
+             'errors': [{'code': 'foreign-key',
+                         'message': 'Foreign key "[\'next_id\']" violation in '
+                                    'row 4',
+                         'message-data': {'fields': ['next_id']},
+                         'row-number': 4},
+                        {'code': 'foreign-key',
+                         'message': 'Foreign key "[\'id\']" violation in row 4',
+                         'message-data': {'fields': ['id']},
+                         'row-number': 4}],
+             'format': 'inline',
+             'headers': ['id', 'name', 'next_id'],
+             'resource-name': 'cities',
+             'row-count': 4,
+             'schema': 'table-schema',
+             'source': 'inline',
+             'time': 0.031,
+             'valid': False},
+            {'datapackage': '...',
+             'error-count': 0,
+             'errors': [],
+             'format': 'inline',
+             'headers': ['label', 'population'],
+             'resource-name': 'people',
+             'row-count': 3,
+             'source': 'inline',
+             'time': 0.038,
+             'valid': True}],
+ 'time': 0.117,
+ 'valid': False,
+ 'warnings': []}
+```
+
+It experimetally supports external resource checks, for example, for a `foreignKey` definition like these:
+
+```json
+{"package": "../people/datapackage.json", "resource": "people", "fields": "label"}
+{"package": "http:/example.com/datapackage.json", "resource": "people", "fields": "label"}
+```
+
 #### sequential-value
 
 This checks is for pretty common case when a column should have integers that sequentially increment.  It accepts following options:
diff --git a/data/datapackages_linked/cities/cities.csv b/data/datapackages_linked/cities/cities.csv
new file mode 100644
index 0000000..d896cd2
--- /dev/null
+++ b/data/datapackages_linked/cities/cities.csv
@@ -0,0 +1,5 @@
+id,name,next_id
+1,london,2
+2,paris,3
+3,rome,4
+4,rio,
diff --git a/data/datapackages_linked/cities/datapackage.json b/data/datapackages_linked/cities/datapackage.json
new file mode 100644
index 0000000..fc32533
--- /dev/null
+++ b/data/datapackages_linked/cities/datapackage.json
@@ -0,0 +1,39 @@
+{
+  "profile": "tabular-data-package",
+  "resources": [
+    {
+      "path": "cities.csv",
+      "profile": "tabular-data-resource",
+      "name": "cities",
+      "schema": {
+        "fields": [
+          {
+            "name": "id",
+            "type": "integer"
+          },
+          {
+            "name": "name",
+            "type": "string"
+          },
+          {
+            "name": "next_id",
+            "type": "integer"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "fields": "next_id",
+            "reference": {"resource": "", "fields": "id"}
+          },
+          {
+            "fields": "id",
+            "reference": {"package": "../people/datapackage.json", "resource": "people", "fields": "label"}
+          }
+        ],
+        "missingValues": [
+          ""
+        ]
+      }
+    }
+  ]
+}
diff --git a/data/datapackages_linked/people/datapackage.json b/data/datapackages_linked/people/datapackage.json
new file mode 100644
index 0000000..e0d69a9
--- /dev/null
+++ b/data/datapackages_linked/people/datapackage.json
@@ -0,0 +1,24 @@
+{
+  "profile": "tabular-data-package",
+  "resources": [
+    {
+      "path": "people.csv",
+      "name": "people",
+      "schema": {
+        "fields": [
+          {
+            "name": "label",
+            "type": "integer"
+          },
+          {
+            "name": "population",
+            "type": "string"
+          }
+        ],
+        "missingValues": [
+          ""
+        ]
+      }
+    }
+  ]
+}
diff --git a/data/datapackages_linked/people/people.csv b/data/datapackages_linked/people/people.csv
new file mode 100644
index 0000000..d0590da
--- /dev/null
+++ b/data/datapackages_linked/people/people.csv
@@ -0,0 +1,5 @@
+label,population
+1,8
+2,2
+3,3
+4,6
diff --git a/data/datapackages_linked_errors/cities/cities.csv b/data/datapackages_linked_errors/cities/cities.csv
new file mode 100644
index 0000000..cc4fe27
--- /dev/null
+++ b/data/datapackages_linked_errors/cities/cities.csv
@@ -0,0 +1,4 @@
+id,name,next_id
+1,london,2
+2,paris,3
+3,rome,4
diff --git a/data/datapackages_linked_errors/cities/datapackage.json b/data/datapackages_linked_errors/cities/datapackage.json
new file mode 100644
index 0000000..fc32533
--- /dev/null
+++ b/data/datapackages_linked_errors/cities/datapackage.json
@@ -0,0 +1,39 @@
+{
+  "profile": "tabular-data-package",
+  "resources": [
+    {
+      "path": "cities.csv",
+      "profile": "tabular-data-resource",
+      "name": "cities",
+      "schema": {
+        "fields": [
+          {
+            "name": "id",
+            "type": "integer"
+          },
+          {
+            "name": "name",
+            "type": "string"
+          },
+          {
+            "name": "next_id",
+            "type": "integer"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "fields": "next_id",
+            "reference": {"resource": "", "fields": "id"}
+          },
+          {
+            "fields": "id",
+            "reference": {"package": "../people/datapackage.json", "resource": "people", "fields": "label"}
+          }
+        ],
+        "missingValues": [
+          ""
+        ]
+      }
+    }
+  ]
+}
diff --git a/data/datapackages_linked_errors/people/datapackage.json b/data/datapackages_linked_errors/people/datapackage.json
new file mode 100644
index 0000000..e0d69a9
--- /dev/null
+++ b/data/datapackages_linked_errors/people/datapackage.json
@@ -0,0 +1,24 @@
+{
+  "profile": "tabular-data-package",
+  "resources": [
+    {
+      "path": "people.csv",
+      "name": "people",
+      "schema": {
+        "fields": [
+          {
+            "name": "label",
+            "type": "integer"
+          },
+          {
+            "name": "population",
+            "type": "string"
+          }
+        ],
+        "missingValues": [
+          ""
+        ]
+      }
+    }
+  ]
+}
diff --git a/data/datapackages_linked_errors/people/people.csv b/data/datapackages_linked_errors/people/people.csv
new file mode 100644
index 0000000..2f4b85f
--- /dev/null
+++ b/data/datapackages_linked_errors/people/people.csv
@@ -0,0 +1,3 @@
+label,population
+1,8
+2,2
diff --git a/goodtables/cells.py b/goodtables/cells.py
index 6227d71..74763fb 100644
--- a/goodtables/cells.py
+++ b/goodtables/cells.py
@@ -35,8 +35,6 @@ def create_cells(headers, schema_fields, values=None, row_number=None):
             field = None
         if value == fillvalue:
             value = None
-        elif value is None:
-            value = ''
 
         cell = create_cell(header, value, field, column_number, row_number)
         cells.append(cell)
diff --git a/goodtables/checks/required_constraint.py b/goodtables/checks/required_constraint.py
index 1ef90db..a448a2e 100644
--- a/goodtables/checks/required_constraint.py
+++ b/goodtables/checks/required_constraint.py
@@ -29,7 +29,7 @@ def required_constraint(cells):
         if field.required or field.descriptor.get('primaryKey'):
             # TODO: remove this hack after:
             # https://github.com/frictionlessdata/tableschema-py/issues/244
-            if value in field._Field__missing_values:
+            if value is None or value in field._Field__missing_values:
                 valid = False
 
         # Skip if valid
diff --git a/goodtables/checks/type_or_format_error.py b/goodtables/checks/type_or_format_error.py
index b1194d1..f1d1d4a 100644
--- a/goodtables/checks/type_or_format_error.py
+++ b/goodtables/checks/type_or_format_error.py
@@ -28,7 +28,7 @@ def type_or_format_error(cells):
         # Cast value
         try:
             valid = True
-            field.cast_value(value, constraints=False)
+            cell['value'] = field.cast_value(value, constraints=False)
         except tableschema.exceptions.TableSchemaException:
             valid = False
 
diff --git a/goodtables/config.py b/goodtables/config.py
index fd3a57c..8c2ec48 100644
--- a/goodtables/config.py
+++ b/goodtables/config.py
@@ -44,6 +44,7 @@ CHECKS = [
     # Contrib
     'goodtables.contrib.checks.blacklisted_value',
     'goodtables.contrib.checks.deviated_value',
+    'goodtables.contrib.checks.foreign_key',
     'goodtables.contrib.checks.sequential_value',
     'goodtables.contrib.checks.truncated_value',
     'goodtables.contrib.checks.custom_constraint',
diff --git a/goodtables/contrib/checks/foreign_key.py b/goodtables/contrib/checks/foreign_key.py
new file mode 100644
index 0000000..745a06c
--- /dev/null
+++ b/goodtables/contrib/checks/foreign_key.py
@@ -0,0 +1,168 @@
+# -*- coding: utf-8 -*-
+from __future__ import division
+from __future__ import print_function
+from __future__ import absolute_import
+from __future__ import unicode_literals
+
+import json
+from copy import deepcopy
+from collections import defaultdict
+from datapackage import Package
+from ...registry import check
+from ...error import Error
+
+
+# Module API
+
+@check('foreign-key', type='custom', context='body')
+class ForeignKey(object):
+
+    # Public
+
+    def __init__(self, **options):
+        self.__package = None
+        self.__schema = None
+        self.__relations = None
+        self.__code = 'foreign-key'
+
+    def prepare(self, stream, schema, extra):
+
+        # Prepare package
+        if 'datapackage' not in extra or 'resource-name' not in extra:
+            return False
+        descriptor = extra['datapackage']
+        if descriptor.strip().startswith('{'):
+            descriptor = json.loads(descriptor)
+        self.__package = Package(descriptor)
+
+        # Prepare schema
+        if not schema:
+            return False
+        if not schema.foreign_keys:
+            return False
+        self.__schema = schema
+
+        # Prepare foreign keys values
+        relations = _get_relations(
+            self.__package, self.__schema,
+            current_resource_name=extra['resource-name'])
+        self.__foreign_keys_values = _get_foreign_keys_values(
+            self.__schema, relations)
+
+        return True
+
+    def check_row(self, cells):
+        row_number = cells[0]['row-number']
+
+        # Prepare keyed_row
+        keyed_row = {}
+        for cell in cells:
+            if cell.get('field'):
+                keyed_row[cell.get('field').name] = cell.get('value')
+
+        # Resolve relations
+        errors = []
+        for foreign_key in self.__schema.foreign_keys:
+            success = _resolve_relations(
+                deepcopy(keyed_row), self.__foreign_keys_values, foreign_key)
+            if success is None:
+                message = 'Foreign key "{fields}" violation in row {row_number}'
+                message_substitutions = {'fields': foreign_key['fields']}
+                errors.append(Error(
+                    self.__code,
+                    row_number=row_number,
+                    message=message,
+                    message_substitutions=message_substitutions,
+                ))
+
+        return errors
+
+
+# Internal
+
+def _get_relations(package, schema, current_resource_name=None):
+    # It's based on the following code:
+    # https://github.com/frictionlessdata/datapackage-py/blob/master/datapackage/resource.py#L393
+
+    # Prepare relations
+    relations = {}
+    for fk in schema.foreign_keys:
+        resource_name = fk['reference'].get('resource')
+        package_name = fk['reference'].get('package')
+        resource = None
+
+        # Self-referenced resource
+        if not resource_name:
+            for item in package.resources:
+                if item.name == current_resource_name:
+                    resource = item
+
+        # Internal resource
+        elif not package_name:
+            resource = package.get_resource(resource_name)
+
+        # External resource (experimental)
+        # For now, we rely on uniqueness of resource names and support relative paths
+        else:
+            descriptor = package_name
+            if not descriptor.startswith('http'):
+                descriptor = '/'.join([package.base_path, package_name])
+            package = Package(descriptor)
+            resource = package.get_resource(resource_name)
+
+        # Add to relations (can be None)
+        relations[resource_name] = resource
+        if resource and resource.tabular:
+            relations[resource_name] = resource.read(keyed=True)
+
+    return relations
+
+
+def _get_foreign_keys_values(schema, relations):
+    # It's based on the following code:
+    # https://github.com/frictionlessdata/tableschema-py/blob/master/tableschema/table.py#L218
+
+    # we dont need to load the complete reference table to test relations
+    # we can lower payload AND optimize testing foreign keys
+    # by preparing the right index based on the foreign key definition
+    # foreign_keys are sets of tuples of all possible values in the foreign table
+    # foreign keys =
+    # [reference] [foreign_keys tuple] = { (foreign_keys_values, ) : one_keyedrow, ... }
+    foreign_keys = defaultdict(dict)
+    if schema:
+        for fk in schema.foreign_keys:
+            # load relation data
+            relation = fk['reference']['resource']
+
+            # create a set of foreign keys
+            # to optimize we prepare index of existing values
+            # this index should use reference + foreign_keys as key
+            # cause many foreign keys may use the same reference
+            foreign_keys[relation][tuple(fk['reference']['fields'])] = {}
+            for row in (relations[relation] or []):
+                key = tuple([row[foreign_field] for foreign_field in fk['reference']['fields']])
+                # here we should chose to pick the first or nth row which match
+                # previous implementation picked the first, so be it
+                if key not in foreign_keys[relation][tuple(fk['reference']['fields'])]:
+                    foreign_keys[relation][tuple(fk['reference']['fields'])][key] = row
+    return foreign_keys
+
+
+def _resolve_relations(keyed_row, foreign_keys_values, foreign_key):
+    # It's based on the following code:
+    # https://github.com/frictionlessdata/tableschema-py/blob/master/tableschema/table.py#L228
+
+    # local values of the FK
+    local_values = tuple(keyed_row[f] for f in foreign_key['fields'])
+    if len([l for l in local_values if l]) > 0:
+        # test existence into the foreign
+        relation = foreign_key['reference']['resource']
+        keys = tuple(foreign_key['reference']['fields'])
+        foreign_values = foreign_keys_values[relation][keys]
+        if local_values in foreign_values:
+            return foreign_values[local_values]
+        else:
+            return None
+    else:
+        # empty values for all keys, return original values
+        return keyed_row
diff --git a/goodtables/inspector.py b/goodtables/inspector.py
index 3591b73..e17e464 100644
--- a/goodtables/inspector.py
+++ b/goodtables/inspector.py
@@ -191,6 +191,18 @@ class Inspector(object):
                     error = _compose_error_from_schema_error(error)
                     errors.append(error)
 
+        # Prepare checks
+        # This is an experimental hook
+        for check in checks:
+            prepare_func = getattr(check['func'], 'prepare', None)
+            if not prepare_func:
+                continue
+            success = prepare_func(stream, schema, extra)
+            if not success:
+                checks.remove(check)
+        if not checks:
+            fatal_error = True
+
         # Head checks
         if not fatal_error:
             # Prepare cells
diff --git a/goodtables/presets/datapackage.py b/goodtables/presets/datapackage.py
index dc18fbe..cfc994b 100644
--- a/goodtables/presets/datapackage.py
+++ b/goodtables/presets/datapackage.py
@@ -4,6 +4,7 @@ from __future__ import print_function
 from __future__ import absolute_import
 from __future__ import unicode_literals
 
+import json
 from tabulator import Stream
 from functools import partial
 from datapackage import Package, exceptions
@@ -34,7 +35,8 @@ def datapackage(source, **options):
                     'stream': Stream(partial(_iter_resource_rows, resource), headers=1),
                     'schema': resource.schema,
                     'extra': {
-                        'datapackage': str(source),
+                        'datapackage': json.dumps(source) if isinstance(source, dict) else source,
+                        'resource-name': resource.name,
                     },
                 })
 
diff --git a/pylama.ini b/pylama.ini
index aefae4b..ce8b52a 100644
--- a/pylama.ini
+++ b/pylama.ini
@@ -3,7 +3,7 @@ linters = pyflakes,mccabe,pep8
 ignore = E128,E301
 
 [pylama:pep8]
-max_line_length = 90
+max_line_length = 100
 
 [pylama:mccabe]
 complexity = 48
diff --git a/setup.py b/setup.py
index 01248de..2604157 100644
--- a/setup.py
+++ b/setup.py
@@ -28,7 +28,7 @@ INSTALL_REQUIRES = [
     'simpleeval>=0.9',
     'statistics>=1.0',
     'tabulator>=1.20',
-    'tableschema>=1.0.3',
+    'tableschema>=1.7',
     'datapackage>=1.2',
 ]
 INSTALL_FORMAT_ODS_REQUIRES = [


Test patch:
diff --git a/tests/scenarios/jungle.yml b/tests/scenarios/jungle.yml
index 3910a53..059d230 100644
--- a/tests/scenarios/jungle.yml
+++ b/tests/scenarios/jungle.yml
@@ -22,4 +22,5 @@ gla-250-report-2014-15-P08:
 multilingual:
   source: data/jungle/multilingual.xlsx
   error_limit: 1
-  report: []
+  report:
+    - [1, 2, 2, 'missing-value']
diff --git a/tests/test_cells.py b/tests/test_cells.py
index d759e46..0347ffe 100644
--- a/tests/test_cells.py
+++ b/tests/test_cells.py
@@ -83,7 +83,7 @@ class TestCells(object):
 
         cells = goodtables.cells.create_cells(headers, schema_fields, values)
         expected_cells = [
-            goodtables.cells.create_cell('name', '', 'name_schema', 1),
+            goodtables.cells.create_cell('name', None, 'name_schema', 1),
             goodtables.cells.create_cell('value', 51, 'value_schema', 2),
         ]
 
diff --git a/tests/test_validate.py b/tests/test_validate.py
index 6892448..b2dd8d7 100644
--- a/tests/test_validate.py
+++ b/tests/test_validate.py
@@ -7,6 +7,8 @@ from __future__ import unicode_literals
 import sys
 import json
 import pytest
+from pprint import pprint
+from copy import deepcopy
 from importlib import import_module
 from goodtables import validate, init_datapackage
 
@@ -146,6 +148,142 @@ def test_source_pathlib_path_datapackage():
     assert report['valid']
 
 
+# Foreign Keys
+
+FK_DESCRIPTOR = {
+  'resources': [
+    {
+      'name': 'cities',
+      'data': [
+        ['id', 'name', 'next_id'],
+        [1, 'london', 2],
+        [2, 'paris', 3],
+        [3, 'rome', 4],
+        [4, 'rio', None],
+      ],
+      'schema': {
+        'fields': [
+          {'name': 'id', 'type': 'integer'},
+          {'name': 'name', 'type': 'string'},
+          {'name': 'next_id', 'type': 'integer'},
+        ],
+        'foreignKeys': [
+          {
+            'fields': 'next_id',
+            'reference': {'resource': '', 'fields': 'id'},
+          },
+          {
+            'fields': 'id',
+            'reference': {'resource': 'people', 'fields': 'label'},
+          },
+        ],
+      },
+    }, {
+      'name': 'people',
+      'data': [
+        ['label', 'population'],
+        [1, 8],
+        [2, 2],
+        [3, 3],
+        [4, 6],
+      ],
+    },
+  ],
+}
+
+
+def test_foreign_key(log):
+    descriptor = deepcopy(FK_DESCRIPTOR)
+    report = validate(descriptor, checks=['foreign-key'])
+    assert log(report) == []
+
+
+def test_foreign_key_not_defined_foreign_keys(log):
+    descriptor = deepcopy(FK_DESCRIPTOR)
+    del descriptor['resources'][0]['schema']['foreignKeys']
+    report = validate(descriptor, checks=['foreign-key'])
+    assert log(report) == []
+
+
+def test_foreign_key_source_is_not_datapackage(log):
+    report = validate('data/valid.csv', checks=['foreign-key'])
+    assert log(report) == []
+
+
+def test_foreign_key_self_referenced_resource_violation(log):
+    descriptor = deepcopy(FK_DESCRIPTOR)
+    del descriptor['resources'][0]['data'][4]
+    report = validate(descriptor, checks=['foreign-key'])
+    assert log(report) == [
+        (1, 4, None, 'foreign-key'),
+    ]
+
+
+def test_foreign_key_internal_resource_violation(log):
+    descriptor = deepcopy(FK_DESCRIPTOR)
+    del descriptor['resources'][1]['data'][4]
+    report = validate(descriptor, checks=['foreign-key'])
+    assert log(report) == [
+        (1, 5, None, 'foreign-key'),
+    ]
+
+
+def test_foreign_key_internal_resource_violation_non_existent(log):
+    descriptor = deepcopy(FK_DESCRIPTOR)
+    del descriptor['resources'][1]
+    report = validate(descriptor, checks=['foreign-key'])
+    assert log(report) == [
+        (1, 2, None, 'foreign-key'),
+        (1, 3, None, 'foreign-key'),
+        (1, 4, None, 'foreign-key'),
+        (1, 5, None, 'foreign-key'),
+    ]
+
+
+def test_foreign_key_external_resource(log):
+    descriptor = 'data/datapackages_linked/cities/datapackage.json'
+    report = validate(descriptor, checks=['structure', 'schema', 'foreign-key'])
+    assert log(report) == []
+
+
+def test_foreign_key_external_resource_errors(log):
+    descriptor = 'data/datapackages_linked_errors/cities/datapackage.json'
+    report = validate(descriptor, checks=['structure', 'schema', 'foreign-key'])
+    assert log(report) == [
+        (1, 4, None, 'foreign-key'),  # self-referenced
+        (1, 4, None, 'foreign-key'),  # external
+    ]
+
+
+def test_foreign_key_external_resource_remote_datapackage(log):
+    descriptor = {
+        'resources': [{
+          'name': 'countries',
+          'data': [
+            ['Country Code', 'Country Name'],
+            ['PRT', 'Portugal'],
+            ['DRL', 'Dreamland'],
+          ],
+          'schema': {
+            'fields': [
+              {'name': 'Country Code', 'type': 'string'},
+              {'name': 'Country Name', 'type': 'string'},
+            ],
+            'foreignKeys': [
+              {
+                'fields': 'Country Code',
+                'reference': {'package': 'https://raw.githubusercontent.com/datasets/gdp/master/datapackage.json', 'resource': 'gdp', 'fields': 'Country Code'},
+              },
+            ],
+          },
+          }]
+    }
+    report = validate(descriptor, checks=['structure', 'schema', 'foreign-key'])
+    assert log(report) == [
+        (1, 3, None, 'foreign-key'),
+    ]
+
+
 # Issues
 
 def test_composite_primary_key_unique_issue_215(log):