diff --git a/.DS_Store b/.DS_Store
new file mode 100644
index 0000000000..9821704b13
Binary files /dev/null and b/.DS_Store differ
diff --git a/bin/build_directus.sh b/bin/build_directus.sh
new file mode 100644
index 0000000000..f99a927bcd
--- /dev/null
+++ b/bin/build_directus.sh
@@ -0,0 +1,10 @@
+rm -rf .directus-build
+git clone https://github.com/directus/directus.git .directus-build
+
+pushd .directus-build
+ npm install
+ gulp build
+ gulp deploy
+popd
+
+rm -rf .directus-build
diff --git a/bin/build_subtree.sh b/bin/build_subtree.sh
new file mode 100644
index 0000000000..04dd71f2b3
--- /dev/null
+++ b/bin/build_subtree.sh
@@ -0,0 +1,27 @@
+git subsplit init git@github.com:directus/directus.git
+
+# Collection
+git subsplit publish --heads="version/6.4" --no-tags --debug api/core/Directus/Collection:git@github.com:directus/directus-collection.git
+
+# Config
+git subsplit publish --heads="version/6.4" --no-tags --debug api/core/Directus/Config:git@github.com:directus/directus-config.git
+
+# Permissions
+git subsplit publish --heads="version/6.4" --no-tags --debug api/core/Directus/Permissions:git@github.com:directus/directus-permissions.git
+
+# Database
+git subsplit publish --heads="version/6.4" --no-tags --debug api/core/Directus/Database:git@github.com:directus/directus-database.git
+
+# Filesystem
+git subsplit publish --heads="version/6.4" --no-tags --debug api/core/Directus/Filesystem:git@github.com:directus/directus-filesystem.git
+
+# Hash
+git subsplit publish --heads="version/6.4" --no-tags --debug api/core/Directus/Hash:git@github.com:directus/directus-hash.git
+
+# Hooks
+git subsplit publish --heads="version/6.4" --no-tags --debug api/core/Directus/Hook:git@github.com:directus/directus-hook.git
+
+# Utils
+git subsplit publish --heads="version/6.4" --no-tags --debug api/core/Directus/Util:git@github.com:directus/directus-php-utils.git
+
+rm -rf .subsplit
diff --git a/bin/directus b/bin/directus
new file mode 100755
index 0000000000..0106175b11
--- /dev/null
+++ b/bin/directus
@@ -0,0 +1,11 @@
+#!/usr/bin/env php
+run();
+
+?>
diff --git a/bin/runtests.sh b/bin/runtests.sh
new file mode 100755
index 0000000000..75a898fed5
--- /dev/null
+++ b/bin/runtests.sh
@@ -0,0 +1,25 @@
+#!/bin/sh
+
+#
+# Command line runner for unit tests for composer projects
+# (c) Del 2015 http://www.babel.com.au/
+# No Rights Reserved
+#
+
+#
+# Clean up after any previous test runs
+#
+mkdir -p documents
+rm -rf documents/coverage-html-new
+rm -f documents/coverage.xml
+
+#
+# Run phpunit
+#
+vendor/bin/phpunit --coverage-html documents/coverage-html-new --coverage-clover documents/coverage.xml
+
+if [ -d documents/coverage-html-new ]; then
+ rm -rf documents/coverage-html
+ mv documents/coverage-html-new documents/coverage-html
+fi
+
diff --git a/config/api.php b/config/api.php
new file mode 100644
index 0000000000..02ac011d45
--- /dev/null
+++ b/config/api.php
@@ -0,0 +1,145 @@
+ [
+ 'path' => '/',
+ 'env' => 'development',
+ 'debug' => true,
+ 'default_language' => 'en',
+ 'timezone' => 'America/New_York',
+ ],
+
+ 'settings' => [
+ 'debug' => true,
+ 'displayErrorDetails' => true,
+ 'logger' => [
+ 'name' => 'directus-api',
+ 'level' => Monolog\Logger::DEBUG,
+ 'path' => __DIR__ . '/logs/app.log',
+ ],
+ ],
+
+ 'database' => [
+ 'type' => 'mysql',
+ 'host' => 'localhost',
+ 'port' => 3306,
+ 'name' => 'directus',
+ 'username' => 'root',
+ 'password' => 'root',
+ 'prefix' => '', // not used
+ 'engine' => 'InnoDB',
+ 'charset' => 'utf8mb4'
+ ],
+
+ 'cache' => [
+ 'enabled' => false,
+ 'response_ttl' => 3600, // seconds
+ 'adapter' => 'filesystem',
+ 'path' => '/storage/cache',
+ // 'pool' => [
+ // 'adapter' => 'apc'
+ // ],
+ // 'pool' => [
+ // 'adapter' => 'apcu'
+ // ],
+ // 'pool' => [
+ // 'adapter' => 'filesystem',
+ // 'path' => '../cache/', // relative to the api directory
+ // ],
+ // 'pool' => [
+ // 'adapter' => 'memcached',
+ // 'host' => 'localhost',
+ // 'port' => 11211
+ // ],
+ // 'pool' => [
+ // 'adapter' => 'redis',
+ // 'host' => 'localhost',
+ // 'port' => 6379
+ // ],
+ ],
+
+ 'filesystem' => [
+ 'adapter' => 'local',
+ // By default media directory are located at the same level of directus root
+ // To make them a level up outsite the root directory
+ // use this instead
+ // Ex: 'root' => realpath(ROOT_PATH.'/../storage/uploads'),
+ // Note: ROOT_PATH constant doesn't end with trailing slash
+ 'root' => 'storage/uploads',
+ // This is the url where all the media will be pointing to
+ // here all assets will be (yourdomain)/storage/uploads
+ // same with thumbnails (yourdomain)/storage/uploads/thumbs
+ 'root_url' => '/storage/uploads',
+ 'root_thumb_url' => '/storage/uploads/thumbs',
+ // 'key' => 's3-key',
+ // 'secret' => 's3-key',
+ // 'region' => 's3-region',
+ // 'version' => 's3-version',
+ // 'bucket' => 's3-bucket'
+ ],
+
+ // HTTP Settings
+ 'http' => [
+ 'emulate_enabled' => false,
+ // can be null, or an array list of method to be emulated
+ // Ex: ['PATH', 'DELETE', 'PUT']
+ // 'emulate_methods' => null,
+ 'force_https' => false
+ ],
+
+ 'mail' => [
+ 'transport' => 'mail',
+ 'from' => 'admin@admin.com'
+ ],
+
+ 'cors' => [
+ 'enabled' => true,
+ 'origin' => ['*'],
+ 'headers' => [
+ ['Access-Control-Allow-Headers', 'Authorization, Content-Type, Access-Control-Allow-Origin'],
+ ['Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE'],
+ ['Access-Control-Allow-Credentials', 'false']
+ ]
+ ],
+
+ 'hooks' => [],
+
+ 'filters' => [],
+
+ 'feedback' => [
+ 'token' => 'a-kind-of-unique-token',
+ 'login' => true
+ ],
+
+ // These tables will not be loaded in the directus schema
+ 'tableBlacklist' => [],
+
+ 'auth' => [
+ 'secret_key' => ' "+e+".04045?Math.pow((t+.055)/1.055,2.4):t/12.92)+.3576*(e=e>.04045?Math.pow((e+.055)/1.055,2.4):e/12.92)+.1805*(n=n>.04045?Math.pow((n+.055)/1.055,2.4):n/12.92)),100*(.2126*t+.7152*e+.0722*n),100*(.0193*t+.1192*e+.9505*n)]},i.rgb.lab=function(r){var t=i.rgb.xyz(r),e=t[0],n=t[1],a=t[2];return n/=100,a/=108.883,e=(e/=95.047)>.008856?Math.pow(e,1/3):7.787*e+16/116,[116*(n=n>.008856?Math.pow(n,1/3):7.787*n+16/116)-16,500*(e-n),200*(n-(a=a>.008856?Math.pow(a,1/3):7.787*a+16/116))]},i.hsl.rgb=function(r){var t,e,n,a,o,i=r[0]/360,s=r[1]/100,l=r[2]/100;if(0===s)return[o=255*l,o,o];t=2*l-(e=l<.5?l*(1+s):l+s-l*s),a=[0,0,0];for(var u=0;u<3;u++)(n=i+1/3*-(u-1))<0&&n++,n>1&&n--,o=6*n<1?t+6*(e-t)*n:2*n<1?e:3*n<2?t+(e-t)*(2/3-n)*6:t,a[u]=255*o;return a},i.hsl.hsv=function(r){var t=r[0],e=r[1]/100,n=r[2]/100,a=e,o=Math.max(n,.01);return e*=(n*=2)<=1?n:2-n,a*=o<=1?o:2-o,[t,100*(0===n?2*a/(o+a):2*e/(n+e)),(n+e)/2*100]},i.hsv.rgb=function(r){var t=r[0]/60,e=r[1]/100,n=r[2]/100,a=Math.floor(t)%6,o=t-Math.floor(t),i=255*n*(1-e),s=255*n*(1-e*o),l=255*n*(1-e*(1-o));switch(n*=255,a){case 0:return[n,l,i];case 1:return[s,n,i];case 2:return[i,n,l];case 3:return[i,s,n];case 4:return[l,i,n];case 5:return[n,i,s]}},i.hsv.hsl=function(r){var t,e,n,a=r[0],o=r[1]/100,i=r[2]/100,s=Math.max(i,.01);return n=(2-o)*i,e=o*s,[a,100*(e=(e/=(t=(2-o)*s)<=1?t:2-t)||0),100*(n/=2)]},i.hwb.rgb=function(r){var t,e,n,a,o,i,s,l=r[0]/360,u=r[1]/100,h=r[2]/100,c=u+h;switch(c>1&&(u/=c,h/=c),e=1-h,n=6*l-(t=Math.floor(6*l)),0!=(1&t)&&(n=1-n),a=u+n*(e-u),t){default:case 6:case 0:o=e,i=a,s=u;break;case 1:o=a,i=e,s=u;break;case 2:o=u,i=e,s=a;break;case 3:o=u,i=a,s=e;break;case 4:o=a,i=u,s=e;break;case 5:o=e,i=u,s=a}return[255*o,255*i,255*s]},i.cmyk.rgb=function(r){var t=r[0]/100,e=r[1]/100,n=r[2]/100,a=r[3]/100;return[255*(1-Math.min(1,t*(1-a)+a)),255*(1-Math.min(1,e*(1-a)+a)),255*(1-Math.min(1,n*(1-a)+a))]},i.xyz.rgb=function(r){var t,e,n,a=r[0]/100,o=r[1]/100,i=r[2]/100;return e=-.9689*a+1.8758*o+.0415*i,n=.0557*a+-.204*o+1.057*i,t=(t=3.2406*a+-1.5372*o+-.4986*i)>.0031308?1.055*Math.pow(t,1/2.4)-.055:12.92*t,e=e>.0031308?1.055*Math.pow(e,1/2.4)-.055:12.92*e,n=n>.0031308?1.055*Math.pow(n,1/2.4)-.055:12.92*n,[255*(t=Math.min(Math.max(0,t),1)),255*(e=Math.min(Math.max(0,e),1)),255*(n=Math.min(Math.max(0,n),1))]},i.xyz.lab=function(r){var t=r[0],e=r[1],n=r[2];return e/=100,n/=108.883,t=(t/=95.047)>.008856?Math.pow(t,1/3):7.787*t+16/116,[116*(e=e>.008856?Math.pow(e,1/3):7.787*e+16/116)-16,500*(t-e),200*(e-(n=n>.008856?Math.pow(n,1/3):7.787*n+16/116))]},i.lab.xyz=function(r){var t,e,n,a=r[0],o=r[1],i=r[2];t=o/500+(e=(a+16)/116),n=e-i/200;var s=Math.pow(e,3),l=Math.pow(t,3),u=Math.pow(n,3);return e=s>.008856?s:(e-16/116)/7.787,t=l>.008856?l:(t-16/116)/7.787,n=u>.008856?u:(n-16/116)/7.787,[t*=95.047,e*=100,n*=108.883]},i.lab.lch=function(r){var t,e=r[0],n=r[1],a=r[2];return(t=360*Math.atan2(a,n)/2/Math.PI)<0&&(t+=360),[e,Math.sqrt(n*n+a*a),t]},i.lch.lab=function(r){var t,e=r[0],n=r[1];return t=r[2]/360*2*Math.PI,[e,n*Math.cos(t),n*Math.sin(t)]},i.rgb.ansi16=function(r){var t=r[0],e=r[1],n=r[2],a=1 in arguments?arguments[1]:i.rgb.hsv(r)[2];if(0===(a=Math.round(a/50)))return 30;var o=30+(Math.round(n/255)<<2|Math.round(e/255)<<1|Math.round(t/255));return 2===a&&(o+=60),o},i.hsv.ansi16=function(r){return i.rgb.ansi16(i.hsv.rgb(r),r[2])},i.rgb.ansi256=function(r){var t=r[0],e=r[1],n=r[2];return t===e&&e===n?t<8?16:t>248?231:Math.round((t-8)/247*24)+232:16+36*Math.round(t/255*5)+6*Math.round(e/255*5)+Math.round(n/255*5)},i.ansi16.rgb=function(r){var t=r%10;if(0===t||7===t)return r>50&&(t+=3.5),[t=t/10.5*255,t,t];var e=.5*(1+~~(r>50));return[(1&t)*e*255,(t>>1&1)*e*255,(t>>2&1)*e*255]},i.ansi256.rgb=function(r){if(r>=232){var t=10*(r-232)+8;return[t,t,t]}var e;return r-=16,[Math.floor(r/36)/5*255,Math.floor((e=r%36)/6)/5*255,e%6/5*255]},i.rgb.hex=function(r){var t=(((255&Math.round(r[0]))<<16)+((255&Math.round(r[1]))<<8)+(255&Math.round(r[2]))).toString(16).toUpperCase();return"000000".substring(t.length)+t},i.hex.rgb=function(r){var t=r.toString(16).match(/[a-f0-9]{6}|[a-f0-9]{3}/i);if(!t)return[0,0,0];var e=t[0];3===t[0].length&&(e=e.split("").map(function(r){return r+r}).join(""));var n=parseInt(e,16);return[n>>16&255,n>>8&255,255&n]},i.rgb.hcg=function(r){var t,e=r[0]/255,n=r[1]/255,a=r[2]/255,o=Math.max(Math.max(e,n),a),i=Math.min(Math.min(e,n),a),s=o-i;return t=s<=0?0:o===e?(n-a)/s%6:o===n?2+(a-e)/s:4+(e-n)/s+4,t/=6,[360*(t%=1),100*s,100*(s<1?i/(1-s):0)]},i.hsl.hcg=function(r){var t,e=r[1]/100,n=r[2]/100,a=0;return(t=n<.5?2*e*n:2*e*(1-n))<1&&(a=(n-.5*t)/(1-t)),[r[0],100*t,100*a]},i.hsv.hcg=function(r){var t=r[1]/100,e=r[2]/100,n=t*e,a=0;return n<1&&(a=(e-n)/(1-n)),[r[0],100*n,100*a]},i.hcg.rgb=function(r){var t=r[0]/360,e=r[1]/100,n=r[2]/100;if(0===e)return[255*n,255*n,255*n];var a,o=[0,0,0],i=t%1*6,s=i%1,l=1-s;switch(Math.floor(i)){case 0:o[0]=1,o[1]=s,o[2]=0;break;case 1:o[0]=l,o[1]=1,o[2]=0;break;case 2:o[0]=0,o[1]=1,o[2]=s;break;case 3:o[0]=0,o[1]=l,o[2]=1;break;case 4:o[0]=s,o[1]=0,o[2]=1;break;default:o[0]=1,o[1]=0,o[2]=l}return a=(1-e)*n,[255*(e*o[0]+a),255*(e*o[1]+a),255*(e*o[2]+a)]},i.hcg.hsv=function(r){var t=r[1]/100,e=t+r[2]/100*(1-t),n=0;return e>0&&(n=t/e),[r[0],100*n,100*e]},i.hcg.hsl=function(r){var t=r[1]/100,e=r[2]/100*(1-t)+.5*t,n=0;return e>0&&e<.5?n=t/(2*e):e>=.5&&e<1&&(n=t/(2*(1-e))),[r[0],100*n,100*e]},i.hcg.hwb=function(r){var t=r[1]/100,e=t+r[2]/100*(1-t);return[r[0],100*(e-t),100*(1-e)]},i.hwb.hcg=function(r){var t=r[1]/100,e=1-r[2]/100,n=e-t,a=0;return n<1&&(a=(e-n)/(1-n)),[r[0],100*n,100*a]},i.apple.rgb=function(r){return[r[0]/65535*255,r[1]/65535*255,r[2]/65535*255]},i.rgb.apple=function(r){return[r[0]/255*65535,r[1]/255*65535,r[2]/255*65535]},i.gray.rgb=function(r){return[r[0]/100*255,r[0]/100*255,r[0]/100*255]},i.gray.hsl=i.gray.hsv=function(r){return[0,0,r[0]]},i.gray.hwb=function(r){return[0,100,r[0]]},i.gray.cmyk=function(r){return[0,0,0,r[0]]},i.gray.lab=function(r){return[r[0],0,0]},i.gray.hex=function(r){var t=255&Math.round(r[0]/100*255),e=((t<<16)+(t<<8)+t).toString(16).toUpperCase();return"000000".substring(e.length)+e},i.rgb.gray=function(r){return[(r[0]+r[1]+r[2])/3/255*100]}},9:function(r,t,e){"use strict";r.exports={aliceblue:[240,248,255],antiquewhite:[250,235,215],aqua:[0,255,255],aquamarine:[127,255,212],azure:[240,255,255],beige:[245,245,220],bisque:[255,228,196],black:[0,0,0],blanchedalmond:[255,235,205],blue:[0,0,255],blueviolet:[138,43,226],brown:[165,42,42],burlywood:[222,184,135],cadetblue:[95,158,160],chartreuse:[127,255,0],chocolate:[210,105,30],coral:[255,127,80],cornflowerblue:[100,149,237],cornsilk:[255,248,220],crimson:[220,20,60],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgoldenrod:[184,134,11],darkgray:[169,169,169],darkgreen:[0,100,0],darkgrey:[169,169,169],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkseagreen:[143,188,143],darkslateblue:[72,61,139],darkslategray:[47,79,79],darkslategrey:[47,79,79],darkturquoise:[0,206,209],darkviolet:[148,0,211],deeppink:[255,20,147],deepskyblue:[0,191,255],dimgray:[105,105,105],dimgrey:[105,105,105],dodgerblue:[30,144,255],firebrick:[178,34,34],floralwhite:[255,250,240],forestgreen:[34,139,34],fuchsia:[255,0,255],gainsboro:[220,220,220],ghostwhite:[248,248,255],gold:[255,215,0],goldenrod:[218,165,32],gray:[128,128,128],green:[0,128,0],greenyellow:[173,255,47],grey:[128,128,128],honeydew:[240,255,240],hotpink:[255,105,180],indianred:[205,92,92],indigo:[75,0,130],ivory:[255,255,240],khaki:[240,230,140],lavender:[230,230,250],lavenderblush:[255,240,245],lawngreen:[124,252,0],lemonchiffon:[255,250,205],lightblue:[173,216,230],lightcoral:[240,128,128],lightcyan:[224,255,255],lightgoldenrodyellow:[250,250,210],lightgray:[211,211,211],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightsalmon:[255,160,122],lightseagreen:[32,178,170],lightskyblue:[135,206,250],lightslategray:[119,136,153],lightslategrey:[119,136,153],lightsteelblue:[176,196,222],lightyellow:[255,255,224],lime:[0,255,0],limegreen:[50,205,50],linen:[250,240,230],magenta:[255,0,255],maroon:[128,0,0],mediumaquamarine:[102,205,170],mediumblue:[0,0,205],mediumorchid:[186,85,211],mediumpurple:[147,112,219],mediumseagreen:[60,179,113],mediumslateblue:[123,104,238],mediumspringgreen:[0,250,154],mediumturquoise:[72,209,204],mediumvioletred:[199,21,133],midnightblue:[25,25,112],mintcream:[245,255,250],mistyrose:[255,228,225],moccasin:[255,228,181],navajowhite:[255,222,173],navy:[0,0,128],oldlace:[253,245,230],olive:[128,128,0],olivedrab:[107,142,35],orange:[255,165,0],orangered:[255,69,0],orchid:[218,112,214],palegoldenrod:[238,232,170],palegreen:[152,251,152],paleturquoise:[175,238,238],palevioletred:[219,112,147],papayawhip:[255,239,213],peachpuff:[255,218,185],peru:[205,133,63],pink:[255,192,203],plum:[221,160,221],powderblue:[176,224,230],purple:[128,0,128],rebeccapurple:[102,51,153],red:[255,0,0],rosybrown:[188,143,143],royalblue:[65,105,225],saddlebrown:[139,69,19],salmon:[250,128,114],sandybrown:[244,164,96],seagreen:[46,139,87],seashell:[255,245,238],sienna:[160,82,45],silver:[192,192,192],skyblue:[135,206,235],slateblue:[106,90,205],slategray:[112,128,144],slategrey:[112,128,144],snow:[255,250,250],springgreen:[0,255,127],steelblue:[70,130,180],tan:[210,180,140],teal:[0,128,128],thistle:[216,191,216],tomato:[255,99,71],turquoise:[64,224,208],violet:[238,130,238],wheat:[245,222,179],white:[255,255,255],whitesmoke:[245,245,245],yellow:[255,255,0],yellowgreen:[154,205,50]}}});
\ No newline at end of file
diff --git a/public/extensions/core/interfaces/color/meta.json b/public/extensions/core/interfaces/color/meta.json
new file mode 100644
index 0000000000..279eab0c5c
--- /dev/null
+++ b/public/extensions/core/interfaces/color/meta.json
@@ -0,0 +1,85 @@
+{
+ "name": "$t:color",
+ "version": "1.0.0",
+ "dataTypes": {
+ "VARCHAR": 30,
+ "CHAR": 30
+ },
+ "options": {
+ "input": {
+ "name": "$t:input",
+ "comment": "$t:input_comment",
+ "interface": "dropdown",
+ "default": "hex",
+ "options": {
+ "choices": {
+ "hex": "Hex",
+ "rgb": "RGB",
+ "hsl": "HSL",
+ "cmyk": "CMYK"
+ }
+ }
+ },
+ "output": {
+ "name": "$t:output",
+ "comment": "$t:output_comment",
+ "interface": "dropdown",
+ "default": "hex",
+ "options": {
+ "choices": {
+ "hex": "Hex",
+ "rgb": "RGB",
+ "hsl": "HSL",
+ "cmyk": "CMYK"
+ }
+ }
+ },
+ "formatValue": {
+ "name": "$t:format",
+ "comment": "$t:format_comment",
+ "interface": "toggle",
+ "default": true
+ },
+ "palette": {
+ "name": "$t:palette",
+ "comment": "$t:palette_comment",
+ "interface": "tags",
+ "type": "CSV",
+ "options": {
+ "wrapWithDelimiter": false
+ },
+ "default": [
+ "#f44336", "#9C27B0", "#039BE5", "#4CAF50", "#FFC107", "#212121"
+ ]
+ },
+ "paletteOnly": {
+ "name": "$t:palette_only",
+ "comment": "$t:palette_only_comment",
+ "interface": "toggle",
+ "default": false
+ },
+ "allowAlpha": {
+ "name": "$t:allow_alpha",
+ "comment": "$t:allow_alpha_comment",
+ "interface": "toggle",
+ "default": false
+ }
+ },
+ "translation": {
+ "en-US": {
+ "color": "Color",
+ "input": "Input",
+ "input_comment": "The unit in which the user will enter the data",
+ "output": "Output",
+ "output_comment": "The unit in which the data gets saved to the DB",
+ "format": "Format",
+ "format_comment": "Show value as color swatch",
+ "palette": "Palette",
+ "palette_comment": "Add color options as hex values",
+ "palette_only": "Palette Only",
+ "palette_only_comment": "Only allow the user to pick from the palette",
+ "allow_alpha": "Allow alpha",
+ "allow_alpha_comment": "Allow values with an alpha channel"
+ }
+ }
+}
diff --git a/public/extensions/core/interfaces/date/Interface.js b/public/extensions/core/interfaces/date/Interface.js
new file mode 100644
index 0000000000..22330528bc
--- /dev/null
+++ b/public/extensions/core/interfaces/date/Interface.js
@@ -0,0 +1 @@
+var __DirectusExtension__=function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{configurable:!1,enumerable:!0,get:r})},n.r=function(e){Object.defineProperty(e,"__esModule",{value:!0})},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=64)}({0:function(e,t,n){"use strict";function r(e,t,n,r,o,a,i,s){var u=typeof(e=e||{}).default;"object"!==u&&"function"!==u||(e=e.default);var c,f="function"==typeof e?e.options:e;if(t&&(f.render=t,f.staticRenderFns=n,f._compiled=!0),r&&(f.functional=!0),a&&(f._scopeId=a),i?(c=function(e){(e=e||this.$vnode&&this.$vnode.ssrContext||this.parent&&this.parent.$vnode&&this.parent.$vnode.ssrContext)||"undefined"==typeof __VUE_SSR_CONTEXT__||(e=__VUE_SSR_CONTEXT__),o&&o.call(this,e),e&&e._registeredComponents&&e._registeredComponents.add(i)},f._ssrRegister=c):o&&(c=s?function(){o.call(this,this.$root.$options.shadowRoot)}:o),c)if(f.functional){f._injectStyles=c;var l=f.render;f.render=function(e,t){return c.call(t),l(e,t)}}else{var d=f.beforeCreate;f.beforeCreate=d?[].concat(d,c):[c]}return{exports:e,options:f}}n.d(t,"a",function(){return r})},1:function(e,t){e.exports={props:{name:{type:String,required:!0},value:{type:null,default:null},type:{type:String,required:!0},length:{type:[String,Number],default:null},readonly:{type:Boolean,default:!1},required:{type:Boolean,default:!1},loading:{type:Boolean,default:!1},options:{type:Object,default:function(){return{}}},newItem:{type:Boolean,default:!1}}}},147:function(e,t,n){(e.exports=n(3)(!1)).push([e.i,".interface-date[data-v-377f2174]{max-width:var(--width-small)}",""])},148:function(e,t,n){var r=n(147);"string"==typeof r&&(r=[[e.i,r,""]]),r.locals&&(e.exports=r.locals),(0,n(4).default)("ba24de10",r,!0,{})},2:function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.default=function(e,t){for(var n=[],r={},o=0;o
\n":"'+(n?e:p(e,!0))+"\n
"},u.prototype.blockquote=function(e){return""+(n?e:p(e,!0))+"\n
\n"+e+"
\n"},u.prototype.html=function(e){return e},u.prototype.heading=function(e,t,n){return"
\n":"
\n"},u.prototype.list=function(e,t){var n=t?"ol":"ul";return"<"+n+">\n"+e+""+n+">\n"},u.prototype.listitem=function(e){return"\n\n"+e+"\n\n"+t+"\n
\n"},u.prototype.tablerow=function(e){return"\n"+e+" \n"},u.prototype.tablecell=function(e,t){var n=t.header?"th":"td";return(t.align?"<"+n+' style="text-align:'+t.align+'">':"<"+n+">")+e+""+n+">\n"},u.prototype.strong=function(e){return""+e+""},u.prototype.em=function(e){return""+e+""},u.prototype.codespan=function(e){return""+e+"
"},u.prototype.br=function(){return this.options.xhtml?"
":"
"},u.prototype.del=function(e){return""+e+""},u.prototype.link=function(e,t,n){if(this.options.sanitize){try{var r=decodeURIComponent((i=e,i.replace(/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi,function(e,t){return"colon"===(t=t.toLowerCase())?":":"#"===t.charAt(0)?"x"===t.charAt(1)?String.fromCharCode(parseInt(t.substring(2),16)):String.fromCharCode(+t.substring(1)):""}))).replace(/[^\w:]/g,"").toLowerCase()}catch(e){return n}if(0===r.indexOf("javascript:")||0===r.indexOf("vbscript:")||0===r.indexOf("data:"))return n}var i;this.options.baseUrl&&!g.test(e)&&(e=f(this.options.baseUrl,e));var s='"+n+""},u.prototype.image=function(e,t,n){this.options.baseUrl&&!g.test(e)&&(e=f(this.options.baseUrl,e));var r='":">")},u.prototype.text=function(e){return e},h.parse=function(e,t,n){return new h(t,n).parse(e)},h.prototype.parse=function(e){this.inline=new a(e.links,this.options,this.renderer),this.tokens=e.reverse();for(var t="";this.next();)t+=this.tok();return t},h.prototype.next=function(){return this.token=this.tokens.pop()},h.prototype.peek=function(){return this.tokens[this.tokens.length-1]||0},h.prototype.parseText=function(){for(var e=this.token.text;"text"===this.peek().type;)e+="\n"+this.next().text;return this.inline.output(e)},h.prototype.tok=function(){switch(this.token.type){case"space":return"";case"hr":return this.renderer.hr();case"heading":return this.renderer.heading(this.inline.output(this.token.text),this.token.depth,this.token.text);case"code":return this.renderer.code(this.token.text,this.token.lang,this.token.escaped);case"table":var e,t,n,r,i="",s="";for(n="",e=0;e
"+p(e.message+"",!0)+"";throw e}}b.exec=b,y.options=y.setOptions=function(e){return m(y.defaults,e),y},y.defaults={gfm:!0,tables:!0,breaks:!1,pedantic:!1,sanitize:!1,sanitizer:null,mangle:!0,smartLists:!1,silent:!1,highlight:null,langPrefix:"lang-",smartypants:!1,headerPrefix:"",renderer:new u,xhtml:!1,baseUrl:null},y.Parser=h,y.parser=h.parse,y.Renderer=u,y.Lexer=o,y.lexer=o.lex,y.InlineLexer=a,y.inlineLexer=a.output,y.parse=y,void 0!==e&&"object"===s(t)?e.exports=y:void 0===(i=function(){return y}.call(t,n,t,e))||(e.exports=i)}).call(function(){return this||("undefined"!=typeof window?window:r)}())}).call(this,n(14))},3:function(e,t){e.exports=function(e){var t=[];return t.toString=function(){return this.map(function(t){var n=function(e,t){var n,r=e[1]||"",i=e[3];if(!i)return r;if(t&&"function"==typeof btoa){var s=(n=i,"/*# sourceMappingURL=data:application/json;charset=utf-8;base64,"+btoa(unescape(encodeURIComponent(JSON.stringify(n))))+" */"),o=i.sources.map(function(e){return"/*# sourceURL="+i.sourceRoot+e+" */"});return[r].concat(o).concat([s]).join("\n")}return[r].join("\n")}(t,e);return t[2]?"@media "+t[2]+"{"+n+"}":n}).join("")},t.i=function(e,n){"string"==typeof e&&(e=[[null,e,""]]);for(var r={},i=0;i
"===n)return t.execCommand("outdent",!1,n);if((s.isFF||s.isEdge)&&"p"===n)return Array.prototype.slice.call(i.childNodes).some(function(e){return!s.isBlockContainer(e)})&&t.execCommand("formatBlock",!1,n),t.execCommand("outdent",!1,n)}return t.execCommand("formatBlock",!1,n)},setTargetBlank:function(e,t){var n,i=t||!1;if("a"===e.nodeName.toLowerCase())e.target="_blank",e.rel="noopener noreferrer";else for(e=e.getElementsByTagName("a"),n=0;nA",contentFA:''},superscript:{name:"superscript",action:"superscript",aria:"superscript",tagNames:["sup"],contentDefault:"x1",contentFA:''},subscript:{name:"subscript",action:"subscript",aria:"subscript",tagNames:["sub"],contentDefault:"x1",contentFA:''},image:{name:"image",action:"image",aria:"image",tagNames:["img"],contentDefault:"image",contentFA:''},html:{name:"html",action:"html",aria:"evaluate html",tagNames:["iframe","object"],contentDefault:"html",contentFA:''},orderedlist:{name:"orderedlist",action:"insertorderedlist",aria:"ordered list",tagNames:["ol"],useQueryState:!0,contentDefault:"1.",contentFA:''},unorderedlist:{name:"unorderedlist",action:"insertunorderedlist",aria:"unordered list",tagNames:["ul"],useQueryState:!0,contentDefault:"•",contentFA:''},indent:{name:"indent",action:"indent",aria:"indent",tagNames:[],contentDefault:"→",contentFA:''},outdent:{name:"outdent",action:"outdent",aria:"outdent",tagNames:[],contentDefault:"←",contentFA:''},justifyCenter:{name:"justifyCenter",action:"justifyCenter",aria:"center justify",tagNames:[],style:{prop:"text-align",value:"center"},contentDefault:"C",contentFA:''},justifyFull:{name:"justifyFull",action:"justifyFull",aria:"full justify",tagNames:[],style:{prop:"text-align",value:"justify"},contentDefault:"J",contentFA:''},justifyLeft:{name:"justifyLeft",action:"justifyLeft",aria:"left justify",tagNames:[],style:{prop:"text-align",value:"left"},contentDefault:"L",contentFA:''},justifyRight:{name:"justifyRight",action:"justifyRight",aria:"right justify",tagNames:[],style:{prop:"text-align",value:"right"},contentDefault:"R",contentFA:''},removeFormat:{name:"removeFormat",aria:"remove formatting",action:"removeFormat",contentDefault:"X",contentFA:''},quote:{name:"quote",action:"append-blockquote",aria:"blockquote",tagNames:["blockquote"],contentDefault:"“",contentFA:''},pre:{name:"pre",action:"append-pre",aria:"preformatted text",tagNames:["pre"],contentDefault:"0101",contentFA:''},h1:{name:"h1",action:"append-h1",aria:"header type one",tagNames:["h1"],contentDefault:"H1",contentFA:'1'},h2:{name:"h2",action:"append-h2",aria:"header type two",tagNames:["h2"],contentDefault:"H2",contentFA:'2'},h3:{name:"h3",action:"append-h3",aria:"header type three",tagNames:["h3"],contentDefault:"H3",contentFA:'3'},h4:{name:"h4",action:"append-h4",aria:"header type four",tagNames:["h4"],contentDefault:"H4",contentFA:'4'},h5:{name:"h5",action:"append-h5",aria:"header type five",tagNames:["h5"],contentDefault:"H5",contentFA:'5'},h6:{name:"h6",action:"append-h6",aria:"header type six",tagNames:["h6"],contentDefault:"H6",contentFA:'6'}},i=e.extensions.button.extend({init:function(){e.extensions.button.prototype.init.apply(this,arguments)},formSaveLabel:"✓",formCloseLabel:"×",activeClass:"medium-editor-toolbar-form-active",hasForm:!0,getForm:function(){},isDisplayed:function(){return!!this.hasForm&&this.getForm().classList.contains(this.activeClass)},showForm:function(){this.hasForm&&this.getForm().classList.add(this.activeClass)},hideForm:function(){this.hasForm&&this.getForm().classList.remove(this.activeClass)},showToolbarDefaultActions:function(){var e=this.base.getExtensionByName("toolbar");e&&e.showToolbarDefaultActions()},hideToolbarDefaultActions:function(){var e=this.base.getExtensionByName("toolbar");e&&e.hideToolbarDefaultActions()},setToolbarPosition:function(){var e=this.base.getExtensionByName("toolbar");e&&e.setToolbarPosition()}}),e.extensions.form=i,o=e.extensions.form.extend({customClassOption:null,customClassOptionText:"Button",linkValidation:!1,placeholderText:"Paste or type a link",targetCheckbox:!1,targetCheckboxText:"Open in new window",name:"anchor",action:"createLink",aria:"link",tagNames:["a"],contentDefault:"#",contentFA:'',init:function(){e.extensions.form.prototype.init.apply(this,arguments),this.subscribe("editableKeydown",this.handleKeydown.bind(this))},handleClick:function(t){t.preventDefault(),t.stopPropagation();var n=e.selection.getSelectionRange(this.document);return"a"===n.startContainer.nodeName.toLowerCase()||"a"===n.endContainer.nodeName.toLowerCase()||e.util.getClosestTag(e.selection.getSelectedParentElement(n),"a")?this.execAction("unlink"):(this.isDisplayed()||this.showForm(),!1)},handleKeydown:function(t){e.util.isKey(t,e.util.keyCode.K)&&e.util.isMetaCtrlKey(t)&&!t.shiftKey&&this.handleClick(t)},getForm:function(){return this.form||(this.form=this.createForm()),this.form},getTemplate:function(){var e=[''];return e.push('',"fontawesome"===this.getEditorOption("buttonLabels")?'':this.formSaveLabel,""),e.push('',"fontawesome"===this.getEditorOption("buttonLabels")?'':this.formCloseLabel,""),this.targetCheckbox&&e.push(' "),this.customClassOption&&e.push(' "),e.join("")},isDisplayed:function(){return e.extensions.form.prototype.isDisplayed.apply(this)},hideForm:function(){e.extensions.form.prototype.hideForm.apply(this),this.getInput().value=""},showForm:function(t){var n=this.getInput(),i=this.getAnchorTargetCheckbox(),o=this.getAnchorButtonCheckbox();if("string"==typeof(t=t||{value:""})&&(t={value:t}),this.base.saveSelection(),this.hideToolbarDefaultActions(),e.extensions.form.prototype.showForm.apply(this),this.setToolbarPosition(),n.value=t.value,n.focus(),i&&(i.checked="_blank"===t.target),o){var r=t.buttonClass?t.buttonClass.split(" "):[];o.checked=-1!==r.indexOf(this.customClassOption)}},destroy:function(){if(!this.form)return!1;this.form.parentNode&&this.form.parentNode.removeChild(this.form),delete this.form},getFormOpts:function(){var e=this.getAnchorTargetCheckbox(),t=this.getAnchorButtonCheckbox(),n={value:this.getInput().value.trim()};return this.linkValidation&&(n.value=this.checkLinkFormat(n.value)),n.target="_self",e&&e.checked&&(n.target="_blank"),t&&t.checked&&(n.buttonClass=this.customClassOption),n},doFormSave:function(){var e=this.getFormOpts();this.completeFormSave(e)},completeFormSave:function(e){this.base.restoreSelection(),this.execAction(this.action,e),this.base.checkSelection()},ensureEncodedUri:function(e){return e===decodeURI(e)?encodeURI(e):e},ensureEncodedUriComponent:function(e){return e===decodeURIComponent(e)?encodeURIComponent(e):e},ensureEncodedParam:function(e){var t=e.split("="),n=t[0],i=t[1];return n+(void 0===i?"":"="+this.ensureEncodedUriComponent(i))},ensureEncodedQuery:function(e){return e.split("&").map(this.ensureEncodedParam.bind(this)).join("&")},checkLinkFormat:function(e){var t=/^([a-z]+:)?\/\/|^(mailto|tel|maps):|^\#/i.test(e),n="",i=e.match(/^(.*?)(?:\?(.*?))?(?:#(.*))?$/),o=i[1],r=i[2],s=i[3];if(/^\+?\s?\(?(?:\d\s?\-?\)?){3,20}$/.test(e))return"tel:"+e;if(!t){var a=o.split("/")[0];(a.match(/.+(\.|:).+/)||"localhost"===a)&&(n="http://")}return n+this.ensureEncodedUri(o)+(void 0===r?"":"?"+this.ensureEncodedQuery(r))+(void 0===s?"":"#"+s)},doFormCancel:function(){this.base.restoreSelection(),this.base.checkSelection()},attachFormEvents:function(e){var t=e.querySelector(".medium-editor-toolbar-close"),n=e.querySelector(".medium-editor-toolbar-save"),i=e.querySelector(".medium-editor-toolbar-input");this.on(e,"click",this.handleFormClick.bind(this)),this.on(i,"keyup",this.handleTextboxKeyup.bind(this)),this.on(t,"click",this.handleCloseClick.bind(this)),this.on(n,"click",this.handleSaveClick.bind(this),!0)},createForm:function(){var e=this.document.createElement("div");return e.className="medium-editor-toolbar-form",e.id="medium-editor-toolbar-form-anchor-"+this.getEditorId(),e.innerHTML=this.getTemplate(),this.attachFormEvents(e),e},getInput:function(){return this.getForm().querySelector("input.medium-editor-toolbar-input")},getAnchorTargetCheckbox:function(){return this.getForm().querySelector(".medium-editor-toolbar-anchor-target")},getAnchorButtonCheckbox:function(){return this.getForm().querySelector(".medium-editor-toolbar-anchor-button")},handleTextboxKeyup:function(t){if(t.keyCode===e.util.keyCode.ENTER)return t.preventDefault(),void this.doFormSave();t.keyCode===e.util.keyCode.ESCAPE&&(t.preventDefault(),this.doFormCancel())},handleFormClick:function(e){e.stopPropagation()},handleSaveClick:function(e){e.preventDefault(),this.doFormSave()},handleCloseClick:function(e){e.preventDefault(),this.doFormCancel()}}),e.extensions.anchor=o,r=e.Extension.extend({name:"anchor-preview",hideDelay:500,previewValueSelector:"a",showWhenToolbarIsVisible:!1,showOnEmptyLinks:!0,init:function(){this.anchorPreview=this.createPreview(),this.getEditorOption("elementsContainer").appendChild(this.anchorPreview),this.attachToEditables()},getInteractionElements:function(){return this.getPreviewElement()},getPreviewElement:function(){return this.anchorPreview},createPreview:function(){var e=this.document.createElement("div");return e.id="medium-editor-anchor-preview-"+this.getEditorId(),e.className="medium-editor-anchor-preview",e.innerHTML=this.getTemplate(),this.on(e,"click",this.handleClick.bind(this)),e},getTemplate:function(){return' '},destroy:function(){this.anchorPreview&&(this.anchorPreview.parentNode&&this.anchorPreview.parentNode.removeChild(this.anchorPreview),delete this.anchorPreview)},hidePreview:function(){this.anchorPreview&&this.anchorPreview.classList.remove("medium-editor-anchor-preview-active"),this.activeAnchor=null},showPreview:function(e){return!(!this.anchorPreview.classList.contains("medium-editor-anchor-preview-active")&&!e.getAttribute("data-disable-preview"))||(this.previewValueSelector&&(this.anchorPreview.querySelector(this.previewValueSelector).textContent=e.attributes.href.value,this.anchorPreview.querySelector(this.previewValueSelector).href=e.attributes.href.value),this.anchorPreview.classList.add("medium-toolbar-arrow-over"),this.anchorPreview.classList.remove("medium-toolbar-arrow-under"),this.anchorPreview.classList.contains("medium-editor-anchor-preview-active")||this.anchorPreview.classList.add("medium-editor-anchor-preview-active"),this.activeAnchor=e,this.positionPreview(),this.attachPreviewHandlers(),this)},positionPreview:function(e){e=e||this.activeAnchor;var t,n,i,o,r,s=this.window.innerWidth,a=this.anchorPreview.offsetHeight,l=e.getBoundingClientRect(),c=this.diffLeft,d=this.diffTop,u=this.getEditorOption("elementsContainer"),h=["absolute","fixed"].indexOf(window.getComputedStyle(u).getPropertyValue("position"))>-1,m={};t=this.anchorPreview.offsetWidth/2;var f=this.base.getExtensionByName("toolbar");f&&(c=f.diffLeft,d=f.diffTop),n=c-t,h?(o=u.getBoundingClientRect(),["top","left"].forEach(function(e){m[e]=l[e]-o[e]}),m.width=l.width,m.height=l.height,l=m,s=o.width,r=u.scrollTop):r=this.window.pageYOffset,i=l.left+l.width/2,r+=a+l.top+l.height-d-this.anchorPreview.offsetHeight,this.anchorPreview.style.top=Math.round(r)+"px",this.anchorPreview.style.right="initial",i
]*>\s*|\s*<\/body[^>]*>[\s\S]*$/g),""],[new RegExp(/|/g),""],[new RegExp(/
$/i),""],[new RegExp(/<[^>]*docs-internal-guid[^>]*>/gi),""],[new RegExp(/<\/b>(
]*>)?$/gi),""],[new RegExp(/\s+<\/span>/g)," "],[new RegExp(/
/g),"
"],[new RegExp(/]*(font-style:italic;font-weight:(bold|700)|font-weight:(bold|700);font-style:italic)[^>]*>/gi),''],[new RegExp(/]*font-style:italic[^>]*>/gi),''],[new RegExp(/]*font-weight:(bold|700)[^>]*>/gi),''],[new RegExp(/<(\/?)(i|b|a)>/gi),"<$1$2>"],[new RegExp(/<a(?:(?!href).)+href=(?:"|”|“|"|“|”)(((?!"|”|“|"|“|”).)*)(?:"|”|“|"|“|”)(?:(?!>).)*>/gi),''],[new RegExp(/<\/p>\n+/gi),"
/gi),""],[new RegExp(/(((?!/gi),"$1"]],this.cleanReplacements||[]);for(t=0;t
").join("
")+"
",n=i.querySelectorAll("a,p,div,br"),t=0;t
+ * $args = array(
+ * 'db_name' => 'directus',
+ * 'db_user' => 'directus_user'
+ * );
+ *
+ *
+ * Extra - unamed - arguments are passed in a simple array of strings.
+ *
+ * @param string $command The command to execute.
+ * @param array [string]string $args The arguments for the command.
+ * @param array [string] $extra Un-named arguments passed to the command.
+ *
+ * @return void This function does not return a value.
+ *
+ * @throws UnsupportedCommand if the module does not support a command.
+ * @throws WrongArguments if the arguments passed to the command are not
+ * sufficient or correct to execute the command.
+ * @throws CommandFailed if the module failed to execute a command.
+ */
+ public function runCommand($command, $args, $extra);
+}
diff --git a/src/core/Directus/Console/Modules/UserModule.php b/src/core/Directus/Console/Modules/UserModule.php
new file mode 100644
index 0000000000..f435c9157d
--- /dev/null
+++ b/src/core/Directus/Console/Modules/UserModule.php
@@ -0,0 +1,73 @@
+help = [
+ 'password' => ''
+ . PHP_EOL . "\t\t-e " . 'User e-mail address.'
+ . PHP_EOL . "\t\t-p " . 'New password for the user.'
+ . PHP_EOL . "\t\t-d " . 'Directus path. Default: ' . $this->getBasePath()
+ ];
+
+ $this->commands_help = [
+ 'password' => 'Change User Password: ' . PHP_EOL . PHP_EOL . "\t\t"
+ . $this->__module_name . ':password -e user_email -p new_password -d directus_path' . PHP_EOL
+ ];
+
+ $this->__module_name = 'user';
+ $this->__module_description = 'commands to manage Directus users';
+ }
+
+ public function cmdPassword($args, $extra)
+ {
+ $directus_path = $this->getBasePath();
+
+ $data = [];
+
+ foreach ($args as $key => $value) {
+ switch ($key) {
+ case 'e':
+ $data['user_email'] = $value;
+ break;
+ case 'p':
+ $data['user_password'] = $value;
+ break;
+ case 'd':
+ $directus_path = $value;
+ break;
+ }
+ }
+
+ if (!isset($data['user_email'])) {
+ throw new WrongArgumentsException($this->__module_name . ':password ' . 'missing user e-mail to change password for!');
+ }
+
+ if (!isset($data['user_password'])) {
+ throw new WrongArgumentsException($this->__module_name . ':password ' . 'missing new password for user!');
+ }
+
+ $user = new User($directus_path);
+ try {
+ $user->changePassword($data['user_email'], $data['user_password']);
+ } catch (PasswordChangeException $ex) {
+ throw new CommandFailedException('Error changing user password' . ': ' . $ex->getMessage());
+ }
+
+ }
+}
diff --git a/src/core/Directus/Container/Container.php b/src/core/Directus/Container/Container.php
new file mode 100644
index 0000000000..44e82de2ea
--- /dev/null
+++ b/src/core/Directus/Container/Container.php
@@ -0,0 +1,37 @@
+offsetExists($offset);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function get($offset)
+ {
+ if (!$this->offsetExists($offset)) {
+ throw new ValueNotFoundException(sprintf('The key "%s" is not defined.', $offset));
+ }
+
+ return $this->offsetGet($offset);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function set($offset, $value)
+ {
+ $this->offsetSet($offset, $value);
+ }
+}
diff --git a/src/core/Directus/Container/Exception/ValueNotFoundException.php b/src/core/Directus/Container/Exception/ValueNotFoundException.php
new file mode 100644
index 0000000000..cac2906c75
--- /dev/null
+++ b/src/core/Directus/Container/Exception/ValueNotFoundException.php
@@ -0,0 +1,8 @@
+=5.4.0",
+ "directus/collection": "^1.0",
+ "pimple/pimple": "^3.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "Directus\\Container\\": ""
+ }
+ }
+}
diff --git a/src/core/Directus/Database/Connection.php b/src/core/Directus/Database/Connection.php
new file mode 100644
index 0000000000..4363ca7476
--- /dev/null
+++ b/src/core/Directus/Database/Connection.php
@@ -0,0 +1,81 @@
+getDriver()->getDatabasePlatformName();
+
+ switch (strtolower($driverName)) {
+ case 'mysql':
+ $enabled = $this->isMySQLStrictModeEnabled();
+ break;
+ }
+
+ return $enabled;
+ }
+
+ /**
+ * Check if MySQL has Strict mode enabled
+ *
+ * @return bool
+ */
+ protected function isMySQLStrictModeEnabled()
+ {
+ $strictModes = ['STRICT_ALL_TABLES', 'STRICT_TRANS_TABLES'];
+ $statement = $this->query('SELECT @@sql_mode as modes');
+ $result = $statement->execute();
+ $modesEnabled = $result->current();
+
+ $modes = explode(',', $modesEnabled['modes']);
+ foreach ($modes as $name) {
+ $modeName = strtoupper(trim($name));
+ if (in_array($modeName, $strictModes)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Connect to the database
+ *
+ * @return \Zend\Db\Adapter\Driver\ConnectionInterface
+ */
+ public function connect()
+ {
+ return call_user_func_array([$this->getDriver()->getConnection(), 'connect'], func_get_args());
+ }
+
+ /**
+ * Execute an query string
+ *
+ * @param $sql
+ *
+ * @return \Zend\Db\Adapter\Driver\StatementInterface|\Zend\Db\ResultSet\ResultSet
+ */
+ public function execute($sql)
+ {
+ return $this->query($sql, static::QUERY_MODE_EXECUTE);
+ }
+}
diff --git a/src/core/Directus/Database/Ddl/Column/Bit.php b/src/core/Directus/Database/Ddl/Column/Bit.php
new file mode 100644
index 0000000000..62213e1fb1
--- /dev/null
+++ b/src/core/Directus/Database/Ddl/Column/Bit.php
@@ -0,0 +1,13 @@
+setName($name);
+ $this->setNullable($nullable);
+ $this->setDefault($default);
+ $this->setOptions($options);
+ }
+}
diff --git a/src/core/Directus/Database/Ddl/Column/CollectionLength.php b/src/core/Directus/Database/Ddl/Column/CollectionLength.php
new file mode 100644
index 0000000000..7b746b03c4
--- /dev/null
+++ b/src/core/Directus/Database/Ddl/Column/CollectionLength.php
@@ -0,0 +1,75 @@
+setLength($length);
+
+ parent::__construct($name, $nullable, $default, $options);
+ }
+
+ /**
+ * @param int $length
+ * @return self Provides a fluent interface
+ */
+ public function setLength($length)
+ {
+ $values = $length;
+ if (!is_array($length)) {
+ $values = explode(',', $values);
+ }
+
+ $length = implode(',', array_map(function ($value) {
+ return sprintf('"%s"', $value);
+ }, $values));
+
+ $this->length = $length;
+
+ return $this;
+ }
+
+ /**
+ * @return int
+ */
+ public function getLength()
+ {
+ return $this->length;
+ }
+
+ /**
+ * @return string
+ */
+ protected function getLengthExpression()
+ {
+ return (string) $this->length;
+ }
+
+ /**
+ * @return array
+ */
+ public function getExpressionData()
+ {
+ $data = parent::getExpressionData();
+
+ if ($this->getLengthExpression()) {
+ $data[0][1][1] .= '(' . $this->getLengthExpression() . ')';
+ }
+
+ return $data;
+ }
+}
diff --git a/src/core/Directus/Database/Ddl/Column/Custom.php b/src/core/Directus/Database/Ddl/Column/Custom.php
new file mode 100644
index 0000000000..488a26d5cb
--- /dev/null
+++ b/src/core/Directus/Database/Ddl/Column/Custom.php
@@ -0,0 +1,43 @@
+setType($type);
+ }
+
+ public function setType($type)
+ {
+ $this->type = $type;
+ }
+
+ /**
+ * Sets the column length
+ *
+ * @param int|array $length
+ *
+ * @return $this
+ */
+ public function setLength($length)
+ {
+ if (is_array($length)) {
+ $length = implode(',', array_map(function ($value) {
+ // add slashes in case the value has quotes
+ return sprintf('"%s"', addslashes($value));
+ }, $length));
+ } else {
+ $length = (int) $length;
+ }
+
+ $this->length = $length;
+
+ return $this;
+ }
+}
diff --git a/src/core/Directus/Database/Ddl/Column/Double.php b/src/core/Directus/Database/Ddl/Column/Double.php
new file mode 100644
index 0000000000..c474144cf9
--- /dev/null
+++ b/src/core/Directus/Database/Ddl/Column/Double.php
@@ -0,0 +1,13 @@
+getLength();
+ }
+}
diff --git a/src/core/Directus/Database/Exception/CollectionAlreadyExistsException.php b/src/core/Directus/Database/Exception/CollectionAlreadyExistsException.php
new file mode 100644
index 0000000000..52a47c2da6
--- /dev/null
+++ b/src/core/Directus/Database/Exception/CollectionAlreadyExistsException.php
@@ -0,0 +1,15 @@
+getMessage();
+ }
+
+ parent::__construct($message, static::ERROR_CODE, $previous);
+ }
+}
diff --git a/src/core/Directus/Database/Exception/CustomUiValidationError.php b/src/core/Directus/Database/Exception/CustomUiValidationError.php
new file mode 100644
index 0000000000..dfe0430cde
--- /dev/null
+++ b/src/core/Directus/Database/Exception/CustomUiValidationError.php
@@ -0,0 +1,9 @@
+query = $query;
+ }
+
+ /**
+ * @return string
+ */
+ public function getQuery()
+ {
+ return $this->query;
+ }
+}
diff --git a/src/core/Directus/Database/Exception/ItemNotFoundException.php b/src/core/Directus/Database/Exception/ItemNotFoundException.php
new file mode 100644
index 0000000000..31cc7aa0d3
--- /dev/null
+++ b/src/core/Directus/Database/Exception/ItemNotFoundException.php
@@ -0,0 +1,15 @@
+identifier = $identifier;
+ $this->values = $values;
+ $this->not = $not;
+ $this->logic = $logic;
+ }
+
+ /**
+ * @return string
+ */
+ public function getIdentifier()
+ {
+ return $this->identifier;
+ }
+
+ /**
+ * @return array
+ */
+ public function getValue()
+ {
+ $operator = ($this->not ? 'not' : '') . 'in';
+
+ return [
+ $operator => $this->values,
+ 'logical' => $this->logic
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public function toArray()
+ {
+ return [
+ $this->getIdentifier() => $this->getValue()
+ ];
+ }
+}
diff --git a/src/core/Directus/Database/Query/Builder.php b/src/core/Directus/Database/Query/Builder.php
new file mode 100644
index 0000000000..83bd3694d2
--- /dev/null
+++ b/src/core/Directus/Database/Query/Builder.php
@@ -0,0 +1,812 @@
+connection = $connection;
+ }
+
+ /**
+ * Sets the columns list to be selected
+ *
+ * @param array $columns
+ *
+ * @return $this
+ */
+ public function columns(array $columns)
+ {
+ $this->columns = $columns;
+
+ return $this;
+ }
+
+ /**
+ * Gets the selected columns
+ *
+ * @return array
+ */
+ public function getColumns()
+ {
+ return $this->columns;
+ }
+
+ /**
+ * Sets the from table
+ *
+ * @param $from
+ *
+ * @return $this
+ */
+ public function from($from)
+ {
+ $this->from = $from;
+
+ return $this;
+ }
+
+ /**
+ * Gets the from table value
+ *
+ * @return null|string
+ */
+ public function getFrom()
+ {
+ return $this->from;
+ }
+
+ public function join($table, $on, $columns = ['*'], $type = 'inner')
+ {
+ $this->joins[] = compact('table', 'on', 'columns', 'type');
+
+ return $this;
+ }
+
+ public function getJoins()
+ {
+ return $this->joins;
+ }
+
+ /**
+ * Sets a condition to the query
+ *
+ * @param $column
+ * @param $operator
+ * @param $value
+ * @param $not
+ * @param $logical
+ *
+ * @return $this
+ */
+ public function where($column, $operator = null, $value = null, $not = false, $logical = 'and')
+ {
+ if ($column instanceof \Closure) {
+ return $this->nestWhere($column, $logical);
+ }
+
+ $type = 'basic';
+
+ $this->wheres[] = compact('type', 'operator', 'column', 'value', 'not', 'logical');
+
+ return $this;
+ }
+
+ /**
+ * Creates a nested condition
+ *
+ * @param \Closure $callback
+ * @param string $logical
+ *
+ * @return $this
+ */
+ public function nestWhere(\Closure $callback, $logical = 'and')
+ {
+ $query = $this->newQuery();
+ call_user_func($callback, $query);
+
+ if (count($query->getWheres())) {
+ $type = 'nest';
+ $this->wheres[] = compact('type', 'query', 'logical');
+ }
+
+ return $this;
+ }
+
+ public function nestOrWhere(\Closure $callback)
+ {
+ return $this->nestWhere($callback, 'or');
+ }
+
+ /**
+ * Create a condition where the given column is empty (NULL or empty string)
+ *
+ * @param $column
+ *
+ * @return Builder
+ */
+ public function whereEmpty($column)
+ {
+ return $this->nestWhere(function(Builder $query) use ($column) {
+ $query->orWhereNull($column);
+ $query->orWhereEqualTo($column, '');
+ });
+ }
+
+ /**
+ * Create a condition where the given column is NOT empty (NULL or empty string)
+ *
+ * @param $column
+ *
+ * @return Builder
+ */
+ public function whereNotEmpty($column)
+ {
+ return $this->nestWhere(function(Builder $query) use ($column) {
+ $query->whereNotNull($column);
+ $query->whereNotEqualTo($column, '');
+ }, 'and');
+ }
+
+ /**
+ * Sets a "where in" condition
+ *
+ * @param $column
+ * @param array|Builder $values
+ * @param bool $not
+ * @param string $logical
+ *
+ * @return Builder
+ */
+ public function whereIn($column, $values, $not = false, $logical = 'and')
+ {
+ return $this->where($column, 'in', $values, $not, $logical);
+ }
+
+ /**
+ * Sets a "where or in" condition
+ *
+ * @param $column
+ * @param array|Builder $values
+ * @param bool $not
+ *
+ * @return Builder
+ */
+ public function orWhereIn($column, $values, $not = false)
+ {
+ return $this->where($column, 'in', $values, $not, 'or');
+ }
+
+ /**
+ * Sets an "where not in" condition
+ *
+ * @param $column
+ * @param array $values
+ *
+ * @return Builder
+ */
+ public function whereNotIn($column, array $values)
+ {
+ return $this->whereIn($column, $values, true);
+ }
+
+ public function whereBetween($column, array $values, $not = false)
+ {
+ return $this->where($column, 'between', $values, $not);
+ }
+
+ public function whereNotBetween($column, array $values)
+ {
+ return $this->whereBetween($column, $values, true);
+ }
+
+ public function whereEqualTo($column, $value, $not = false, $logical = 'and')
+ {
+ return $this->where($column, '=', $value, $not, $logical);
+ }
+
+ public function orWhereEqualTo($column, $value, $not = false)
+ {
+ return $this->where($column, '=', $value, $not, 'or');
+ }
+
+ public function whereNotEqualTo($column, $value)
+ {
+ return $this->whereEqualTo($column, $value, true);
+ }
+
+ public function whereLessThan($column, $value)
+ {
+ return $this->where($column, '<', $value);
+ }
+
+ public function whereLessThanOrEqual($column, $value)
+ {
+ return $this->where($column, '<=', $value);
+ }
+
+ public function whereGreaterThan($column, $value)
+ {
+ return $this->where($column, '>', $value);
+ }
+
+ public function whereGreaterThanOrEqual($column, $value)
+ {
+ return $this->where($column, '>=', $value);
+ }
+
+ public function whereNull($column, $not = false, $logical = 'and')
+ {
+ return $this->where($column, 'null', null, $not, $logical);
+ }
+
+ public function orWhereNull($column, $not = false)
+ {
+ return $this->whereNull($column, $not, 'or');
+ }
+
+ public function whereNotNull($column)
+ {
+ return $this->whereNull($column, true);
+ }
+
+ public function whereLike($column, $value, $not = false, $logical = 'and')
+ {
+ return $this->where($column, 'like', $value, $not, $logical);
+ }
+
+ public function orWhereLike($column, $value, $not = false)
+ {
+ return $this->whereLike($column, $value, $not, 'or');
+ }
+
+ public function whereNotLike($column, $value)
+ {
+ return $this->whereLike($column, $value, true);
+ }
+
+ public function whereAll($column, $table, $columnLeft, $columnRight, $values)
+ {
+ if ($columnLeft === null) {
+ $relation = new OneToManyRelation($this, $column, $table, $columnRight, $this->getFrom());
+ } else {
+ $relation = new ManyToManyRelation($this, $table, $columnLeft, $columnRight);
+ }
+
+ $relation->all($values);
+
+ return $this->whereIn($column, $relation);
+ }
+
+ public function whereHas($column, $table, $columnLeft, $columnRight, $count = 1, $not = false)
+ {
+ if (is_null($columnLeft)) {
+ $relation = new OneToManyRelation($this, $column, $table, $columnRight, $this->getFrom());
+ } else {
+ $relation = new ManyToManyRelation($this, $table, $columnLeft, $columnRight);
+ }
+
+ // If checking if has 0, this case will be the opposite
+ // has = 0, NOT IN the record that has more than 0
+ // not has = 0, IN the record that has more than 0
+ if ($count < 1) {
+ $not = !$not;
+ }
+
+ $relation->has($count);
+
+ return $this->whereIn($column, $relation, $not);
+ }
+
+ public function whereNotHas($column, $table, $columnLeft, $columnRight, $count = 1)
+ {
+ return $this->whereHas($column, $table, $columnLeft, $columnRight, $count, true);
+ }
+
+ public function whereRelational($column, $table, $columnLeft, $columnRight = null, \Closure $callback = null, $logical = 'and')
+ {
+ if (is_callable($columnRight)) {
+ // $column: Relational Column
+ // $table: Related table
+ // $columnRight: Related table that points to $column
+ $callback = $columnRight;
+ $columnRight = $columnLeft;
+ $columnLeft = null;
+ $relation = new ManyToOneRelation($this, $columnRight, $table);
+ } else if (is_null($columnLeft)) {
+ $relation = new OneToManyRelation($this, $column, $table, $columnRight, $this->getFrom());
+ } else {
+ $relation = new ManyToManyRelation($this, $table, $columnLeft, $columnRight);
+ }
+
+ call_user_func($callback, $relation);
+
+ return $this->whereIn($column, $relation, false, $logical);
+ }
+
+ public function orWhereRelational($column, $table, $columnLeft, $columnRight = null, \Closure $callback = null)
+ {
+ return $this->whereRelational($column, $table, $columnLeft, $columnRight, $callback, 'or');
+ }
+
+ /**
+ * Gets the query conditions
+ *
+ * @return array
+ */
+ public function getWheres()
+ {
+ return $this->wheres;
+ }
+
+ /**
+ * Order the query by the given table
+ *
+ * @param $column
+ * @param string $direction
+ *
+ * @return Builder
+ */
+ public function orderBy($column, $direction = 'ASC')
+ {
+ $this->order[(string) $column] = (string) $direction;
+
+ return $this;
+ }
+
+ /**
+ * Gets the sorts
+ *
+ * @return array
+ */
+ public function getOrder()
+ {
+ return $this->order;
+ }
+
+ /**
+ * Clears the order list
+ */
+ public function clearOrder()
+ {
+ $this->order = [];
+ }
+
+ /**
+ * Sets the number of records to skip
+ *
+ * @param $value
+ *
+ * @return Builder
+ */
+ public function offset($value)
+ {
+ $this->offset = max(0, $value);
+
+ return $this;
+ }
+
+ /**
+ * Alias of Builder::offset
+ *
+ * @param $value
+ *
+ * @return Builder
+ */
+ public function skip($value)
+ {
+ return $this->offset($value);
+ }
+
+ /**
+ * Gets the query offset
+ *
+ * @return int|null
+ */
+ public function getOffset()
+ {
+ return $this->offset;
+ }
+
+ /**
+ * Alias of Builder::getOffset
+ *
+ * @return int|null
+ */
+ public function getSkip()
+ {
+ return $this->getOffset();
+ }
+
+ /**
+ * Sets the query result limit
+ *
+ * @param $value
+ *
+ * @return $this
+ */
+ public function limit($value)
+ {
+ // =============================================================================
+ // LIMIT 0 quickly returns an empty set.
+ // This can be useful for checking the validity of a query.
+ // @see: http://dev.mysql.com/doc/refman/5.7/en/limit-optimization.html
+ // =============================================================================
+ if ($value >= 0) {
+ $this->limit = (int) $value;
+ } else {
+ $this->limit = null;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Gets the query result limit
+ *
+ * @return int|null
+ */
+ public function getLimit()
+ {
+ return $this->limit;
+ }
+
+ /**
+ * Sets Group by columns
+ *
+ * @param array|string $columns
+ */
+ public function groupBy($columns)
+ {
+ if (!is_array($columns)) {
+ $columns = [$columns];
+ }
+
+ if ($this->groupBys === null) {
+ $this->groupBys = [];
+ }
+
+ foreach($columns as $column) {
+ $this->groupBys[] = $column;
+ }
+ }
+
+ /**
+ * Sets having
+ *
+ * @param $column
+ * @param $operator
+ * @param $value
+ *
+ * @return $this
+ */
+ public function having($column, $operator = null, $value = null)
+ {
+ $this->havings[] = compact('column', 'operator', 'value');
+
+ return $this;
+ }
+
+ /**
+ * Gets havings
+ *
+ * @return array|null
+ */
+ public function getHavings()
+ {
+ return $this->havings;
+ }
+
+ /**
+ * Build the Select Object
+ *
+ * @return \Zend\Db\Sql\Select
+ */
+ public function buildSelect()
+ {
+ $select = $this->getSqlObject()->select($this->getFrom());
+ $select->columns($this->getColumns());
+ $select->order($this->buildOrder());
+
+ if ($this->getJoins() !== null) {
+ foreach($this->getJoins() as $join) {
+ $select->join($join['table'], $join['on'], $join['columns'], $join['type']);
+ }
+ }
+
+ if ($this->getOffset() !== null && $this->getLimit() !== null) {
+ $select->offset($this->getOffset());
+ }
+
+ if ($this->getLimit() !== null) {
+ $select->limit($this->getLimit());
+ }
+
+ foreach ($this->getWheres() as $condition) {
+ $this->buildCondition($select->where, $condition);
+ }
+
+ if ($this->groupBys !== null) {
+ $groupBys = [];
+
+ foreach ($this->groupBys as $groupBy) {
+ $groupBys[] = $this->getIdentifier($groupBy);
+ }
+
+ $select->group($groupBys);
+ }
+
+ if ($this->getHavings() !== null) {
+ foreach ($this->getHavings() as $having) {
+ if ($having['column'] instanceof Expression) {
+ $expression = $having['column'];
+ $callback = function(Having $having) use ($expression) {
+ $having->addPredicate($expression);
+ };
+ } else {
+ $callback = function(Having $havingObject) use ($having) {
+ $havingObject->addPredicate(new Operator($having['column'], $having['operator'], $having['value']));
+ };
+ }
+
+ $select->having($callback);
+ }
+ }
+
+ return $select;
+ }
+
+ /**
+ * Executes the query
+ *
+ * @return AbstractResultSet
+ */
+ public function get()
+ {
+ $sql = $this->getSqlObject();
+ $select = $this->buildSelect();
+
+ $statement = $sql->prepareStatementForSqlObject($select);
+ $result = $statement->execute();
+
+ $resultSet = new ResultSet($result);
+ $resultSet->initialize($result);
+
+ return $resultSet;
+ }
+
+ /**
+ * Gets the query string
+ *
+ * @return string
+ */
+ public function getSql()
+ {
+ $sql = $this->getSqlObject();
+ $select = $this->buildSelect();
+
+ return $sql->buildSqlString($select, $this->connection);
+ }
+
+ /**
+ * Build the condition expressions
+ *
+ * @param Predicate $where
+ * @param array $condition
+ */
+ protected function buildCondition(Predicate $where, array $condition)
+ {
+ $logical = strtoupper(ArrayUtils::get($condition, 'logical', 'and'));
+
+ if (ArrayUtils::get($condition, 'type') === 'nest') {
+ /** @var Builder $query */
+ $query = ArrayUtils::get($condition, 'query');
+ if ($logical === 'OR') {
+ $where->or;
+ }
+
+ $where = $where->nest();
+
+ foreach ($query->getWheres() as $condition) {
+ $query->from($this->getFrom());
+ $query->buildCondition($where, $condition);
+ }
+
+ $where->unnest();
+ } else {
+ $where->addPredicate($this->buildConditionExpression($condition), $logical);
+ }
+ }
+
+ protected function buildOrder()
+ {
+ $order = [];
+ foreach($this->getOrder() as $orderBy => $orderDirection) {
+ $order[] = sprintf('%s %s', $this->getIdentifier($orderBy), $orderDirection);
+ }
+
+ return $order;
+ }
+
+ /**
+ * Get the column identifier (table name prepended)
+ *
+ * @param string $column
+ *
+ * @return string
+ */
+ protected function getIdentifier($column)
+ {
+ $platform = $this->getConnection()->getPlatform();
+ $table = $this->getFrom();
+
+ if (strpos($column, $platform->getIdentifierSeparator()) === false) {
+ $column = implode($platform->getIdentifierSeparator(), [$table, $column]);
+ }
+
+ return $column;
+ }
+
+ protected function buildConditionExpression($condition)
+ {
+ $not = ArrayUtils::get($condition, 'not', false) === true;
+ $notChar = '';
+ if ($not === true) {
+ $notChar = $condition['operator'] === '=' ? '!' : 'n';
+ }
+
+ $operator = $notChar . $condition['operator'];
+
+ $column = $condition['column'];
+ $identifier = $this->getIdentifier($column);
+ $value = $condition['value'];
+
+ if ($value instanceof Builder) {
+ $value = $value->buildSelect();
+ }
+
+ switch ($operator) {
+ case 'in':
+ $expression = new In($identifier, $value);
+ break;
+ case 'nin':
+ $expression = new NotIn($identifier, $value);
+ break;
+ case 'like':
+ $value = "%$value%";
+ $expression = new Like($identifier, $value);
+ break;
+ case 'nlike':
+ $value = "%$value%";
+ $expression = new NotLike($identifier, $value);
+ break;
+ case 'null':
+ $expression = new IsNull($identifier);
+ break;
+ case 'nnull':
+ $expression = new IsNotNull($identifier);
+ break;
+ case 'between':
+ $expression = new Between($identifier, array_shift($value), array_pop($value));
+ break;
+ case 'nbetween':
+ $expression = new NotBetween($identifier, array_shift($value), array_pop($value));
+ break;
+ default:
+ $expression = new Operator($identifier, $operator, $value);
+ }
+
+ return $expression;
+ }
+
+ protected function getSqlObject()
+ {
+ if ($this->sql === null) {
+ $this->sql = new Sql($this->connection);
+ }
+
+ return $this->sql;
+ }
+
+ /**
+ * Gets a new instance of the query builder
+ *
+ * @return \Directus\Database\Query\Builder
+ */
+ public function newQuery()
+ {
+ return new self($this->connection);
+ }
+
+ /**
+ * Gets the connection
+ *
+ * @return AdapterInterface
+ */
+ public function getConnection()
+ {
+ return $this->connection;
+ }
+}
diff --git a/src/core/Directus/Database/Query/Relations/ManyToManyRelation.php b/src/core/Directus/Database/Query/Relations/ManyToManyRelation.php
new file mode 100644
index 0000000000..ddce6126ce
--- /dev/null
+++ b/src/core/Directus/Database/Query/Relations/ManyToManyRelation.php
@@ -0,0 +1,45 @@
+getConnection());
+
+ $this->parentBuilder = $builder;
+ $this->table = $table;
+ $this->columnLeft = $columnLeft;
+ $this->columnRight = $columnRight;
+
+ $this->from($table);
+ }
+
+ public function all(array $values)
+ {
+ $this->columns([$this->columnLeft]);
+ $this->whereIn($this->columnRight, $values);
+ $this->groupBy($this->columnLeft);
+ $this->having(new Expression('COUNT(*) = ?', count($values)));
+
+ return $this;
+ }
+
+ public function has($count = 1)
+ {
+ $this->columns([$this->columnLeft]);
+ $this->groupBy($this->columnLeft);
+ $this->having(new Expression('COUNT(*) >= ?', (int) $count));
+
+ return $this;
+ }
+}
\ No newline at end of file
diff --git a/src/core/Directus/Database/Query/Relations/ManyToOneRelation.php b/src/core/Directus/Database/Query/Relations/ManyToOneRelation.php
new file mode 100644
index 0000000000..cb19ee998f
--- /dev/null
+++ b/src/core/Directus/Database/Query/Relations/ManyToOneRelation.php
@@ -0,0 +1,26 @@
+getConnection());
+
+ $this->parentBuilder = $builder;
+ $this->column = $column;
+ $this->relatedTable = $relatedTable;
+
+ $this->columns([$this->column]);
+ $this->from($this->relatedTable);
+ }
+}
diff --git a/src/core/Directus/Database/Query/Relations/OneToManyRelation.php b/src/core/Directus/Database/Query/Relations/OneToManyRelation.php
new file mode 100644
index 0000000000..938f6b4085
--- /dev/null
+++ b/src/core/Directus/Database/Query/Relations/OneToManyRelation.php
@@ -0,0 +1,65 @@
+getConnection());
+
+ $this->parentBuilder = $builder;
+ $this->column = $column;
+ $this->table = $table;
+ $this->columnRight = $columnRight;
+ $this->relatedTable = $relatedTable;
+
+ $this->columns([$this->column]);
+ $this->from($relatedTable);
+ $on = sprintf('%s.%s = %s.%s', $this->relatedTable, $this->column, $this->table, $this->columnRight);
+ $this->join($this->table, $on, [$this->columnRight], 'right');
+ }
+
+ public function all(array $values)
+ {
+ $this->columns([]);
+ $this->whereIn($this->table . '.' . $this->column, $values);
+ $this->groupBy($this->columnRight);
+ $this->having(new Expression('COUNT(*) = ?', count($values)));
+
+ return $this;
+ }
+
+ public function has($count = 1)
+ {
+ $this->columns([]);
+ $this->groupBy($this->columnRight);
+ $this->having(new Expression('COUNT(*) >= ?', (int) $count));
+
+ return $this;
+ }
+
+ public function whereLike($column, $value, $not = false, $logical = 'and')
+ {
+ $this->columns([]);
+ parent::whereLike($this->table . '.' . $column, $value, $not, $logical);
+
+ return $this;
+ }
+
+ public function orWhereLike($column, $value, $not = false)
+ {
+ $this->whereLike($column, $value, $not, 'or');
+
+ return $this;
+ }
+}
diff --git a/src/core/Directus/Database/README.md b/src/core/Directus/Database/README.md
new file mode 100644
index 0000000000..503da852f9
--- /dev/null
+++ b/src/core/Directus/Database/README.md
@@ -0,0 +1,5 @@
+# Directus Database
+
+The Directus Database component.
+
+### _**A Work in Progress**_
diff --git a/src/core/Directus/Database/Repositories/AbstractRepository.php b/src/core/Directus/Database/Repositories/AbstractRepository.php
new file mode 100644
index 0000000000..04e94824c1
--- /dev/null
+++ b/src/core/Directus/Database/Repositories/AbstractRepository.php
@@ -0,0 +1,60 @@
+tableGateway = $tableGateway;
+ $this->idAttribute = ArrayUtils::get($options, 'id', 'id');
+ $this->hookEmitter = ArrayUtils::get($options, 'hookEmitter');
+ $this->acl = ArrayUtils::get($options, 'acl');
+ }
+
+ /**
+ * @param string $attribute
+ * @param mixed $value
+ * @return mixed
+ */
+ public function findOneBy($attribute, $value)
+ {
+ // TODO: Implement findOneBy() method.
+ }
+
+ /**
+ * @param int|string $id
+ * @return mixed
+ */
+ public function find($id)
+ {
+ return $this->findOneBy($this->idAttribute, $id);
+ }
+}
diff --git a/src/core/Directus/Database/Repositories/Repository.php b/src/core/Directus/Database/Repositories/Repository.php
new file mode 100644
index 0000000000..0243010a70
--- /dev/null
+++ b/src/core/Directus/Database/Repositories/Repository.php
@@ -0,0 +1,8 @@
+acl = $acl;
+
+ parent::__construct($primaryKeyColumn, $table, $adapterOrSql);
+ }
+
+ public function getId()
+ {
+ return $this->data[$this->primaryKeyColumn[0]];
+ }
+
+ public function getCollection()
+ {
+ return $this->table;
+ }
+
+ /**
+ * Override this function to do table-specific record data filtration, pre-insert and update.
+ * This method is called during #populate and #populateSkipAcl.
+ *
+ * @param array $rowData
+ * @param boolean $rowExistsInDatabase
+ *
+ * @return array Filtered $rowData.
+ */
+ public function preSaveDataHook(array $rowData, $rowExistsInDatabase = false)
+ {
+ // Custom gateway logic
+ return $rowData;
+ }
+
+ /**
+ *
+ * @param string|array $primaryKeyColumn
+ * @param $table
+ * @param $adapter
+ * @param Acl|null $acl
+ *
+ * @return BaseRowGateway
+ */
+ public static function makeRowGatewayFromTableName($primaryKeyColumn, $table, $adapter, $acl = null)
+ {
+
+ // =============================================================================
+ // @NOTE: Setting the column to 'id' by default
+ // As it mostly will be the default column
+ // Otherwise it will be set to whatever name or compose id is.
+ // =============================================================================
+
+ // Underscore to camelcase table name to namespaced row gateway classname,
+ // e.g. directus_users => \Directus\Database\RowGateway\DirectusUsersRowGateway
+ $rowGatewayClassName = Formatting::underscoreToCamelCase($table) . 'RowGateway';
+ $rowGatewayClassName = __NAMESPACE__ . '\\' . $rowGatewayClassName;
+ if (!class_exists($rowGatewayClassName)) {
+ $rowGatewayClassName = get_called_class();
+ }
+
+ return new $rowGatewayClassName($primaryKeyColumn, $table, $adapter, $acl);
+ }
+
+ /**
+ * @param array $primaryKeyData
+ *
+ * @return string
+ */
+ public static function stringifyPrimaryKeyForRecordDebugRepresentation($primaryKeyData)
+ {
+ if (null === $primaryKeyData) {
+ return 'null primary key';
+ }
+
+ return 'primary key (' . implode(':', array_keys($primaryKeyData)) . ') "' . implode(':', $primaryKeyData) . '"';
+ }
+
+ /**
+ * Populate Data
+ *
+ * @param array $rowData
+ * @param bool $rowExistsInDatabase
+ *
+ * @return RowGatewayInterface
+ */
+ public function populate(array $rowData, $rowExistsInDatabase = false)
+ {
+ // IDEAL OR SOMETHING LIKE IT
+ // grab record
+ // populate skip acl
+ // diff btwn real record $rowData parameter
+ // only run blacklist on the diff from real data and the db data
+
+ $rowData = $this->preSaveDataHook($rowData, $rowExistsInDatabase);
+
+ //if(!$this->acl->hasTablePrivilege($this->table, 'bigedit')) {
+ // Enforce field write blacklist
+ // $attemptOffsets = array_keys($rowData);
+ // $this->acl->enforceBlacklist($this->table, $attemptOffsets, Acl::FIELD_WRITE_BLACKLIST);
+ //}
+
+ return parent::populate($rowData, $rowExistsInDatabase);
+ }
+
+ /**
+ * ONLY USE THIS FOR INITIALIZING THE ROW OBJECT.
+ *
+ * This function does not enforce ACL write privileges.
+ * It shouldn't be used to fulfill data assignment on behalf of the user.
+ *
+ * @param mixed $rowData Row key/value pairs.
+ * @param bool $rowExistsInDatabase
+ *
+ * @return RowGatewayInterface
+ */
+ public function populateSkipAcl(array $rowData, $rowExistsInDatabase = false)
+ {
+ return parent::populate($rowData, $rowExistsInDatabase);
+ }
+
+ /**
+ * ONLY USE THIS FOR INITIALIZING THE ROW OBJECT.
+ *
+ * This function does not enforce ACL write privileges.
+ * It shouldn't be used to fulfill data assignment on behalf of the user.
+ * @param mixed $rowData Row key/value pairs.
+ *
+ * @return RowGatewayInterface
+ */
+ public function exchangeArray($rowData)
+ {
+ // NOTE: Something we made select where the primary key is not involved
+ // getting the read/unread/total from messages is an example
+ $exists = ArrayUtils::contains($rowData, $this->primaryKeyColumn);
+
+ return $this->populateSkipAcl($rowData, $exists);
+ }
+
+ /**
+ * @return int
+ *
+ * @throws ForbiddenCollectionUpdateException
+ * @throws \Exception
+ */
+ public function save()
+ {
+ // if (!$this->acl) {
+ return parent::save();
+ // }
+
+ // =============================================================================
+ // ACL Enforcement
+ // -----------------------------------------------------------------------------
+ // Note: Field Write Blacklists are enforced at the object setter level
+ // BaseRowGateway::__set, BaseRowGateway::populate, BaseRowGateway::offsetSet)
+ // =============================================================================
+
+ $isCreating = !$this->rowExistsInDatabase();
+ if ($isCreating) {
+ $this->acl->enforceCreate($this->table);
+ }
+
+ // Enforce Privilege: "Little" Edit (I am the record CMS owner)
+ $ownerFieldName = SchemaService::getCollectionOwnerFieldName($this->table);
+ $cmsOwnerId = $this->acl->getRecordCmsOwnerId($this, $this->table);
+ $currentUserId = $this->acl->getUserId();
+ $canEdit = $this->acl->canUpdate($this->table);
+ $canBigEdit = $this->acl->hasTablePrivilege($this->table, 'bigedit');
+
+ // Enforce Privilege: "Big" Edit (I am not the record CMS owner)
+ if ($cmsOwnerId !== $currentUserId && !$canBigEdit) {
+ $recordPk = self::stringifyPrimaryKeyForRecordDebugRepresentation($this->primaryKeyData);
+ $recordOwner = (false === $cmsOwnerId) ? 'no magic owner column' : 'the CMS owner #' . $cmsOwnerId;
+ $aclErrorPrefix = $this->acl->getErrorMessagePrefix();
+
+ throw new ForbiddenCollectionUpdateException($aclErrorPrefix . 'Table bigedit access forbidden on `' . $this->table . '` table record with ' . $recordPk . ' and ' . $recordOwner . '.');
+ }
+
+ if (!$canEdit) {
+ $recordPk = self::stringifyPrimaryKeyForRecordDebugRepresentation($this->primaryKeyData);
+ $aclErrorPrefix = $this->acl->getErrorMessagePrefix();
+
+ throw new ForbiddenCollectionUpdateException($aclErrorPrefix . 'Table edit access forbidden on `' . $this->table . '` table record with ' . $recordPk . ' owned by the authenticated CMS user (#' . $cmsOwnerId . ').');
+ }
+
+ try {
+ return parent::save();
+ } catch (InvalidQueryException $e) {
+ throw new \Exception('Error running save on this data: ' . print_r($this->data, true));
+ }
+ }
+
+ public function delete()
+ {
+ if (!$this->acl) {
+ return parent::delete();
+ }
+
+ // =============================================================================
+ // ACL Enforcement
+ // =============================================================================
+ $currentUserId = $this->acl->getUserId();
+ $cmsOwnerId = $this->acl->getRecordCmsOwnerId($this, $this->table);
+ $canDelete = $this->acl->hasTablePrivilege($this->table, 'delete');
+ $canBigDelete = $this->acl->hasTablePrivilege($this->table, 'bigdelete');
+
+ // =============================================================================
+ // Enforce Privilege: "Big" Delete (I am not the record CMS owner)
+ // =============================================================================
+ if ($cmsOwnerId !== $currentUserId && !$canBigDelete) {
+ $recordPk = self::stringifyPrimaryKeyForRecordDebugRepresentation($this->primaryKeyData);
+ $aclErrorPrefix = $this->acl->getErrorMessagePrefix();
+
+ throw new ForbiddenCollectionDeleteException($aclErrorPrefix . 'Table harddelete access forbidden on `' . $this->table . '` table record with ' . $recordPk . ' owned by the authenticated CMS user (#' . $cmsOwnerId . ').');
+ }
+
+ // =============================================================================
+ // Enforce Privilege: "Little" Delete (I am the record CMS owner)
+ // =============================================================================
+ if (!$canDelete) {
+ $recordPk = self::stringifyPrimaryKeyForRecordDebugRepresentation($this->primaryKeyData);
+ $recordOwner = (false === $cmsOwnerId) ? 'no magic owner column' : 'the CMS owner #' . $cmsOwnerId;
+ $aclErrorPrefix = $this->acl->getErrorMessagePrefix();
+
+ throw new ForbiddenCollectionDeleteException($aclErrorPrefix . 'Table bigharddelete access forbidden on `' . $this->table . '` table record with ' . $recordPk . ' and ' . $recordOwner . '.');
+ }
+
+ return parent::delete();
+ }
+
+ /**
+ * __get
+ *
+ * @param string $name
+ * @return mixed
+ */
+ public function __get($name)
+ {
+ // Confirm user group has read privileges on field with name $name
+ if ($this->acl) {
+ $this->acl->enforceReadField($this->table, $name);
+ }
+
+ return parent::__get($name);
+ }
+
+ /**
+ * Offset get
+ *
+ * @param string $offset
+ *
+ * @return mixed
+ */
+ public function offsetGet($offset)
+ {
+ // Confirm user group has read privileges on field with name $offset
+ if ($this->acl) {
+ $this->acl->enforceReadField($this->table, $offset);
+ }
+
+ return parent::offsetGet($offset);
+ }
+
+ /**
+ * Offset set
+ *
+ * NOTE: Protecting this method protects self#__set, which calls this method in turn.
+ *
+ * @param string $offset
+ * @param mixed $value
+ *
+ * @return RowGatewayInterface
+ */
+ public function offsetSet($offset, $value)
+ {
+ // Enforce field write blacklist
+ if ($this->acl) {
+ $this->acl->enforceWriteField($this->table, $offset);
+ }
+
+ return parent::offsetSet($offset, $value);
+ }
+
+ /**
+ * Offset unset
+ *
+ * @param string $offset
+ *
+ * @return RowGatewayInterface
+ */
+ public function offsetUnset($offset)
+ {
+ // Enforce field write blacklist
+ if ($this->acl) {
+ $this->acl->enforceWriteField($this->table, $offset);
+ }
+
+ return parent::offsetUnset($offset);
+ }
+}
diff --git a/src/core/Directus/Database/RowGateway/DirectusMediaRowGateway.php b/src/core/Directus/Database/RowGateway/DirectusMediaRowGateway.php
new file mode 100644
index 0000000000..4e9b02432f
--- /dev/null
+++ b/src/core/Directus/Database/RowGateway/DirectusMediaRowGateway.php
@@ -0,0 +1,22 @@
+acl->getCmsOwnerColumnByTable($this->table);
+ // $rowData[$cmsOwnerColumnName] = $currentUser['id'];
+ } else {
+ if (array_key_exists('date_uploaded', $rowData))
+ unset($rowData['date_uploaded']);
+ }
+ return $rowData;
+ }
+
+}
diff --git a/src/core/Directus/Database/RowGateway/DirectusUsersRowGateway.php b/src/core/Directus/Database/RowGateway/DirectusUsersRowGateway.php
new file mode 100644
index 0000000000..5457f7bff3
--- /dev/null
+++ b/src/core/Directus/Database/RowGateway/DirectusUsersRowGateway.php
@@ -0,0 +1,33 @@
+table, $this->sql->getAdapter(), $this->acl);
+ $dbRecord = $TableGateway->find($rowData['id']);
+ if (false === $dbRecord) {
+ // @todo is it better to throw an exception here?
+ $rowExistsInDatabase = false;
+ }
+ }
+
+ // User is updating themselves.
+ // Corresponds to a ping indicating their last activity.
+ // Updated their "last_access" value.
+ if ($this->acl) {
+ if (isset($rowData['id']) && $rowData['id'] == $this->acl->getUserId()) {
+ $rowData['last_access'] = DateTimeUtils::nowInUTC()->toString();
+ }
+ }
+
+ return $rowData;
+ }
+
+}
diff --git a/src/core/Directus/Database/Schema/DataTypes.php b/src/core/Directus/Database/Schema/DataTypes.php
new file mode 100644
index 0000000000..672f51ec07
--- /dev/null
+++ b/src/core/Directus/Database/Schema/DataTypes.php
@@ -0,0 +1,317 @@
+attributes = new Attributes($attributes);
+ }
+
+ /**
+ * @param mixed $offset
+ *
+ * @return bool
+ */
+ public function offsetExists($offset)
+ {
+ return $this->attributes->has($offset);
+ }
+
+ /**
+ * @param mixed $offset
+ *
+ * @return mixed
+ */
+ public function offsetGet($offset)
+ {
+ return $this->attributes->get($offset);
+ }
+
+ /**
+ * @param mixed $offset
+ * @param mixed $value
+ *
+ * @return void
+ *
+ * @throws ErrorException
+ */
+ public function offsetSet($offset, $value)
+ {
+ throw new ErrorException('Cannot set any value in ' . get_class($this));
+ }
+
+ /**
+ * @param mixed $offset
+ *
+ * @return void
+ *
+ * @throws ErrorException
+ */
+ public function offsetUnset($offset)
+ {
+ throw new ErrorException('Cannot unset any attribute in ' . get_class($this));
+ }
+
+ /**
+ * @return array
+ */
+ public function toArray()
+ {
+ return $this->attributes->toArray();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ function jsonSerialize()
+ {
+ return $this->toArray();
+ }
+}
diff --git a/src/core/Directus/Database/Schema/Object/Collection.php b/src/core/Directus/Database/Schema/Object/Collection.php
new file mode 100644
index 0000000000..92b6b4c1d9
--- /dev/null
+++ b/src/core/Directus/Database/Schema/Object/Collection.php
@@ -0,0 +1,708 @@
+attributes->get('collection');
+ }
+
+ /**
+ * Sets the collection fields
+ *
+ * @param array $fields
+ *
+ * @return Collection
+ */
+ public function setFields(array $fields)
+ {
+ foreach ($fields as $field) {
+ if (is_array($field)) {
+ $field = new Field($field);
+ }
+
+ if (!($field instanceof Field)) {
+ throw new \InvalidArgumentException('Invalid field object. ' . gettype($field) . ' given instead');
+ }
+
+ // @NOTE: This is a temporary solution
+ // to always set the primary field to the first primary key field
+ if (!$this->getPrimaryField() && $field->hasPrimaryKey()) {
+ $this->setPrimaryField($field);
+ } else if (!$this->getSortingField() && $field->getInterface() === SystemInterface::INTERFACE_SORTING) {
+ $this->setSortingField($field);
+ } else if (!$this->getStatusField() && $field->getInterface() === SystemInterface::INTERFACE_STATUS) {
+ $this->setStatusField($field);
+ } else if (!$this->getDateCreateField() && $field->getInterface() === SystemInterface::INTERFACE_DATE_CREATED) {
+ $this->setDateCreateField($field);
+ } else if (!$this->getUserCreateField() && $field->getInterface() === SystemInterface::INTERFACE_USER_CREATED) {
+ $this->setUserCreateField($field);
+ } else if (!$this->getDateUpdateField() && $field->getInterface() === SystemInterface::INTERFACE_DATE_MODIFIED) {
+ $this->setDateUpdateField($field);
+ } else if (!$this->getUserUpdateField() && $field->getInterface() === SystemInterface::INTERFACE_USER_MODIFIED) {
+ $this->setUserUpdateField($field);
+ }
+
+ $this->fields[$field->getName()] = $field;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Gets a list of the collection's fields
+ *
+ * @param array $names
+ *
+ * @return Field[]
+ */
+ public function getFields(array $names = [])
+ {
+ $fields = $this->fields;
+
+ if ($names) {
+ $fields = array_filter($fields, function(Field $field) use ($names) {
+ return in_array($field->getName(), $names);
+ });
+ }
+
+ return $fields;
+ }
+
+ /**
+ * Returns a list of Fields not in the given list name
+ *
+ * @param array $names
+ *
+ * @return Field[]
+ */
+ public function getFieldsNotIn(array $names)
+ {
+ $fields = $this->fields;
+
+ if ($names) {
+ $fields = array_filter($fields, function(Field $field) use ($names) {
+ return !in_array($field->getName(), $names);
+ });
+ }
+
+ return $fields;
+ }
+
+ /**
+ * Gets a field with the given name
+ *
+ * @param string $name
+ *
+ * @return Field
+ */
+ public function getField($name)
+ {
+ $fields = $this->getFields([$name]);
+
+ // Gets the first matched result
+ return array_shift($fields);
+ }
+
+ /**
+ * Checks whether the collection is being managed by Directus
+ *
+ * @return bool
+ */
+ public function isManaged()
+ {
+ return $this->attributes->get('managed') == 1;
+ }
+
+ /**
+ * Get all fields data as array
+ *
+ * @return array
+ */
+ public function getFieldsArray()
+ {
+ return array_map(function(Field $field) {
+ return $field->toArray();
+ }, $this->getFields());
+ }
+
+ /**
+ * Returns an array representation a list of Fields not in the given list name
+ *
+ * @param array $names
+ *
+ * @return array
+ */
+ public function getFieldsNotInArray(array $names)
+ {
+ return array_map(function (Field $field) {
+ return $field->toArray();
+ }, $this->getFieldsNotIn($names));
+ }
+
+ /**
+ * Gets all relational fields
+ *
+ * @param array $names
+ *
+ * @return Field[]
+ */
+ public function getRelationalFields(array $names = [])
+ {
+ return array_filter($this->getFields($names), function (Field $field) {
+ return $field->hasRelationship();
+ });
+ }
+
+ /**
+ * Gets all relational fields
+ *
+ * @param array $names
+ *
+ * @return Field[]
+ */
+ public function getNonRelationalFields(array $names = [])
+ {
+ return array_filter($this->getFields($names), function(Field $field) {
+ return !$field->hasRelationship();
+ });
+ }
+
+ /**
+ * Gets all the alias fields
+ *
+ * @return Field[]
+ */
+ public function getAliasFields()
+ {
+ return array_filter($this->getFields(), function(Field $field) {
+ return $field->isAlias();
+ });
+ }
+
+ /**
+ * Gets all the non-alias fields
+ *
+ * @return Field[]
+ */
+ public function getNonAliasFields()
+ {
+ return array_filter($this->getFields(), function(Field $field) {
+ return !$field->isAlias();
+ });
+ }
+
+ /**
+ * Gets all the fields name
+ *
+ * @return array
+ */
+ public function getFieldsName()
+ {
+ return array_map(function(Field $field) {
+ return $field->getName();
+ }, $this->getFields());
+ }
+
+ /**
+ * Gets all the relational fields name
+ *
+ * @return array
+ */
+ public function getRelationalFieldsName()
+ {
+ return array_map(function (Field $field) {
+ return $field->getName();
+ }, $this->getRelationalFields());
+ }
+
+ /**
+ * Gets all the alias fields name
+ *
+ * @return array
+ */
+ public function getAliasFieldsName()
+ {
+ return array_map(function(Field $field) {
+ return $field->getName();
+ }, $this->getAliasFields());
+ }
+
+ /**
+ * Gets all the non-alias fields name
+ *
+ * @return array
+ */
+ public function getNonAliasFieldsName()
+ {
+ return array_map(function(Field $field) {
+ return $field->getName();
+ }, $this->getNonAliasFields());
+ }
+
+ /**
+ * Checks whether the collection has a `primary key` interface field
+ *
+ * @return bool
+ */
+ public function hasPrimaryField()
+ {
+ return $this->getPrimaryField() ? true : false;
+ }
+
+ /**
+ * Checks whether the collection has a `status` interface field
+ *
+ * @return bool
+ */
+ public function hasStatusField()
+ {
+ return $this->getStatusField() ? true : false;
+ }
+
+ /**
+ * Checks whether the collection has a `sorting` interface field
+ *
+ * @return bool
+ */
+ public function hasSortingField()
+ {
+ return $this->getSortingField() ? true : false;
+ }
+
+ /**
+ * Checks Whether or not the collection has the given field name
+ *
+ * @param string $name
+ *
+ * @return bool
+ */
+ public function hasField($name)
+ {
+ return array_key_exists($name, $this->fields);
+ }
+
+ /**
+ * Checks whether or not the collection has the given data type field
+ *
+ * @param string $type
+ *
+ * @return bool
+ */
+ public function hasType($type)
+ {
+ foreach ($this->fields as $field) {
+ if (strtolower($type) === strtolower($field->getType())) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Checks whether or not the collection has a JSON data type field
+ *
+ * @return bool
+ */
+ public function hasJsonField()
+ {
+ return $this->hasType(DataTypes::TYPE_JSON) || $this->hasType(DataTypes::TYPE_LONG_JSON) || $this->hasType(DataTypes::TYPE_TINY_JSON) || $this->hasType(DataTypes::TYPE_MEDIUM_JSON);
+ }
+
+ /**
+ * Checks whether or not the collection has a Array data type field
+ *
+ * @return bool
+ */
+ public function hasArrayField()
+ {
+ return $this->hasType(DataTypes::TYPE_ARRAY);
+ }
+
+ /**
+ * Checks whether or not the collection has a Boolean data type field
+ *
+ * @return bool
+ */
+ public function hasBooleanField()
+ {
+ return $this->hasType(DataTypes::TYPE_BOOLEAN) || $this->hasType(DataTypes::TYPE_BOOL);
+ }
+
+ /**
+ * Gets the schema/database this collection belongs to
+ *
+ * @return null|string
+ */
+ public function getSchema()
+ {
+ return $this->attributes->get('schema', null);
+ }
+
+ /**
+ * Whether or not the collection is hidden
+ *
+ * @return bool
+ */
+ public function isHidden()
+ {
+ return (bool)$this->attributes->get('hidden');
+ }
+
+ /**
+ * Whether or not the collection is single
+ *
+ * @return bool
+ */
+ public function isSingle()
+ {
+ return (bool) $this->attributes->get('single');
+ }
+
+ /**
+ * Gets the collection custom status mapping
+ *
+ * @return StatusMapping|null
+ */
+ public function getStatusMapping()
+ {
+ $statusField = $this->getStatusField();
+ if (!$statusField) {
+ return null;
+ }
+
+ $mapping = $statusField->getOptions('status_mapping');
+ if ($mapping === null) {
+ return $mapping;
+ }
+
+ if ($mapping instanceof StatusMapping) {
+ return $mapping;
+ }
+
+ if (!is_array($mapping)) {
+ $mapping = @json_decode($mapping, true);
+ }
+
+ if (is_array($mapping)) {
+ $this->attributes->set('status_mapping', new StatusMapping($mapping));
+
+ $mapping = $this->attributes->get('status_mapping');
+ }
+
+ return $mapping;
+ }
+
+ /**
+ * Sets the primary key interface field
+ *
+ * Do not confuse it with primary key
+ *
+ * @param Field $field
+ *
+ * @return Collection
+ */
+ public function setPrimaryField(Field $field)
+ {
+ return $this->setSystemField(SystemInterface::INTERFACE_PRIMARY_KEY, $field);
+ }
+
+ /**
+ * Gets primary key interface field
+ *
+ * @return Field
+ */
+ public function getPrimaryField()
+ {
+ $field = $this->getSystemField(SystemInterface::INTERFACE_PRIMARY_KEY);
+
+ if (!$field) {
+ $field = $this->getPrimaryKey();
+ }
+
+ return $field;
+ }
+
+ /**
+ * Gets the primary key field
+ *
+ * @return Field|null
+ */
+ public function getPrimaryKey()
+ {
+ $primaryKeyField = null;
+
+ foreach ($this->getFields() as $field) {
+ if ($field->hasPrimaryKey()) {
+ $primaryKeyField = $field;
+ break;
+ }
+ }
+
+ return $primaryKeyField;
+ }
+
+ /**
+ * Gets primary key interface field's name
+ *
+ * @return string
+ */
+ public function getPrimaryKeyName()
+ {
+ $primaryField = $this->getPrimaryKey();
+ $name = null;
+
+ if ($primaryField) {
+ $name = $primaryField->getName();
+ }
+
+ return $name;
+ }
+
+ /**
+ * Sets status interface field
+ *
+ * @param Field $field
+ *
+ * @return Collection
+ */
+ public function setStatusField(Field $field)
+ {
+ return $this->setSystemField(SystemInterface::INTERFACE_STATUS, $field);
+ }
+
+ /**
+ * Gets status interface field
+ *
+ * @return Field|bool
+ */
+ public function getStatusField()
+ {
+ return $this->getSystemField(SystemInterface::INTERFACE_STATUS);
+ }
+
+ /**
+ * Sets the sort interface field
+ *
+ * @param Field $field
+ *
+ * @return Collection
+ */
+ public function setSortingField(Field $field)
+ {
+ $this->setSystemField(SystemInterface::INTERFACE_SORTING, $field);
+
+ return $this;
+ }
+
+ /**
+ * Gets the sort interface field
+ *
+ * @return Field|null
+ */
+ public function getSortingField()
+ {
+ return $this->getSystemField(SystemInterface::INTERFACE_SORTING);
+ }
+
+ /**
+ * Sets the field storing the record's user owner
+ *
+ * @param Field $field
+ *
+ * @return Collection
+ */
+ public function setUserCreateField(Field $field)
+ {
+ return $this->setSystemField(SystemInterface::INTERFACE_USER_CREATED, $field);
+ }
+
+ /**
+ * Gets the field storing the record's user owner
+ *
+ * @return Field|bool
+ */
+ public function getUserCreateField()
+ {
+ return $this->getSystemField(SystemInterface::INTERFACE_USER_CREATED);
+ }
+
+ /**
+ * Sets the field storing the user updating the record
+ *
+ * @param Field $field
+ *
+ * @return Collection
+ */
+ public function setUserUpdateField(Field $field)
+ {
+ return $this->setSystemField(SystemInterface::INTERFACE_USER_MODIFIED, $field);
+ }
+
+ /**
+ * Gets the field storing the user updating the record
+ *
+ * @return Field|null
+ */
+ public function getUserUpdateField()
+ {
+ return $this->getSystemField(SystemInterface::INTERFACE_USER_MODIFIED);
+ }
+
+ /**
+ * Sets the field storing the record created time
+ *
+ * @param Field $field
+ *
+ * @return Collection
+ */
+ public function setDateCreateField(Field $field)
+ {
+ return $this->setSystemField(SystemInterface::INTERFACE_DATE_CREATED, $field);
+ }
+
+ /**
+ * Gets the field storing the record created time
+ *
+ * @return Field|null
+ */
+ public function getDateCreateField()
+ {
+ return $this->getSystemField(SystemInterface::INTERFACE_DATE_CREATED);
+ }
+
+ /**
+ * Sets the field storing the record updated time
+ *
+ * @param Field $field
+ *
+ * @return Collection
+ */
+ public function setDateUpdateField(Field $field)
+ {
+ return $this->setSystemField(SystemInterface::INTERFACE_DATE_MODIFIED, $field);
+ }
+
+ /**
+ * Gets the field storing the record updated time
+ *
+ * @return Field|null
+ */
+ public function getDateUpdateField()
+ {
+ return $this->getSystemField(SystemInterface::INTERFACE_DATE_MODIFIED);
+ }
+
+ /**
+ * Gets the collection comment
+ *
+ * @return string
+ */
+ public function getComment()
+ {
+ return $this->attributes->get('comment');
+ }
+
+ /**
+ * Gets the item preview url
+ *
+ * @return string
+ */
+ public function getPreviewUrl()
+ {
+ return $this->attributes->get('preview_url');
+ }
+
+ /**
+ * Gets Collection item name display template
+ *
+ * Representation value of the table items
+ *
+ * @return string
+ */
+ public function getItemNameTemplate()
+ {
+ return $this->attributes->get('item_name_template');
+ }
+
+ /**
+ * @param string $interface
+ *
+ * @return Field|bool
+ */
+ protected function getSystemField($interface)
+ {
+ $systemField = ArrayUtils::get($this->systemFields, $interface, null);
+
+ if ($systemField === null) {
+ $systemField = false;
+
+ foreach ($this->fields as $field) {
+ if ($field->getInterface() === $interface) {
+ $systemField = $field;
+ }
+ }
+
+ if ($systemField) {
+ $this->setSystemField($interface, $systemField);
+ } else {
+ $this->systemFields[$interface] = $systemField;
+ }
+ }
+
+ return $systemField;
+ }
+
+ /**
+ * Sets the system interface field
+ *
+ * @param string $interface
+ * @param Field $field
+ *
+ * @return $this
+ */
+ protected function setSystemField($interface, Field $field)
+ {
+ if ($field->getInterface() === $interface) {
+ $this->systemFields[$interface] = $field;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Array representation of the collection with fields
+ *
+ * @return array
+ */
+ public function toArray()
+ {
+ $attributes = parent::toArray();
+ $attributes['fields'] = $this->getFieldsArray();
+
+ return $attributes;
+ }
+}
diff --git a/src/core/Directus/Database/Schema/Object/Field.php b/src/core/Directus/Database/Schema/Object/Field.php
new file mode 100644
index 0000000000..c2c2a928ff
--- /dev/null
+++ b/src/core/Directus/Database/Schema/Object/Field.php
@@ -0,0 +1,504 @@
+attributes->get('id');
+ }
+
+ /**
+ * Gets the field name
+ *
+ * @return string
+ */
+ public function getName()
+ {
+ return $this->attributes->get('field');
+ }
+
+ /**
+ * Gets the field type
+ *
+ * @return string
+ */
+ public function getType()
+ {
+ $type = $this->attributes->get('type');
+
+ // if the type if empty in Directus Fields table
+ // fallback to the actual data type
+ if (!$type) {
+ $type = $this->getOriginalType();
+ }
+
+ return $type;
+ }
+
+ /**
+ * Gets the field original type (based on its database)
+ *
+ * @return string
+ */
+ public function getOriginalType()
+ {
+ return $this->attributes->get('original_type');
+ }
+
+ /**
+ * Get the field length
+ *
+ * @return int
+ */
+ public function getLength()
+ {
+ $length = $this->getCharLength();
+
+ if (!$length) {
+ $length = (int) $this->attributes->get('length');
+ }
+
+ return $length;
+ }
+
+ /**
+ * Gets field full type (mysql)
+ *
+ * @return string
+ */
+ public function getColumnType()
+ {
+ // TODO: Make this from the schema manager
+ return $this->attributes->get('column_type');
+ }
+
+ /**
+ * Checks whether the fields only accepts unsigned values
+ *
+ * @return bool
+ */
+ public function isUnsigned()
+ {
+ $type = $this->getColumnType();
+
+ return strpos($type, 'unsigned') !== false;
+ }
+
+ /**
+ * Checks whether the columns has zero fill attribute
+ *
+ * @return bool
+ */
+ public function hasZeroFill()
+ {
+ $type = $this->getColumnType();
+
+ return strpos($type, 'zerofill') !== false;
+ }
+
+ /**
+ * Gets the field character length
+ *
+ * @return int
+ */
+ public function getCharLength()
+ {
+ return (int) $this->attributes->get('char_length');
+ }
+
+ /**
+ * Gets field precision
+ *
+ * @return int
+ */
+ public function getPrecision()
+ {
+ return (int) $this->attributes->get('precision');
+ }
+
+ /**
+ * Gets field scale
+ *
+ * @return int
+ */
+ public function getScale()
+ {
+ return (int) $this->attributes->get('scale');
+ }
+
+ /**
+ * Gets field ordinal position
+ *
+ * @return int
+ */
+ public function getSort()
+ {
+ return (int) $this->attributes->get('sort');
+ }
+
+ /**
+ * Gets field default value
+ *
+ * @return mixed
+ */
+ public function getDefaultValue()
+ {
+ return $this->attributes->get('default_value');
+ }
+
+ /**
+ * Gets whether or not the field is nullable
+ *
+ * @return bool
+ */
+ public function isNullable()
+ {
+ return boolval($this->attributes->get('nullable'));
+ }
+
+ /**
+ * Gets the field key
+ *
+ * @return string
+ */
+ public function getKey()
+ {
+ return $this->attributes->get('key');
+ }
+
+ /**
+ * Gets the field extra
+ *
+ * @return string
+ */
+ public function getExtra()
+ {
+ return $this->attributes->get('extra');
+ }
+
+ /**
+ * Gets whether or not the column has auto increment
+ *
+ * @return bool
+ */
+ public function hasAutoIncrement()
+ {
+ return strtolower($this->getExtra() ?: '') === 'auto_increment';
+ }
+
+ /**
+ * Checks whether or not the field has primary key
+ *
+ * @return bool
+ */
+ public function hasPrimaryKey()
+ {
+ return strtoupper($this->getKey()) === 'PRI';
+ }
+
+ /**
+ * Checks whether or not the field has unique key
+ *
+ * @return bool
+ */
+ public function hasUniqueKey()
+ {
+ return strtoupper($this->getKey()) === 'UNI';
+ }
+
+ /**
+ * Gets whether the field is required
+ *
+ * @return bool
+ */
+ public function isRequired()
+ {
+ return $this->attributes->get('required');
+ }
+
+ /**
+ * Gets the interface name
+ *
+ * @return string
+ */
+ public function getInterface()
+ {
+ return $this->attributes->get('interface');
+ }
+
+ /**
+ * Gets all or the given key options
+ *
+ * @param string|null $key
+ *
+ * @return mixed
+ */
+ public function getOptions($key = null)
+ {
+ $options = [];
+ if ($this->attributes->has('options')) {
+ $options = $this->attributes->get('options');
+ }
+
+ if ($key !== null && is_array($options)) {
+ $options = ArrayUtils::get($options, $key);
+ }
+
+ return $options;
+ }
+
+ /**
+ * Gets whether the field must be hidden in lists
+ *
+ * @return bool
+ */
+ public function isHiddenList()
+ {
+ return $this->attributes->get('hidden_list');
+ }
+
+ /**
+ * Gets whether the field must be hidden in forms
+ *
+ * @return bool
+ */
+ public function isHiddenInput()
+ {
+ return $this->attributes->get('hidden_input');
+ }
+
+ /**
+ * Gets the field comment
+ *
+ * @return null|string
+ */
+ public function getComment()
+ {
+ return $this->attributes->get('comment');
+ }
+
+ /**
+ * Gets the collection's name the field belongs to
+ *
+ * @return string
+ */
+ public function getCollectionName()
+ {
+ return $this->attributes->get('collection');
+ }
+
+ /**
+ * Checks whether the field is an alias
+ *
+ * @return bool
+ */
+ public function isAlias()
+ {
+ return DataTypes::isAliasType($this->getType());
+ }
+
+ /**
+ * Checks whether the field is a array type
+ *
+ * @return bool
+ */
+ public function isArray()
+ {
+ return strtoupper($this->getType()) === static::TYPE_ARRAY;
+ }
+
+ /**
+ * Checks whether the field is a json type
+ *
+ * @return bool
+ */
+ public function isJson()
+ {
+ return in_array(
+ strtoupper($this->getType()),
+ [
+ static::TYPE_JSON,
+ static::TYPE_TINY_JSON,
+ static::TYPE_MEDIUM_JSON,
+ static::TYPE_LONG_JSON
+ ]
+ );
+ }
+
+ /**
+ * Checks whether the field is a boolean type
+ *
+ * @return bool
+ */
+ public function isBoolean()
+ {
+ return in_array(
+ strtoupper($this->getType()),
+ [
+ strtoupper(DataTypes::TYPE_BOOLEAN),
+ strtoupper(DataTypes::TYPE_BOOL)
+ ]
+ );
+ }
+
+ /**
+ * Checks whether or not is a system field
+ *
+ * @return bool
+ */
+ public function isSystem()
+ {
+ return SystemInterface::isSystem($this->getInterface());
+ }
+
+ /**
+ * Checks whether this column is date system interface
+ *
+ * @return bool
+ */
+ public function isSystemDate()
+ {
+ return SystemInterface::isSystemDate($this->getInterface());
+ }
+
+ /**
+ * Set the column relationship
+ *
+ * @param FieldRelationship|array $relationship
+ *
+ * @return Field
+ */
+ public function setRelationship($relationship)
+ {
+ // Ignore relationship information if the field is primary key
+ if (!$this->hasPrimaryKey()) {
+ // Relationship can be pass as an array
+ if (!($relationship instanceof FieldRelationship)) {
+ $relationship = new FieldRelationship($this, $relationship);
+ }
+
+ $this->relationship = $relationship;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Gets the field relationship
+ *
+ * @return FieldRelationship
+ */
+ public function getRelationship()
+ {
+ return $this->relationship;
+ }
+
+ /**
+ * Checks whether the field has relationship
+ *
+ * @return bool
+ */
+ public function hasRelationship()
+ {
+ return $this->getRelationship() instanceof FieldRelationship;
+ }
+
+ /**
+ * Gets the field relationship type
+ *
+ * @return null|string
+ */
+ public function getRelationshipType()
+ {
+ $type = null;
+
+ if ($this->hasRelationship()) {
+ $type = $this->getRelationship()->getType();
+ }
+
+ return $type;
+ }
+
+ /**
+ * Checks whether the relationship is MANY TO ONE
+ *
+ * @return bool
+ */
+ public function isManyToOne()
+ {
+ return $this->hasRelationship() ? $this->getRelationship()->isManyToOne() : false;
+ }
+
+ /**
+ * Checks whether the relationship is MANY TO MANY
+ *
+ * @return bool
+ */
+ public function isManyToMany()
+ {
+ return $this->hasRelationship() ? $this->getRelationship()->isManyToMany() : false;
+ }
+
+ /**
+ * Checks whether the relationship is ONE TO MANY
+ *
+ * @return bool
+ */
+ public function isOneToMany()
+ {
+ return $this->hasRelationship() ? $this->getRelationship()->isOneToMany() : false;
+ }
+
+ /**
+ * Checks whether the field has ONE/MANY TO MANY Relationship
+ *
+ * @return bool
+ */
+ public function isToMany()
+ {
+ return $this->isOneToMany() || $this->isManyToMany();
+ }
+
+ /**
+ * Is the field being managed by Directus
+ *
+ * @return bool
+ */
+ public function isManaged()
+ {
+ return $this->attributes->get('managed') == 1;
+ }
+
+ /**
+ * @return array
+ */
+ public function toArray()
+ {
+ $attributes = parent::toArray();
+ $attributes['relationship'] = $this->hasRelationship() ? $this->getRelationship()->toArray() : null;
+
+ return $attributes;
+ }
+}
diff --git a/src/core/Directus/Database/Schema/Object/FieldRelationship.php b/src/core/Directus/Database/Schema/Object/FieldRelationship.php
new file mode 100644
index 0000000000..4b4680f978
--- /dev/null
+++ b/src/core/Directus/Database/Schema/Object/FieldRelationship.php
@@ -0,0 +1,233 @@
+fromField = $fromField;
+
+ parent::__construct($attributes);
+
+ if ($this->fromField->getName() === $this->attributes->get('field_b')) {
+ $this->attributes->replace(
+ $this->swapRelationshipAttributes($this->attributes->toArray())
+ );
+ }
+
+ $this->attributes->set('type', $this->guessType());
+ }
+
+ /**
+ * Gets the parent collection
+ *
+ * @return string
+ */
+ public function getCollectionA()
+ {
+ return $this->attributes->get('collection_a');
+ }
+
+ /**
+ * Gets the parent field
+ *
+ * @return string
+ */
+ public function getFieldA()
+ {
+ return $this->attributes->get('field_a');
+ }
+
+ /**
+ *
+ *
+ * @return null|string
+ */
+ public function getJunctionKeyA()
+ {
+ return $this->attributes->get('junction_key_a');
+ }
+
+ public function getJunctionCollection()
+ {
+ return $this->attributes->get('junction_collection');
+ }
+
+ public function getJunctionMixedCollections()
+ {
+ return $this->attributes->get('junction_mixed_collections');
+ }
+
+ public function getJunctionKeyB()
+ {
+ return $this->attributes->get('junction_key_b');
+ }
+
+ public function getCollectionB()
+ {
+ return $this->attributes->get('collection_b');
+ }
+
+ public function getFieldB()
+ {
+ return $this->attributes->get('field_b');
+ }
+
+ /**
+ * Checks whether the relationship has a valid type
+ *
+ * @return bool
+ */
+ public function isValid()
+ {
+ return $this->getType() !== null;
+ }
+
+ /**
+ * Gets the relationship type
+ *
+ * @return string|null
+ */
+ public function getType()
+ {
+ return $this->attributes->get('type');
+ }
+
+ /**
+ * Checks whether the relatiopship is MANY TO ONE
+ *
+ * @return bool
+ */
+ public function isManyToOne()
+ {
+ return $this->getType() === static::MANY_TO_ONE;
+ }
+
+ /**
+ * Checks whether the relatiopship is MANY TO MANY
+ *
+ * @return bool
+ */
+ public function isManyToMany()
+ {
+ return $this->getType() === static::MANY_TO_MANY;
+ }
+
+ /**
+ * Checks whether the relatiopship is ONE TO MANY
+ *
+ * @return bool
+ */
+ public function isOneToMany()
+ {
+ return $this->getType() === static::ONE_TO_MANY;
+ }
+
+ /**
+ * Checks whether is a many to many or one to many
+ *
+ * @return bool
+ */
+ public function isToMany()
+ {
+ return $this->isManyToMany() || $this->isOneToMany();
+ }
+
+ /**
+ * Guess the data type
+ *
+ * @return null|string
+ */
+ protected function guessType()
+ {
+ $fieldName = $this->fromField->getName();
+ $isAlias = $this->fromField->isAlias();
+ $type = null;
+
+ if (!$this->fromField) {
+ $type = null;
+ } else if (
+ !$isAlias &&
+ $this->getCollectionB() !== null &&
+ $this->getFieldA() === $fieldName &&
+ $this->getJunctionKeyA() === null &&
+ $this->getJunctionCollection() === null &&
+ $this->getJunctionKeyB() === null &&
+ $this->getJunctionMixedCollections() === null &&
+ $this->getCollectionB() !== null
+ // Can have or not this value depends if the backward (O2M) relationship is set
+ // $this->getFieldB() === null
+ ) {
+ $type = static::MANY_TO_ONE;
+ } else if (
+ $isAlias &&
+ $this->getCollectionB() !== null &&
+ $this->getFieldA() === $fieldName &&
+ $this->getJunctionKeyA() === null &&
+ $this->getJunctionCollection() === null &&
+ $this->getJunctionKeyB() === null &&
+ $this->getJunctionMixedCollections() === null &&
+ $this->getCollectionB() !== null &&
+ $this->getFieldB() !== null
+ ) {
+ $type = static::ONE_TO_MANY;
+ } else if (
+ $isAlias &&
+ $this->getCollectionB() !== null &&
+ $this->getFieldA() === $fieldName &&
+ $this->getJunctionKeyA() !== null &&
+ $this->getJunctionCollection() !== null &&
+ $this->getJunctionKeyB() !== null &&
+ $this->getJunctionMixedCollections() === null &&
+ $this->getCollectionB() !== null
+ // $this->getFieldB() !== null
+ ) {
+ $type = static::MANY_TO_MANY;
+ }
+
+ return $type;
+ }
+
+ /**
+ * Change the direction of the relationship
+ *
+ * @param array $attributes
+ *
+ * @return array
+ */
+ protected function swapRelationshipAttributes(array $attributes)
+ {
+ $newAttributes = [
+ 'collection_a' => ArrayUtils::get($attributes, 'collection_b'),
+ 'field_a' => ArrayUtils::get($attributes, 'field_b'),
+ 'junction_key_a' => ArrayUtils::get($attributes, 'junction_key_b'),
+ 'junction_collection' => ArrayUtils::get($attributes, 'junction_collection'),
+ 'junction_mixed_collections' => ArrayUtils::get($attributes, 'junction_mixed_collections'),
+ 'junction_key_b' => ArrayUtils::get($attributes, 'junction_key_a'),
+ 'collection_b' => ArrayUtils::get($attributes, 'collection_a'),
+ 'field_b' => ArrayUtils::get($attributes, 'field_a'),
+ ];
+
+ return $newAttributes;
+ }
+}
diff --git a/src/core/Directus/Database/Schema/SchemaFactory.php b/src/core/Directus/Database/Schema/SchemaFactory.php
new file mode 100644
index 0000000000..2ac324b8ee
--- /dev/null
+++ b/src/core/Directus/Database/Schema/SchemaFactory.php
@@ -0,0 +1,427 @@
+schemaManager = $manager;
+ $this->validator = new Validator();
+ }
+
+ /**
+ * Create a new table
+ *
+ * @param string $name
+ * @param array $columnsData
+ *
+ * @return CreateTable
+ */
+ public function createTable($name, array $columnsData = [])
+ {
+ $table = new CreateTable($name);
+
+ // $columnsData = $this->mergeDefaultColumnsData($columnsData);
+ $columns = $this->createColumns($columnsData);
+
+ foreach ($columnsData as $column) {
+ if (SystemInterface::INTERFACE_PRIMARY_KEY === $column['interface']) {
+ $table->addConstraint(new PrimaryKey($column['field']));
+ break;
+ }
+ }
+
+ foreach ($columns as $column) {
+ $table->addColumn($column);
+ }
+
+ return $table;
+ }
+
+ /**
+ * Alter an existing table
+ *
+ * @param $name
+ * @param array $data
+ *
+ * @return AlterTable
+ */
+ public function alterTable($name, array $data)
+ {
+ $table = new AlterTable($name);
+
+ $toAddColumnsData = ArrayUtils::get($data, 'add', []);
+ $toAddColumns = $this->createColumns($toAddColumnsData);
+ foreach ($toAddColumns as $column) {
+ $table->addColumn($column);
+ }
+
+ $toChangeColumnsData = ArrayUtils::get($data, 'change', []);
+ $toChangeColumns = $this->createColumns($toChangeColumnsData);
+ foreach ($toChangeColumns as $column) {
+ $table->changeColumn($column->getName(), $column);
+ }
+
+ $toDropColumnsName = ArrayUtils::get($data, 'drop', []);
+ foreach ($toDropColumnsName as $column) {
+ $table->dropColumn($column);
+ }
+
+ return $table;
+ }
+
+ /**
+ * @param array $data
+ *
+ * @return Column[]
+ */
+ public function createColumns(array $data)
+ {
+ $columns = [];
+ foreach ($data as $column) {
+ if (!DataTypes::isAliasType(ArrayUtils::get($column, 'type'))) {
+ $columns[] = $this->createColumn(ArrayUtils::get($column, 'field'), $column);
+ }
+ }
+
+ return $columns;
+ }
+
+ /**
+ * @param string $name
+ * @param array $data
+ *
+ * @return Column
+ */
+ public function createColumn($name, array $data)
+ {
+ $this->validate($data);
+ $type = $this->schemaManager->getDataType(ArrayUtils::get($data, 'type'));
+ $interface = ArrayUtils::get($data, 'interface');
+ $autoincrement = ArrayUtils::get($data, 'auto_increment', false);
+ $length = ArrayUtils::get($data, 'length', $this->schemaManager->getFieldDefaultLength($type));
+ $nullable = ArrayUtils::get($data, 'nullable', true);
+ $default = ArrayUtils::get($data, 'default_value', null);
+ $unsigned = ArrayUtils::get($data, 'unsigned', false);
+ // TODO: Make comment work in an abstract level
+ // ZendDB doesn't support charset nor comment
+ // $comment = ArrayUtils::get($data, 'comment');
+
+ $column = $this->createColumnFromType($name, $type);
+ $column->setNullable($nullable);
+ $column->setDefault($default);
+
+ // CollectionLength are SET or ENUM data type
+ if ($column instanceof AbstractLengthColumn || $column instanceof CollectionLength) {
+ $column->setLength($length);
+ } else {
+ $column->setOption('length', $length);
+ }
+
+ // Only works for integers
+ if ($column instanceof Integer) {
+ $column->setOption('autoincrement', $autoincrement);
+ $column->setOption('unsigned', $unsigned);
+ }
+
+ return $column;
+ }
+
+ /**
+ * Creates the given table
+ *
+ * @param AbstractSql|AlterTable|CreateTable $table
+ *
+ * @return \Zend\Db\Adapter\Driver\StatementInterface|\Zend\Db\ResultSet\ResultSet
+ */
+ public function buildTable(AbstractSql $table)
+ {
+ $connection = $this->schemaManager->getSource()->getConnection();
+ $sql = new Sql($connection);
+
+ // TODO: Allow charset and comment
+ return $connection->query(
+ $sql->buildSqlString($table),
+ $connection::QUERY_MODE_EXECUTE
+ );
+ }
+
+ /**
+ * Creates column based on type
+ *
+ * @param $name
+ * @param $type
+ *
+ * @return Column
+ *
+ * @throws UnknownDataTypeException
+ */
+ protected function createColumnFromType($name, $type)
+ {
+ switch (strtolower($type)) {
+ case DataTypes::TYPE_CHAR:
+ $column = new Char($name);
+ break;
+ case DataTypes::TYPE_VARCHAR:
+ $column = new Varchar($name);
+ break;
+ case DataTypes::TYPE_TINY_JSON:
+ case DataTypes::TYPE_TINY_TEXT:
+ $column = new LongText($name);
+ break;
+ case DataTypes::TYPE_JSON:
+ case DataTypes::TYPE_TEXT:
+ $column = new Text($name);
+ break;
+ case DataTypes::TYPE_MEDIUM_JSON:
+ case DataTypes::TYPE_MEDIUM_TEXT:
+ $column = new MediumText($name);
+ break;
+ case DataTypes::TYPE_LONG_JSON:
+ case DataTypes::TYPE_LONGTEXT:
+ $column = new LongText($name);
+ break;
+ case DataTypes::TYPE_UUID:
+ $column = new Uuid($name);
+ break;
+ case DataTypes::TYPE_CSV:
+ case DataTypes::TYPE_ARRAY:
+ $column = new Varchar($name);
+ break;
+
+ case DataTypes::TYPE_TIME:
+ $column = new Time($name);
+ break;
+ case DataTypes::TYPE_DATE:
+ $column = new Date($name);
+ break;
+ case DataTypes::TYPE_DATETIME:
+ $column = new Datetime($name);
+ break;
+ case DataTypes::TYPE_TIMESTAMP:
+ $column = new Timestamp($name);
+ break;
+
+ case DataTypes::TYPE_TINY_INT:
+ $column = new TinyInteger($name);
+ break;
+ case DataTypes::TYPE_SMALL_INT:
+ $column = new SmallInteger($name);
+ break;
+ case DataTypes::TYPE_INTEGER:
+ case DataTypes::TYPE_INT:
+ $column = new Integer($name);
+ break;
+ case DataTypes::TYPE_MEDIUM_INT:
+ $column = new MediumInteger($name);
+ break;
+ case DataTypes::TYPE_BIG_INT:
+ $column = new BigInteger($name);
+ break;
+ case DataTypes::TYPE_SERIAL:
+ $column = new Serial($name);
+ break;
+ case DataTypes::TYPE_FLOAT:
+ $column = new Floating($name);
+ break;
+ case DataTypes::TYPE_DOUBLE:
+ $column = new Double($name);
+ break;
+ case DataTypes::TYPE_DECIMAL:
+ $column = new Decimal($name);
+ break;
+ case DataTypes::TYPE_REAL:
+ $column = new Real($name);
+ break;
+ case DataTypes::TYPE_NUMERIC:
+ case DataTypes::TYPE_CURRENCY:
+ $column = new Numeric($name);
+ break;
+ case DataTypes::TYPE_BIT:
+ $column = new Bit($name);
+ break;
+ case DataTypes::TYPE_BOOL:
+ case DataTypes::TYPE_BOOLEAN:
+ $column = new Boolean($name);
+ break;
+
+ case DataTypes::TYPE_BINARY:
+ $column = new Binary($name);
+ break;
+ case DataTypes::TYPE_VARBINARY:
+ $column = new Varbinary($name);
+ break;
+ case DataTypes::TYPE_TINY_BLOB:
+ $column = new TinyBlob($name);
+ break;
+ case DataTypes::TYPE_BLOB:
+ $column = new Blob($name);
+ break;
+ case DataTypes::TYPE_MEDIUM_BLOB:
+ $column = new MediumBlob($name);
+ break;
+ case DataTypes::TYPE_LONG_BLOB:
+ $column = new LongBlob($name);
+ break;
+
+ case DataTypes::TYPE_SET:
+ $column = new Set($name);
+ break;
+ case DataTypes::TYPE_ENUM:
+ $column = new Enum($name);
+ break;
+
+ case DataTypes::TYPE_FILE:
+ $column = new File($name);
+ break;
+
+ default:
+ throw new UnknownDataTypeException($type);
+ break;
+ }
+
+ return $column;
+ }
+
+ /**
+ * @param array $columns
+ *
+ * @return array
+ */
+ protected function mergeDefaultColumnsData(array $columns)
+ {
+ if (!$this->hasPrimaryKey($columns)) {
+ array_unshift($columns, [
+ 'field' => 'id',
+ 'type' => 'INTEGER',
+ 'interface' => 'primary_key'
+ ]);
+ }
+
+ return $columns;
+ }
+
+ /**
+ * Whether the columns data array has a primary key
+ *
+ * @param array $columns
+ *
+ * @return bool
+ */
+ protected function hasPrimaryKey(array $columns)
+ {
+ $has = false;
+ foreach ($columns as $column) {
+ $interface = ArrayUtils::get($column, 'interface');
+ if ($this->schemaManager->isPrimaryKeyInterface($interface)) {
+ $has = true;
+ break;
+ }
+ }
+
+ return $has;
+ }
+
+ /**
+ * @param array $columnData
+ *
+ * @throws InvalidRequestException
+ */
+ protected function validate(array $columnData)
+ {
+ $constraints = [
+ 'field' => ['required', 'string'],
+ 'type' => ['required', 'string'],
+ 'interface' => ['required', 'string']
+ ];
+
+ // Copied from route
+ // TODO: Route needs a restructure to get the utils code like this shared
+ $violations = [];
+ $data = ArrayUtils::pick($columnData, array_keys($constraints));
+ foreach (array_keys($constraints) as $field) {
+ $violations[$field] = $this->validator->validate(ArrayUtils::get($data, $field), $constraints[$field]);
+ }
+
+ $messages = [];
+ /** @var ConstraintViolationList $violation */
+ foreach ($violations as $field => $violation) {
+ $iterator = $violation->getIterator();
+
+ $errors = [];
+ while ($iterator->valid()) {
+ $constraintViolation = $iterator->current();
+ $errors[] = $constraintViolation->getMessage();
+ $iterator->next();
+ }
+
+ if ($errors) {
+ $messages[] = sprintf('%s: %s', $field, implode(', ', $errors));
+ }
+ }
+
+ if (count($messages) > 0) {
+ throw new InvalidRequestException(implode(' ', $messages));
+ }
+ }
+}
diff --git a/src/core/Directus/Database/Schema/SchemaManager.php b/src/core/Directus/Database/Schema/SchemaManager.php
new file mode 100644
index 0000000000..d26c58ca4e
--- /dev/null
+++ b/src/core/Directus/Database/Schema/SchemaManager.php
@@ -0,0 +1,687 @@
+source = $source;
+ }
+
+ /**
+ * Adds a primary key to the given column
+ *
+ * @param $table
+ * @param $column
+ *
+ * @return bool
+ */
+ public function addPrimaryKey($table, $column)
+ {
+ return $this->source->addPrimaryKey($table, $column);
+ }
+
+ /**
+ * Removes the primary key of the given column
+ *
+ * @param $table
+ * @param $column
+ *
+ * @return bool
+ */
+ public function dropPrimaryKey($table, $column)
+ {
+ return $this->source->dropPrimaryKey($table, $column);
+ }
+
+ /**
+ * Get the table schema information
+ *
+ * @param string $tableName
+ * @param array $params
+ * @param bool $skipCache
+ *
+ * @throws CollectionNotFoundException
+ *
+ * @return \Directus\Database\Schema\Object\Collection
+ */
+ public function getCollection($collectionName, $params = [], $skipCache = false)
+ {
+ $collection = ArrayUtils::get($this->data, 'collections.' . $collectionName, null);
+ if (!$collection || $skipCache) {
+ // Get the table schema data from the source
+ $collectionResult = $this->source->getCollection($collectionName);
+ $collectionData = $collectionResult->current();
+
+ if (!$collectionData) {
+ throw new CollectionNotFoundException($collectionName);
+ }
+
+ // Create a table object based of the table schema data
+ $collection = $this->createCollectionFromArray(array_merge($collectionData, [
+ 'schema' => $this->source->getSchemaName()
+ ]));
+ $this->addCollection($collectionName, $collection);
+ }
+
+ // =============================================================================
+ // Set table columns
+ // -----------------------------------------------------------------------------
+ // @TODO: Do not allow to add duplicate column names
+ // =============================================================================
+ if (empty($collection->getFields())) {
+ $fields = $this->getFields($collectionName);
+ $collection->setFields($fields);
+ }
+
+ return $collection;
+ }
+
+ /**
+ * Gets column schema
+ *
+ * @param $tableName
+ * @param $columnName
+ * @param bool $skipCache
+ *
+ * @return Field
+ */
+ public function getField($tableName, $columnName, $skipCache = false)
+ {
+ $columnSchema = ArrayUtils::get($this->data, 'fields.' . $tableName . '.' . $columnName, null);
+
+ if (!$columnSchema || $skipCache) {
+ // Get the column schema data from the source
+ $columnResult = $this->source->getFields($tableName, ['column_name' => $columnName]);
+ $columnData = $columnResult->current();
+
+ // Create a column object based of the table schema data
+ $columnSchema = $this->createFieldFromArray($columnData);
+ $this->addField($columnSchema);
+ }
+
+ return $columnSchema;
+ }
+
+ /**
+ * Add the system table prefix to to a table name.
+ *
+ * @param string|array $names
+ *
+ * @return array
+ */
+ public function addSystemCollectionPrefix($names)
+ {
+ if (!is_array($names)) {
+ $names = [$names];
+ }
+
+ return array_map(function ($name) {
+ // TODO: Directus tables prefix _probably_ will be dynamic
+ return $this->prefix . $name;
+ }, $names);
+ }
+
+ /**
+ * Get Directus System tables name
+ *
+ * @return array
+ */
+ public function getSystemCollections()
+ {
+ return $this->addSystemCollectionPrefix($this->directusTables);
+ }
+
+ /**
+ * Check if the given name is a system table
+ *
+ * @param $name
+ *
+ * @return bool
+ */
+ public function isSystemCollection($name)
+ {
+ return in_array($name, $this->getSystemCollections());
+ }
+
+ /**
+ * Check if a table name exists
+ *
+ * @param $tableName
+ * @return bool
+ */
+ public function tableExists($tableName)
+ {
+ return $this->source->collectionExists($tableName);
+ }
+
+ /**
+ * Gets list of table
+ *
+ * @param array $params
+ *
+ * @return Collection[]
+ */
+ public function getCollections(array $params = [])
+ {
+ // TODO: Filter should be outsite
+ // $schema = Bootstrap::get('schema');
+ // $config = Bootstrap::get('config');
+
+ // $ignoredTables = static::getDirectusTables(DirectusPreferencesTableGateway::$IGNORED_TABLES);
+ // $blacklistedTable = $config['tableBlacklist'];
+ // array_merge($ignoredTables, $blacklistedTable)
+ $collections = $this->source->getCollections();
+
+ $tables = [];
+ foreach ($collections as $collection) {
+ // Create a table object based of the table schema data
+ $tableSchema = $this->createCollectionFromArray(array_merge($collection, [
+ 'schema' => $this->source->getSchemaName()
+ ]));
+ $tableName = $tableSchema->getName();
+ $this->addCollection($tableName, $tableSchema);
+
+ $tables[$tableName] = $tableSchema;
+ }
+
+ return $tables;
+ }
+
+ /**
+ * Get all columns in the given table name
+ *
+ * @param $tableName
+ * @param array $params
+ *
+ * @return \Directus\Database\Schema\Object\Field[]
+ */
+ public function getFields($tableName, $params = [])
+ {
+ // TODO: filter black listed fields on services level
+
+ $columnsSchema = ArrayUtils::get($this->data, 'columns.' . $tableName, null);
+ if (!$columnsSchema) {
+ $columnsResult = $this->source->getFields($tableName, $params);
+ $relationsResult = $this->source->getRelations($tableName);
+
+ // TODO: Improve this logic
+ $relationsA = [];
+ $relationsB = [];
+ foreach ($relationsResult as $relation) {
+ $relationsA[$relation['field_a']] = $relation;
+
+ if (isset($relation['field_b'])) {
+ $relationsB[$relation['field_b']] = $relation;
+ }
+ }
+
+ $columnsSchema = [];
+ foreach ($columnsResult as $column) {
+ $field = $this->createFieldFromArray($column);
+
+ // Set all FILE data type related to directus files (M2O)
+ if (DataTypes::isFilesType($field->getType())) {
+ $field->setRelationship([
+ 'collection_a' => $field->getCollectionName(),
+ 'field_a' => $field->getName(),
+ 'collection_b' => static::COLLECTION_FILES,
+ 'field_b' => 'id'
+ ]);
+ } else if (array_key_exists($field->getName(), $relationsA)) {
+ $field->setRelationship($relationsA[$field->getName()]);
+ } else if (array_key_exists($field->getName(), $relationsB)) {
+ $field->setRelationship($relationsB[$field->getName()]);
+ }
+
+ $columnsSchema[] = $field;
+ }
+
+ $this->data['columns'][$tableName] = $columnsSchema;
+ }
+
+ return $columnsSchema;
+ }
+
+ public function getFieldsName($tableName)
+ {
+ $columns = $this->getFields($tableName);
+
+ $columnNames = [];
+ foreach ($columns as $column) {
+ $columnNames[] = $column->getName();
+ }
+
+ return $columnNames;
+ }
+
+ /**
+ * Get all the columns
+ *
+ * @return Field[]
+ */
+ public function getAllFields()
+ {
+ $allColumns = $this->source->getAllFields();
+
+ $columns = [];
+ foreach($allColumns as $column) {
+ $columns[] = $this->createFieldFromArray($column);
+ }
+
+ return $columns;
+ }
+
+ /**
+ * Get a list of columns table grouped by table name
+ *
+ * @return array
+ */
+ public function getAllFieldsByCollection()
+ {
+ $fields = [];
+ foreach ($this->getAllFields() as $field) {
+ $collectionName = $field->getCollectionName();
+ if (!isset($fields[$collectionName])) {
+ $fields[$collectionName] = [];
+ }
+
+ $columns[$collectionName][] = $field;
+ }
+
+ return $fields;
+ }
+
+ public function getPrimaryKey($tableName)
+ {
+ $collection = $this->getCollection($tableName);
+ if ($collection) {
+ return $collection->getPrimaryKeyName();
+ }
+
+ return false;
+ }
+
+ public function hasSystemDateField($tableName)
+ {
+ $tableObject = $this->getCollection($tableName);
+
+ return $tableObject->getDateCreateField() || $tableObject->getDateUpdateField();
+ }
+
+ public function castRecordValues($records, $columns)
+ {
+ return $this->source->castRecordValues($records, $columns);
+ }
+
+ /**
+ * Cast value against a database type
+ *
+ * NOTE: it only works with MySQL data types
+ *
+ * @param $value
+ * @param $type
+ * @param $length
+ *
+ * @return mixed
+ */
+ public function castValue($value, $type = null, $length = false)
+ {
+ return $this->source->castValue($value, $type, $length);
+ }
+
+ /**
+ * Checks whether the given type is numeric type
+ *
+ * @param $type
+ *
+ * @return bool
+ */
+ public function isNumericType($type)
+ {
+ return DataTypes::isNumericType($type);
+ }
+
+ /**
+ * Checks whether the given type is string type
+ *
+ * @param $type
+ *
+ * @return bool
+ */
+ public function isStringType($type)
+ {
+ return DataTypes::isStringType($type);
+ }
+
+ /**
+ * Checks whether the given type is integer type
+ *
+ * @param $type
+ *
+ * @return bool
+ */
+ public function isIntegerType($type)
+ {
+ return DataTypes::isIntegerType($type);
+ }
+
+ /**
+ * Checks whether the given type is decimal type
+ *
+ * @param $type
+ *
+ * @return bool
+ */
+ public function isFloatingPointType($type)
+ {
+ return static::isFloatingPointType($type);
+ }
+
+ /**
+ * Cast default value
+ *
+ * @param $value
+ * @param $type
+ * @param $length
+ *
+ * @return mixed
+ */
+ public function castDefaultValue($value, $type, $length = null)
+ {
+ if (strtolower($value) === 'null') {
+ $value = null;
+ } else {
+ $value = $this->castValue($value, $type, $length);
+ }
+
+ return $value;
+ }
+
+ /**
+ * Get all Directus system tables name
+ *
+ * @param array $filterNames
+ *
+ * @return array
+ */
+ public function getDirectusCollections(array $filterNames = [])
+ {
+ $tables = $this->directusTables;
+ if ($filterNames) {
+ foreach ($tables as $i => $table) {
+ if (!in_array($table, $filterNames)) {
+ unset($tables[$i]);
+ }
+ }
+ }
+
+ return $this->addSystemCollectionPrefix($tables);
+ }
+
+ /**
+ * Check if a given table is a directus system table name
+ *
+ * @param $tableName
+ *
+ * @return bool
+ */
+ public function isDirectusCollection($tableName)
+ {
+ return in_array($tableName, $this->getDirectusCollections());
+ }
+
+ /**
+ * Get the schema adapter
+ *
+ * @return SchemaInterface
+ */
+ public function getSchema()
+ {
+ return $this->source;
+ }
+
+ /**
+ * List of supported databases
+ *
+ * @return array
+ */
+ public static function getSupportedDatabases()
+ {
+ return [
+ 'mysql' => [
+ 'id' => 'mysql',
+ 'name' => 'MySQL/Percona'
+ ],
+ ];
+ }
+
+ public static function getTemplates()
+ {
+ // @TODO: SchemaManager shouldn't be a class with static methods anymore
+ // the UI templates list will be provided by a container or bootstrap.
+ $path = implode(DIRECTORY_SEPARATOR, [
+ base_path(),
+ 'api',
+ 'migrations',
+ 'templates',
+ '*'
+ ]);
+
+ $templatesDirs = glob($path, GLOB_ONLYDIR);
+ $templatesData = [];
+ foreach ($templatesDirs as $dir) {
+ $key = basename($dir);
+ $templatesData[$key] = [
+ 'id' => $key,
+ 'name' => uc_convert($key)
+ ];
+ }
+
+ return $templatesData;
+ }
+
+ /**
+ * Gets a collection object from an array attributes data
+ * @param $data
+ *
+ * @return Collection
+ */
+ public function createCollectionFromArray($data)
+ {
+ return new Collection($data);
+ }
+
+ /**
+ * Creates a column object from the given array
+ *
+ * @param array $column
+ *
+ * @return Field
+ */
+ public function createFieldFromArray($column)
+ {
+ // PRIMARY KEY must be required
+ if ($column['key'] === 'PRI') {
+ $column['required'] = true;
+ }
+
+ $options = json_decode(isset($column['options']) ? $column['options'] : '', true);
+ $column['options'] = $options ? $options : null;
+
+ // NOTE: Alias column must are nullable
+ if (strtoupper($column['type']) === 'ALIAS') {
+ $column['nullable'] = 1;
+ }
+
+ // NOTE: MariaDB store "NULL" as a string on some data types such as VARCHAR.
+ // We reserved the word "NULL" on nullable data type to be actually null
+ if ($column['nullable'] === 1 && $column['default_value'] == 'NULL') {
+ $column['default_value'] = null;
+ }
+
+ return new Field($column);
+ }
+
+ /**
+ * Checks whether the interface is a system interface
+ *
+ * @param $interface
+ *
+ * @return bool
+ */
+ public function isSystemField($interface)
+ {
+ return SystemInterface::isSystem($interface);
+ }
+
+ /**
+ * Checks whether the interface is primary key interface
+ *
+ * @param $interface
+ *
+ * @return bool
+ */
+ public function isPrimaryKeyInterface($interface)
+ {
+ return $interface === SystemInterface::INTERFACE_PRIMARY_KEY;
+ }
+
+ protected function addCollection($name, $schema)
+ {
+ // save the column into the data
+ // @NOTE: this is the early implementation of cache
+ // soon this will be change to cache
+ $this->data['tables'][$name] = $schema;
+ }
+
+ protected function addField(Field $column)
+ {
+ $tableName = $column->getCollectionName();
+ $columnName = $column->getName();
+ $this->data['fields'][$tableName][$columnName] = $column;
+ }
+
+ /**
+ * Gets the data types default interfaces
+ *
+ * @return array
+ */
+ public function getDefaultInterfaces()
+ {
+ return $this->source->getDefaultInterfaces();
+ }
+
+ /**
+ * Gets the given data type default interface
+ *
+ * @param $type
+ *
+ * @return string
+ */
+ public function getFieldDefaultInterface($type)
+ {
+ return $this->source->getColumnDefaultInterface($type);
+ }
+
+ /**
+ *
+ *
+ * @param $type
+ *
+ * @return integer
+ */
+ public function getFieldDefaultLength($type)
+ {
+ return $this->source->getColumnDefaultLength($type);
+ }
+
+ /**
+ * Gets the column type based the schema adapter
+ *
+ * @param string $type
+ *
+ * @return string
+ */
+ public function getDataType($type)
+ {
+ return $this->source->getDataType($type);
+ }
+
+ /**
+ * Gets the source schema adapter
+ *
+ * @return SchemaInterface
+ */
+ public function getSource()
+ {
+ return $this->source;
+ }
+}
diff --git a/src/core/Directus/Database/Schema/Sources/AbstractSchema.php b/src/core/Directus/Database/Schema/Sources/AbstractSchema.php
new file mode 100644
index 0000000000..10c1c759de
--- /dev/null
+++ b/src/core/Directus/Database/Schema/Sources/AbstractSchema.php
@@ -0,0 +1,189 @@
+ $record) {
+ $fieldName = $field->getName();
+ if (ArrayUtils::has($record, $fieldName)) {
+ $records[$index][$fieldName] = $this->castValue($record[$fieldName], $field->getType());
+ }
+ }
+ }
+
+ return $singleRecord ? reset($records) : $records;
+ }
+
+ /**
+ * Parse records value by its column data type
+ *
+ * @see AbastractSchema::castRecordValues
+ *
+ * @param array $records
+ * @param $columns
+ *
+ * @return array
+ */
+ public function parseRecordValuesByType(array $records, $columns)
+ {
+ return $this->castRecordValues($records, $columns);
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getDefaultInterfaces()
+ {
+ return [
+ 'ALIAS' => static::INTERFACE_ALIAS,
+ 'MANYTOMANY' => static::INTERFACE_ALIAS,
+ 'ONETOMANY' => static::INTERFACE_ALIAS,
+
+ 'BIT' => static::INTERFACE_TOGGLE,
+ 'TINYINT' => static::INTERFACE_TOGGLE,
+
+ 'MEDIUMBLOB' => static::INTERFACE_BLOB,
+ 'BLOB' => static::INTERFACE_BLOB,
+
+ 'TINYTEXT' => static::INTERFACE_TEXT_AREA,
+ 'TEXT' => static::INTERFACE_TEXT_AREA,
+ 'MEDIUMTEXT' => static::INTERFACE_TEXT_AREA,
+ 'LONGTEXT' => static::INTERFACE_TEXT_AREA,
+
+ 'CHAR' => static::INTERFACE_TEXT_INPUT,
+ 'VARCHAR' => static::INTERFACE_TEXT_INPUT,
+ 'POINT' => static::INTERFACE_TEXT_INPUT,
+
+ 'DATETIME' => static::INTERFACE_DATETIME,
+ 'TIMESTAMP' => static::INTERFACE_DATETIME,
+
+ 'DATE' => static::INTERFACE_DATE,
+
+ 'TIME' => static::INTERFACE_TIME,
+
+ 'YEAR' => static::INTERFACE_NUMERIC,
+ 'SMALLINT' => static::INTERFACE_NUMERIC,
+ 'MEDIUMINT' => static::INTERFACE_NUMERIC,
+ 'INT' => static::INTERFACE_NUMERIC,
+ 'INTEGER' => static::INTERFACE_NUMERIC,
+ 'BIGINT' => static::INTERFACE_NUMERIC,
+ 'FLOAT' => static::INTERFACE_NUMERIC,
+ 'DOUBLE' => static::INTERFACE_NUMERIC,
+ 'DECIMAL' => static::INTERFACE_NUMERIC,
+ ];
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getDefaultLengths()
+ {
+ return [
+ // 'ALIAS' => static::INTERFACE_ALIAS,
+ // 'MANYTOMANY' => static::INTERFACE_ALIAS,
+ // 'ONETOMANY' => static::INTERFACE_ALIAS,
+
+ // 'BIT' => static::INTERFACE_TOGGLE,
+ // 'TINYINT' => static::INTERFACE_TOGGLE,
+
+ // 'MEDIUMBLOB' => static::INTERFACE_BLOB,
+ // 'BLOB' => static::INTERFACE_BLOB,
+
+ // 'TINYTEXT' => static::INTERFACE_TEXT_AREA,
+ // 'TEXT' => static::INTERFACE_TEXT_AREA,
+ // 'MEDIUMTEXT' => static::INTERFACE_TEXT_AREA,
+ // 'LONGTEXT' => static::INTERFACE_TEXT_AREA,
+
+ 'CHAR' => 1,
+ 'VARCHAR' => 255,
+ // 'POINT' => static::INTERFACE_TEXT_INPUT,
+
+ // 'DATETIME' => static::INTERFACE_DATETIME,
+ // 'TIMESTAMP' => static::INTERFACE_DATETIME,
+
+ // 'DATE' => static::INTERFACE_DATE,
+
+ // 'TIME' => static::INTERFACE_TIME,
+
+ // 'YEAR' => static::INTERFACE_NUMERIC,
+ // 'SMALLINT' => static::INTERFACE_NUMERIC,
+ // 'MEDIUMINT' => static::INTERFACE_NUMERIC,
+ 'INT' => 11,
+ 'INTEGER' => 11,
+ // 'BIGINT' => static::INTERFACE_NUMERIC,
+ // 'FLOAT' => static::INTERFACE_NUMERIC,
+ // 'DOUBLE' => static::INTERFACE_NUMERIC,
+ // 'DECIMAL' => static::INTERFACE_NUMERIC,
+ ];
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getColumnDefaultInterface($type)
+ {
+ return ArrayUtils::get($this->getDefaultInterfaces(), strtoupper($type), static::INTERFACE_TEXT_INPUT);
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getColumnDefaultLength($type)
+ {
+ return ArrayUtils::get($this->getDefaultLengths(), strtoupper($type), null);
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function isType($type, array $list)
+ {
+ return in_array(strtolower($type), $list);
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getDataType($type)
+ {
+ switch (strtolower($type)) {
+ case 'array':
+ case 'json':
+ $type = 'text';
+ break;
+ case 'tinyjson':
+ $type = 'tinytext';
+ break;
+ case 'mediumjson':
+ $type = 'mediumtext';
+ break;
+ case 'longjson':
+ $type = 'longtext';
+ break;
+ }
+
+ return $type;
+ }
+}
diff --git a/src/core/Directus/Database/Schema/Sources/MySQLSchema.php b/src/core/Directus/Database/Schema/Sources/MySQLSchema.php
new file mode 100644
index 0000000000..045373badc
--- /dev/null
+++ b/src/core/Directus/Database/Schema/Sources/MySQLSchema.php
@@ -0,0 +1,563 @@
+adapter = $adapter;
+ }
+
+ /**
+ * Get the schema name
+ *
+ * @return string
+ */
+ public function getSchemaName()
+ {
+ return $this->adapter->getCurrentSchema();
+ }
+
+ /**
+ * @return \Zend\DB\Adapter\Adapter
+ */
+ public function getConnection()
+ {
+ return $this->adapter;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getCollections(array $params = [])
+ {
+ $select = new Select();
+ $select->columns([
+ 'collection' => 'TABLE_NAME',
+ 'date_created' => 'CREATE_TIME',
+ 'collation' => 'TABLE_COLLATION',
+ 'schema_comment' => 'TABLE_COMMENT'
+ ]);
+ $select->from(['ST' => new TableIdentifier('TABLES', 'INFORMATION_SCHEMA')]);
+ $select->join(
+ ['DT' => 'directus_collections'],
+ 'DT.collection = ST.TABLE_NAME',
+ [
+ 'comment',
+ 'hidden' => new Expression('IFNULL(`DT`.`hidden`, 0)'),
+ 'single' => new Expression('IFNULL(`DT`.`single`, 0)'),
+ 'item_name_template',
+ 'preview_url',
+ 'managed' => new Expression('IF(ISNULL(`DT`.`collection`), 0, 1)')
+ ],
+ $select::JOIN_LEFT
+ );
+
+ $condition = [
+ 'ST.TABLE_SCHEMA' => $this->adapter->getCurrentSchema(),
+ 'ST.TABLE_TYPE' => 'BASE TABLE'
+ ];
+
+ $select->where($condition);
+ if (isset($params['name'])) {
+ $tableName = $params['name'];
+ // hotfix: This solve the problem fetching a table with capital letter
+ $where = $select->where->nest();
+ $where->equalTo('ST.TABLE_NAME', $tableName);
+ $where->OR;
+ $where->equalTo('ST.TABLE_NAME', $tableName);
+ $where->unnest();
+ }
+
+ $sql = new Sql($this->adapter);
+ $statement = $sql->prepareStatementForSqlObject($select);
+ $result = $statement->execute();
+
+ return $result;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function collectionExists($collectionsName)
+ {
+ if (is_string($collectionsName)) {
+ $collectionsName = [$collectionsName];
+ }
+
+ $select = new Select();
+ $select->columns(['TABLE_NAME']);
+ $select->from(['T' => new TableIdentifier('TABLES', 'INFORMATION_SCHEMA')]);
+ $select->where([
+ new In('T.TABLE_NAME', $collectionsName),
+ 'T.TABLE_SCHEMA' => $this->adapter->getCurrentSchema()
+ ]);
+
+ $sql = new Sql($this->adapter);
+ $statement = $sql->prepareStatementForSqlObject($select);
+ $result = $statement->execute();
+
+ return $result->count() ? true : false;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getCollection($collectionName)
+ {
+ return $this->getCollections(['name' => $collectionName]);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getFields($tableName, $params = null)
+ {
+ return $this->getAllFields(['collection' => $tableName]);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getAllFields(array $params = [])
+ {
+ $selectOne = new Select();
+ // $selectOne->quantifier($selectOne::QUANTIFIER_DISTINCT);
+ $selectOne->columns([
+ 'collection' => 'TABLE_NAME',
+ 'field' => 'COLUMN_NAME',
+ 'sort' => new Expression('IFNULL(DF.sort, SF.ORDINAL_POSITION)'),
+ 'original_type' => new Expression('UCASE(SF.DATA_TYPE)'),
+ 'key' => 'COLUMN_KEY',
+ 'extra' => 'EXTRA',
+ 'char_length' => 'CHARACTER_MAXIMUM_LENGTH',
+ 'precision' => 'NUMERIC_PRECISION',
+ 'scale' => 'NUMERIC_SCALE',
+ 'nullable' => new Expression('IF(SF.IS_NULLABLE="YES",1,0)'),
+ 'default_value' => 'COLUMN_DEFAULT',
+ 'comment' => new Expression('IFNULL(DF.comment, SF.COLUMN_COMMENT)'),
+ 'column_type' => 'COLUMN_TYPE',
+ ]);
+
+ $selectOne->from(['SF' => new TableIdentifier('COLUMNS', 'INFORMATION_SCHEMA')]);
+ $selectOne->join(
+ ['DF' => 'directus_fields'],
+ 'SF.COLUMN_NAME = DF.field AND SF.TABLE_NAME = DF.collection',
+ [
+ 'type' => new Expression('UCASE(IFNULL(DF.type, SF.DATA_TYPE))'),
+ 'managed' => new Expression('IF(ISNULL(DF.id),0,1)'),
+ 'interface',
+ 'hidden_input' => new Expression('IF(DF.hidden_input=1,1,0)'),
+ 'required' => new Expression('IF(DF.required=1,1,0)'),
+ 'options'
+ ],
+ $selectOne::JOIN_LEFT
+ );
+
+ $selectOne->where([
+ 'SF.TABLE_SCHEMA' => $this->adapter->getCurrentSchema(),
+ // 'T.TABLE_TYPE' => 'BASE TABLE'
+ ]);
+
+ if (isset($params['collection'])) {
+ $selectOne->where([
+ 'SF.TABLE_NAME' => $params['collection']
+ ]);
+ }
+
+ $selectTwo = new Select();
+ $selectTwo->columns([
+ 'collection',
+ 'field',
+ 'sort',
+ 'original_type' => new Expression('NULL'),
+ 'key' => new Expression('NULL'),
+ 'extra' => new Expression('NULL'),
+ 'char_length' => new Expression('NULL'),
+ 'precision' => new Expression('NULL'),
+ 'scale' => new Expression('NULL'),
+ 'is_nullable' => new Expression('"NO"'),
+ 'default_value' => new Expression('NULL'),
+ 'comment',
+ 'column_type' => new Expression('NULL'),
+ 'type' => new Expression('UCASE(type)'),
+ 'managed' => new Expression('IF(ISNULL(DF2.id),0,1)'),
+ 'interface',
+ 'hidden_input',
+ 'required',
+ 'options',
+ ]);
+ $selectTwo->from(['DF2' => 'directus_fields']);
+
+ $where = new Where();
+ $where->addPredicate(new In(new Expression('UCASE(type)'), DataTypes::getAliasTypes()));
+ if (isset($params['collection'])) {
+ $where->equalTo('DF2.collection', $params['collection']);
+ }
+
+ $selectTwo->where($where);
+
+ $selectOne->combine($selectTwo);//, $selectOne::COMBINE_UNION, 'ALL');
+ $selectOne->order('collection');
+
+ $sql = new Sql($this->adapter);
+ $statement = $sql->prepareStatementForSqlObject($selectOne);
+ $result = $statement->execute();
+
+ return $result;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function hasField($tableName, $columnName)
+ {
+ // TODO: Implement hasColumn() method.
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getField($tableName, $columnName)
+ {
+ return $this->getFields($tableName, ['field' => $columnName])->current();
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getAllRelations()
+ {
+ // TODO: Implement getAllRelations() method.
+ }
+
+ public function getRelations($collectionName)
+ {
+ $selectOne = new Select();
+ // $selectOne->quantifier($selectOne::QUANTIFIER_DISTINCT);
+ $selectOne->columns([
+ 'id',
+ 'collection_a',
+ 'field_a',
+ 'junction_key_a',
+ 'junction_collection',
+ 'junction_mixed_collections',
+ 'junction_key_b',
+ 'collection_b',
+ 'field_b'
+ ]);
+
+ $selectOne->from('directus_relations');
+
+ $where = $selectOne->where->nest();
+ $where->equalTo('collection_a', $collectionName);
+ $where->OR;
+ $where->equalTo('collection_b', $collectionName);
+ $where->unnest();
+
+ $sql = new Sql($this->adapter);
+ $statement = $sql->prepareStatementForSqlObject($selectOne);
+ $result = $statement->execute();
+
+ return $result;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function hasPrimaryKey($tableName)
+ {
+ // TODO: Implement hasPrimaryKey() method.
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getPrimaryKey($tableName)
+ {
+ $select = new Select();
+ $columnName = null;
+
+ // @todo: make this part of loadSchema
+ // without the need to use acl and create a infinite nested function call
+ $select->columns([
+ 'column_name' => 'COLUMN_NAME'
+ ]);
+ $select->from(new TableIdentifier('COLUMNS', 'INFORMATION_SCHEMA'));
+ $select->where([
+ 'TABLE_NAME' => $tableName,
+ 'TABLE_SCHEMA' => $this->adapter->getCurrentSchema(),
+ 'COLUMN_KEY' => 'PRI'
+ ]);
+
+ $sql = new Sql($this->adapter);
+ $statement = $sql->prepareStatementForSqlObject($select);
+ $result = $statement->execute();
+
+ // @TODO: Primary key can be more than one.
+ $column = $result->current();
+ if ($column) {
+ $columnName = $column['column_name'];
+ }
+
+ return $columnName;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getFullSchema()
+ {
+ // TODO: Implement getFullSchema() method.
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getColumnUI($column)
+ {
+ // TODO: Implement getColumnUI() method.
+ }
+
+ /**
+ * Add primary key to an existing column
+ *
+ * @param $table
+ * @param $column
+ *
+ * @return \Zend\Db\Adapter\Driver\StatementInterface|\Zend\Db\ResultSet\ResultSet
+ *
+ * @throws Exception
+ */
+ public function addPrimaryKey($table, $column)
+ {
+ $columnData = $this->getField($table, $column);
+
+ if (!$columnData) {
+ // TODO: Better error message
+ throw new Exception('Missing column');
+ }
+
+ $dataType = ArrayUtils::get($columnData, 'type');
+
+ if (!$dataType) {
+ // TODO: Better error message
+ throw new Exception('Missing data type');
+ }
+
+ $queryFormat = 'ALTER TABLE `%s` ADD PRIMARY KEY(`%s`)';
+ // NOTE: Make this work with strings
+ if ($this->isNumericType($dataType)) {
+ $queryFormat .= ', MODIFY COLUMN `%s` %s AUTO_INCREMENT';
+ }
+
+ $query = sprintf($queryFormat, $table, $column, $column, $dataType);
+ $connection = $this->adapter;
+
+ return $connection->query($query, $connection::QUERY_MODE_EXECUTE);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function dropPrimaryKey($table, $column)
+ {
+ $columnData = $this->getField($table, $column);
+
+ if (!$columnData) {
+ // TODO: Better message
+ throw new Exception('Missing column');
+ }
+
+ $dataType = ArrayUtils::get($columnData, 'type');
+
+ if (!$dataType) {
+ // TODO: Better message
+ throw new Exception('Missing data type');
+ }
+
+ $queryFormat = 'ALTER TABLE `%s` CHANGE COLUMN `%s` `%s` %s NOT NULL, DROP PRIMARY KEY';
+ $query = sprintf($queryFormat, $table, $column, $column, $dataType);
+ $connection = $this->adapter;
+
+ return $connection->query($query, $connection::QUERY_MODE_EXECUTE);
+ }
+
+ /**
+ * Cast string values to its database type.
+ *
+ * @param $data
+ * @param $type
+ * @param $length
+ *
+ * @return mixed
+ */
+ public function castValue($data, $type = null, $length = false)
+ {
+ $type = strtolower($type);
+
+ switch ($type) {
+ case 'blob':
+ case 'mediumblob':
+ // NOTE: Do we really need to encode the blob?
+ $data = base64_encode($data);
+ break;
+ case 'year':
+ case 'bigint':
+ case 'smallint':
+ case 'mediumint':
+ case 'int':
+ case 'integer':
+ case 'long':
+ case 'tinyint':
+ $data = ($data === null) ? null : (int)$data;
+ break;
+ case 'float':
+ $data = (float)$data;
+ break;
+ case 'date':
+ case 'datetime':
+ $format = 'Y-m-d';
+ $zeroData = '0000-00-00';
+ if ($type === 'datetime') {
+ $format .= ' H:i:s';
+ $zeroData .= ' 00:00:00';
+ }
+
+ if ($data === $zeroData) {
+ $data = null;
+ }
+ $datetime = \DateTime::createFromFormat($format, $data);
+ $data = $datetime ? $datetime->format($format) : null;
+ break;
+ case 'time':
+ // NOTE: Assuming this are all valid formatted data
+ $data = !empty($data) ? $data : null;
+ break;
+ case 'char':
+ case 'varchar':
+ case 'text':
+ case 'tinytext':
+ case 'mediumtext':
+ case 'longtext':
+ case 'var_string':
+ break;
+ }
+
+ return $data;
+ }
+
+ public function parseType($data, $type = null, $length = false)
+ {
+ return $this->castValue($data, $type, $length);
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getDecimalTypes()
+ {
+ return [
+ 'double',
+ 'decimal',
+ 'float'
+ ];
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getIntegerTypes()
+ {
+ return [
+ 'year',
+ 'bigint',
+ 'smallint',
+ 'mediumint',
+ 'int',
+ 'long',
+ 'tinyint'
+ ];
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getNumericTypes()
+ {
+ return array_merge($this->getDecimalTypes(), $this->getIntegerTypes());
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function isDecimalType($type)
+ {
+ return $this->isType($type, $this->getDecimalTypes());
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function isIntegerType($type)
+ {
+ return $this->isType($type, $this->getIntegerTypes());
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function isNumericType($type)
+ {
+ return in_array(strtolower($type), $this->getNumericTypes());
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getStringTypes()
+ {
+ return [
+ 'char',
+ 'varchar',
+ 'text',
+ 'enum',
+ 'set',
+ 'tinytext',
+ 'text',
+ 'mediumtext',
+ 'longtext'
+ ];
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function isStringType($type)
+ {
+ return in_array(strtolower($type), $this->getStringTypes());
+ }
+}
diff --git a/src/core/Directus/Database/Schema/Sources/SQLiteSchema.php b/src/core/Directus/Database/Schema/Sources/SQLiteSchema.php
new file mode 100644
index 0000000000..1f2a2521c9
--- /dev/null
+++ b/src/core/Directus/Database/Schema/Sources/SQLiteSchema.php
@@ -0,0 +1,410 @@
+metadata = new SqliteMetadata($adapter);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getTables()
+ {
+ $tablesObject = $this->metadata->getTables();
+ $directusTablesInfo = $this->getDirectusTablesInfo();
+
+ return $this->formatTablesFromInfo($tablesObject, $directusTablesInfo);
+ }
+
+ protected function getDirectusTablesInfo()
+ {
+ $config = Bootstrap::get('config');
+
+ $blacklist = [];
+ if ($config->has('tableBlacklist')) {
+ $blacklist = $config->get('tableBlacklist');
+ }
+
+ $select = new Select();
+ $select->columns([
+ 'table_name',
+ 'hidden' => new Expression('IFNULL(hidden, 0)'),
+ 'single' => new Expression('IFNULL(single, 0)'),
+ 'user_create_column',
+ 'user_update_column',
+ 'date_create_column',
+ 'date_update_column',
+ 'footer',
+ 'list_view',
+ 'column_groupings',
+ 'filter_column_blacklist',
+ 'primary_column'
+ ]);
+ $select->from('directus_tables');
+
+ $skipTables = array_merge(SchemaManager::getDirectusTables(), (array)$blacklist);
+ $select->where([
+ new NotIn('table_name', $skipTables),
+ ]);
+
+ $sql = new Sql($this->adapter);
+ $statement = $sql->prepareStatementForSqlObject($select);
+ $result = $statement->execute();
+
+ return iterator_to_array($result);
+ }
+
+ protected function formatTablesFromInfo($tablesObject, $directusTablesInfo)
+ {
+ $tables = [];
+ foreach ($tablesObject as $tableObject) {
+ $directusTableInfo = [];
+ foreach ($directusTablesInfo as $index => $table) {
+ if ($table['table_name'] == $tableObject->getName()) {
+ $directusTableInfo = $table;
+ unset($directusTablesInfo[$index]);
+ }
+ }
+
+ $tables[] = $this->formatTableFromInfo($tableObject, $directusTableInfo);
+ }
+
+ return $tables;
+ }
+
+ protected function formatTableFromInfo($tableObject, $directusTableInfo)
+ {
+ return [
+ 'id' => $tableObject->getName(),
+ 'table_name' => $tableObject->getName(),
+ 'date_created' => null,
+ 'comment' => '',
+ 'count' => null,
+ 'hidden' => ArrayUtils::get($directusTableInfo, 'hidden', 0),
+ 'single' => ArrayUtils::get($directusTableInfo, 'single', 0),
+ 'user_create_column' => ArrayUtils::get($directusTableInfo, 'user_create_column', null),
+ 'user_update_column' => ArrayUtils::get($directusTableInfo, 'user_update_column', null),
+ 'date_create_column' => ArrayUtils::get($directusTableInfo, 'date_create_column', null),
+ 'date_update_column' => ArrayUtils::get($directusTableInfo, 'date_update_column', null),
+ 'footer' => ArrayUtils::get($directusTableInfo, 'footer', 0),
+ 'list_view' => ArrayUtils::get($directusTableInfo, 'list_view', null),
+ 'column_groupings' => ArrayUtils::get($directusTableInfo, 'column_groupings', null),
+ 'filter_column_blacklist' => ArrayUtils::get($directusTableInfo, 'filter_column_blacklist', null),
+ 'primary_column' => ArrayUtils::get($directusTableInfo, 'primary_column', null)
+ ];
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function hasTable($tableName)
+ {
+ // TODO: Implement hasTable() method.
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function tableExists($tableName)
+ {
+ return $this->hasTable($tableName);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function someTableExists(array $tablesName)
+ {
+ return false;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getTable($tableName)
+ {
+ $tablesObject = $this->metadata->getTable($tableName);
+ $directusTablesInfo = $this->getDirectusTableInfo($tableName);
+ if (!$directusTablesInfo) {
+ $directusTablesInfo = [];
+ }
+
+ return $this->formatTableFromInfo($tablesObject, $directusTablesInfo);
+ }
+
+ public function getDirectusTableInfo($tableName)
+ {
+ $select = new Select();
+ $select->columns([
+ 'table_name',
+ 'hidden' => new Expression('IFNULL(hidden, 0)'),
+ 'single' => new Expression('IFNULL(single, 0)'),
+ 'user_create_column',
+ 'user_update_column',
+ 'date_create_column',
+ 'date_update_column',
+ 'footer',
+ 'list_view',
+ 'column_groupings',
+ 'filter_column_blacklist',
+ 'primary_column'
+ ]);
+ $select->from('directus_tables');
+
+ $select->where([
+ 'table_name' => $tableName
+ ]);
+
+ $sql = new Sql($this->adapter);
+ $statement = $sql->prepareStatementForSqlObject($select);
+ $result = $statement->execute();
+
+ return $result->current();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getColumns($tableName, $params = null)
+ {
+ $columnsInfo = $this->metadata->getColumns($tableName);
+ // OLD FILTER
+ // @TODO this should be a job for the SchemaManager
+ $columnName = isset($params['column_name']) ? $params['column_name'] : -1;
+ if ($columnName != -1) {
+ foreach ($columnsInfo as $index => $column) {
+ if ($column->getName() == $columnName) {
+ unset($columnsInfo[$index]);
+ break;
+ }
+ }
+ }
+
+ $directusColumns = $this->getDirectusColumnsInfo($tableName, $params);
+ $columns = $this->formatColumnsFromInfo($columnsInfo, $directusColumns);
+
+ return $columns;
+ }
+
+ public function getAllColumns()
+ {
+ $allColumns = [];
+ $allTables = $this->getTables();
+
+ foreach ($allTables as $table) {
+ $columns = $this->getColumns($table['table_name']);
+ foreach ($columns as $index => $column) {
+ $columns[$index]['table_name'] = $table['table_name'];
+ }
+
+ $allColumns = array_merge($allColumns, $columns);
+ }
+
+ return $allColumns;
+ }
+
+ protected function formatColumnsFromInfo($columnsInfo, $directusColumnsInfo)
+ {
+ $columns = [];
+
+ foreach ($columnsInfo as $columnInfo) {
+ $directusColumnInfo = [];
+ foreach ($directusColumnsInfo as $index => $column) {
+ if ($column['column_name'] == $columnInfo->getName()) {
+ $directusColumnInfo = $column;
+ unset($directusColumnsInfo[$index]);
+ }
+ }
+
+ $columns[] = $this->formatColumnFromInfo($columnInfo, $directusColumnInfo);
+ }
+
+ return $columns;
+ }
+
+ protected function formatColumnFromInfo($columnInfo, $directusColumnInfo)
+ {
+ $matches = [];
+ preg_match('#^([a-zA-Z]+)(\(.*\)){0,1}$#', $columnInfo->getDataType(), $matches);
+
+ $dataType = strtoupper($matches[1]);
+
+ return [
+ 'id' => $columnInfo->getName(),
+ 'column_name' => $columnInfo->getName(),
+ 'type' => $dataType,
+ 'char_length' => $columnInfo->getCharacterMaximumLength(),
+ 'is_nullable' => $columnInfo->getIsNullable() ? 'YES' : 'NO',
+ 'default_value' => $columnInfo->getColumnDefault() == 'NULL' ? NULL : $columnInfo->getColumnDefault(),
+ 'comment' => '',
+ 'sort' => $columnInfo->getOrdinalPosition(),
+ 'column_type' => $columnInfo->getDataType(),
+ 'ui' => ArrayUtils::get($directusColumnInfo, 'ui', null),
+ 'hidden_input' => ArrayUtils::get($directusColumnInfo, 'hidden_input', 0),
+ 'relationship_type' => ArrayUtils::get($directusColumnInfo, 'relationship_type', null),
+ 'related_table' => ArrayUtils::get($directusColumnInfo, 'related_table', null),
+ 'junction_table' => ArrayUtils::get($directusColumnInfo, 'junction_table', null),
+ 'junction_key_left' => ArrayUtils::get($directusColumnInfo, 'junction_key_left', null),
+ 'junction_key_right' => ArrayUtils::get($directusColumnInfo, 'junction_key_right', null),
+ 'required' => ArrayUtils::get($directusColumnInfo, 'required', 0),
+ ];
+ }
+
+ /**
+ * Get all the columns information stored on Directus Columns table
+ *
+ * @param $tableName
+ * @param $params
+ *
+ * @return array
+ */
+ protected function getDirectusColumnsInfo($tableName, $params = null)
+ {
+ $acl = Bootstrap::get('acl');
+
+ $blacklist = $readFieldBlacklist = $acl->getTablePrivilegeList($tableName, $acl::FIELD_READ_BLACKLIST);
+ $columnName = isset($params['column_name']) ? $params['column_name'] : -1;
+
+ $select = new Select();
+ $select->columns([
+ 'id' => 'column_name',
+ 'column_name',
+ 'type' => new Expression('upper(data_type)'),
+ 'char_length' => new Expression('NULL'),
+ 'is_nullable' => new Expression('"NO"'),
+ 'default_value' => new Expression('NULL'),
+ 'comment',
+ 'sort',
+ 'column_type' => new Expression('NULL'),
+ 'ui',
+ 'hidden_input',
+ 'relationship_type',
+ 'related_table',
+ 'junction_table',
+ 'junction_key_left',
+ 'junction_key_right',
+ 'required' => new Expression('IFNULL(required, 0)')
+ ]);
+ $select->from('directus_columns');
+ $where = new Where();
+ $where
+ ->equalTo('TABLE_NAME', $tableName)
+ ->addPredicate(new In('data_type', ['alias', 'MANYTOMANY', 'ONETOMANY']));
+ // ->nest()
+ // ->addPredicate(new \Zend\Db\Sql\Predicate\Expression("'$columnName' = '-1'"))
+ // ->OR
+ // ->equalTo('column_name', $columnName)
+ // ->unnest()
+ // ->addPredicate(new IsNotNull('data_type'));
+
+ if ($columnName != -1) {
+ $where->equalTo('column_name', $columnName);
+ }
+
+ if (count($blacklist)) {
+ $where->addPredicate(new NotIn('COLUMN_NAME', $blacklist));
+ }
+
+ $select->where($where);
+ $select->order('sort');
+
+ $sql = new Sql($this->adapter);
+ $statement = $sql->prepareStatementForSqlObject($select);
+ // $query = $sql->getSqlStringForSqlObject($select, $this->adapter->getPlatform());
+ // echo $query;
+ $result = $statement->execute();
+ $columns = iterator_to_array($result);
+
+ return $columns;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function hasColumn($tableName, $columnName)
+ {
+ // TODO: Implement hasColumn() method.
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getColumn($tableName, $columnName)
+ {
+ // TODO: Implement getColumn() method.
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function hasPrimaryKey($tableName)
+ {
+ // TODO: Implement hasPrimaryKey() method.
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getPrimaryKey($tableName)
+ {
+ $columnName = null;
+
+ $constraints = $this->metadata->getConstraints($tableName);
+ foreach ($constraints as $constraint) {
+ if ($constraint->isPrimaryKey()) {
+ // @TODO: Directus should handle multiple columns
+ $columns = $constraint->getColumns();
+ $columnName = array_shift($columns);
+ break;
+ }
+ }
+
+ return $columnName;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getFullSchema()
+ {
+ // TODO: Implement getFullSchema() method.
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getColumnUI($column)
+ {
+ // TODO: Implement getColumnUI() method.
+ }
+
+ public function parseType($data, $type = null)
+ {
+ return $data;
+ }
+}
diff --git a/src/core/Directus/Database/Schema/Sources/SchemaInterface.php b/src/core/Directus/Database/Schema/Sources/SchemaInterface.php
new file mode 100644
index 0000000000..a41cb8622f
--- /dev/null
+++ b/src/core/Directus/Database/Schema/Sources/SchemaInterface.php
@@ -0,0 +1,300 @@
+fromContainer('schema_manager')
+ );
+ }
+
+ return static::$schemaManager;
+ }
+
+ /**
+ * Set the Schema Manager instance
+ *
+ * @param $schemaManager
+ */
+ public static function setSchemaManagerInstance($schemaManager)
+ {
+ static::$schemaManager = $schemaManager;
+ }
+
+ /**
+ * Get ACL Instance
+ *
+ * @return \Directus\Permissions\Acl
+ */
+ public static function getAclInstance()
+ {
+ if (static::$acl === null) {
+ static::setAclInstance(
+ Application::getInstance()->fromContainer('acl')
+ );
+ }
+
+ return static::$acl;
+ }
+
+
+ /**
+ * Set ACL Instance
+ * @param $acl
+ */
+ public static function setAclInstance($acl)
+ {
+ static::$acl = $acl;
+ }
+
+ /**
+ * Get Connection Instance
+ *
+ * @return \Directus\Database\Connection
+ */
+ public static function getConnectionInstance()
+ {
+ if (static::$connection === null) {
+ static::setConnectionInstance(
+ Application::getInstance()->fromContainer('schema_manager')
+ );
+ }
+
+ return static::$connection;
+ }
+
+ public static function setConnectionInstance($connection)
+ {
+ static::$connection = $connection;
+ }
+
+ public static function setConfig($config)
+ {
+ static::$config = $config;
+ }
+
+ /**
+ * Gets table schema object
+ *
+ * @param $tableName
+ * @param array $params
+ * @param bool $skipCache
+ * @param bool $skipAcl
+ *
+ * @return Collection
+ */
+ public static function getCollection($tableName, array $params = [], $skipCache = false, $skipAcl = false)
+ {
+ // if (!$skipAcl) {
+ // static::getAclInstance()->enforceRead($tableName);
+ // }
+
+ return static::getSchemaManagerInstance()->getCollection($tableName, $params, $skipCache);
+ }
+
+ public static function getCollectionOwnerField($collection)
+ {
+ $collectionObject = static::getCollection($collection);
+
+ return $collectionObject->getUserCreateField();
+ }
+
+ public static function getCollectionOwnerFieldName($collection)
+ {
+ $field = static::getCollectionOwnerField($collection);
+
+ return $field ? $field->getName() : null;
+ }
+
+ /**
+ * Gets table columns schema
+ *
+ * @param string $tableName
+ * @param array $params
+ * @param bool $skipCache
+ *
+ * @return Field[]
+ */
+ public static function getFields($tableName, array $params = [], $skipCache = false)
+ {
+ $tableObject = static::getCollection($tableName, $params, $skipCache);
+
+ return array_values(array_filter($tableObject->getFields(), function (Field $column) {
+ return static::canReadField($column->getCollectionName(), $column->getName());
+ }));
+ }
+
+ /**
+ * Gets the column object
+ *
+ * @param string $tableName
+ * @param string $columnName
+ * @param bool $skipCache
+ * @param bool $skipAcl
+ *
+ * @return Field
+ */
+ public static function getField($tableName, $columnName, $skipCache = false, $skipAcl = false)
+ {
+ // Due to a problem the way we use to cache using array
+ // if a column information is fetched before its table
+ // the table is going to be created with only one column
+ // to prevent this we always get the table even if we only want one column
+ // Stop using getColumnSchema($tableName, $columnName); until we fix this.
+ $tableObject = static::getCollection($tableName, [], $skipCache, $skipAcl);
+ $column = $tableObject->getField($columnName);
+
+ return $column;
+ }
+
+ /**
+ * @todo for ALTER requests, caching schemas can't be allowed
+ */
+
+ /**
+ * Checks whether the given table has a status column
+ *
+ * @param $tableName
+ * @param $skipAcl
+ *
+ * @return bool
+ */
+ public static function hasStatusField($tableName, $skipAcl = false)
+ {
+ $schema = static::getCollection($tableName, [], false, $skipAcl);
+
+ return $schema->hasStatusField();
+ }
+
+ /**
+ * Gets the status field
+ *
+ * @param $tableName
+ * @param $skipAcl
+ *
+ * @return null|Field
+ */
+ public static function getStatusField($tableName, $skipAcl = false)
+ {
+ $schema = static::getCollection($tableName, [], false, $skipAcl);
+
+ return $schema->getStatusField();
+ }
+
+ /**
+ * Gets the status field name
+ *
+ * @param $collectionName
+ * @param bool $skipAcl
+ *
+ * @return null|string
+ */
+ public static function getStatusFieldName($collectionName, $skipAcl = false)
+ {
+ $field = static::getStatusField($collectionName, $skipAcl);
+ $name = null;
+
+ if ($field) {
+ $name = $field->getName();
+ }
+
+ return $name;
+ }
+
+ /**
+ * If the table has one or more relational interfaces
+ *
+ * @param $tableName
+ * @param array $columns
+ * @param bool $skipAcl
+ *
+ * @return bool
+ */
+ public static function hasSomeRelational($tableName, array $columns, $skipAcl = false)
+ {
+ $tableSchema = static::getCollection($tableName, [], false, $skipAcl);
+ $relationalColumns = $tableSchema->getRelationalFieldsName();
+
+ $has = false;
+ foreach ($relationalColumns as $column) {
+ if (in_array($column, $columns)) {
+ $has = true;
+ break;
+ }
+ }
+
+ return $has;
+ }
+
+ /**
+ * Gets tehe column relationship type
+ *
+ * @param $tableName
+ * @param $columnName
+ *
+ * @return null|string
+ */
+ public static function getColumnRelationshipType($tableName, $columnName)
+ {
+ $relationship = static::getColumnRelationship($tableName, $columnName);
+
+ $relationshipType = null;
+ if ($relationship) {
+ $relationshipType = $relationship->getType();
+ }
+
+ return $relationshipType;
+ }
+
+ /**
+ * Gets Column's relationship
+ *
+ * @param $tableName
+ * @param $columnName
+ *
+ * @return FieldRelationship|null
+ */
+ public static function getColumnRelationship($tableName, $columnName)
+ {
+ $column = static::getField($tableName, $columnName);
+
+ return $column && $column->hasRelationship() ? $column->getRelationship() : null;
+ }
+
+ /**
+ * Check whether the given table-column has relationship
+ *
+ * @param $tableName
+ * @param $columnName
+ *
+ * @return bool
+ *
+ * @throws FieldNotFoundException
+ */
+ public static function hasRelationship($tableName, $columnName)
+ {
+ $tableObject = static::getCollection($tableName);
+ $columnObject = $tableObject->getField($columnName);
+
+ if (!$columnObject) {
+ throw new FieldNotFoundException($columnName);
+ }
+
+ return $columnObject->hasRelationship();
+ }
+
+ /**
+ * Gets related table name
+ *
+ * @param $tableName
+ * @param $columnName
+ *
+ * @return string
+ */
+ public static function getRelatedCollectionName($tableName, $columnName)
+ {
+ if (!static::hasRelationship($tableName, $columnName)) {
+ return null;
+ }
+
+ $tableObject = static::getCollection($tableName);
+ $columnObject = $tableObject->getField($columnName);
+
+ return $columnObject->getRelationship()->getCollectionB();
+ }
+
+ // @NOTE: This was copy-paste to Column Object
+ /**
+ * Whether or not the column name is the name of a system column.
+ *
+ * @param $interfaceName
+ *
+ * @return bool
+ */
+ public static function isSystemColumn($interfaceName)
+ {
+ return static::getSchemaManagerInstance()->isSystemField($interfaceName);
+ }
+
+ /**
+ * Checks whether the table is a system table
+ *
+ * @param $tableName
+ *
+ * @return bool
+ */
+ public static function isSystemCollection($tableName)
+ {
+ return static::getSchemaManagerInstance()->isSystemCollection($tableName);
+ }
+
+ /**
+ * @param $tableName
+ *
+ * @return \Directus\Database\Schema\Object\Field[] |bool
+ */
+ public static function getAllCollectionFields($tableName)
+ {
+ $columns = static::getSchemaManagerInstance()->getFields($tableName);
+
+ $acl = static::getAclInstance();
+ $readFieldBlacklist = $acl->getReadFieldBlacklist($tableName);
+
+ return array_filter($columns, function (Field $column) use ($readFieldBlacklist) {
+ return !in_array($column->getName(), $readFieldBlacklist);
+ });
+ }
+
+ /**
+ * @param $tableName
+ *
+ * @return array
+ */
+ public static function getAllCollectionFieldsName($tableName)
+ {
+ // @TODO: make all these methods name more standard
+ // TableColumnsName vs TableColumnNames
+ $fields = static::getAllCollectionFields($tableName);
+
+ return array_map(function(Field $field) {
+ return $field->getName();
+ }, $fields);
+ }
+
+ public static function getAllNonAliasCollectionFieldNames($table)
+ {
+ $columnNames = [];
+ $columns = self::getAllNonAliasCollectionFields($table);
+ if (false === $columns) {
+ return false;
+ }
+
+ foreach ($columns as $column) {
+ $columnNames[] = $column->getName();
+ }
+
+ return $columnNames;
+ }
+
+ /**
+ * Gets the non alias columns from the given table name
+ *
+ * @param string $tableName
+ * @param bool $onlyNames
+ *
+ * @return Field[]|bool
+ */
+ public static function getAllNonAliasCollectionFields($tableName, $onlyNames = false)
+ {
+ $columns = [];
+ $schemaArray = static::getAllCollectionFields($tableName);
+ if (false === $schemaArray) {
+ return false;
+ }
+
+ foreach ($schemaArray as $column) {
+ if (!$column->isAlias()) {
+ $columns[] = $onlyNames === true ? $column->getName() : $column;
+ }
+ }
+
+ return $columns;
+ }
+
+ /**
+ * Gets the alias columns from the given table name
+ *
+ * @param string $tableName
+ * @param bool $onlyNames
+ *
+ * @return Field[]|bool
+ */
+ public static function getAllAliasCollectionFields($tableName, $onlyNames = false)
+ {
+ $columns = [];
+ $schemaArray = static::getAllCollectionFields($tableName);
+ if (false === $schemaArray) {
+ return false;
+ }
+
+ foreach ($schemaArray as $column) {
+ if ($column->isAlias()) {
+ $columns[] = $onlyNames === true ? $column->getName() : $column;
+ }
+ }
+
+ return $columns;
+ }
+
+ /**
+ * Gets the non alias columns name from the given table name
+ *
+ * @param string $tableName
+ *
+ * @return Field[]|bool
+ */
+ public static function getAllNonAliasCollectionFieldsName($tableName)
+ {
+ return static::getAllNonAliasCollectionFields($tableName, true);
+ }
+
+ /**
+ * Gets the alias columns name from the given table name
+ *
+ * @param string $tableName
+ *
+ * @return Field[]|bool
+ */
+ public static function getAllAliasCollectionFieldsName($tableName)
+ {
+ return static::getAllAliasCollectionFields($tableName, true);
+ }
+
+ public static function getCollectionFields($table, $limit = null, $skipIgnore = false)
+ {
+ if (!self::canGroupReadCollection($table)) {
+ return [];
+ }
+
+ $schemaManager = static::getSchemaManagerInstance();
+ $tableObject = $schemaManager->getCollection($table);
+ $columns = $tableObject->getFields();
+ $columnsName = [];
+ $count = 0;
+ foreach ($columns as $column) {
+ if ($skipIgnore === false
+ && (
+ ($tableObject->hasStatusField() && $column->getName() === $tableObject->getStatusField()->getName())
+ || ($tableObject->hasSortingField() && $column->getName() === $tableObject->getSortingField())
+ || ($tableObject->hasPrimaryField() && $column->getName() === $tableObject->getPrimaryField())
+ )
+ ) {
+ continue;
+ }
+
+ // at least will return one
+ if ($limit && $count > $limit) {
+ break;
+ }
+
+ $columnsName[] = $column->getName();
+ $count++;
+ }
+
+ return $columnsName;
+ }
+
+ public static function getFieldsName($table)
+ {
+ if (isset(static::$_schemas[$table])) {
+ $columns = array_map(function($column) {
+ return $column['column_name'];
+ }, static::$_schemas[$table]);
+ } else {
+ $columns = static::getSchemaManagerInstance()->getFieldsName($table);
+ }
+
+ $names = [];
+ foreach ($columns as $column) {
+ $names[] = $column;
+ }
+
+ return $names;
+ }
+
+ /**
+ * Checks whether or not the given table has a sort column
+ *
+ * @param $table
+ * @param bool $includeAlias
+ *
+ * @return bool
+ */
+ public static function hasCollectionSortField($table, $includeAlias = false)
+ {
+ $column = static::getCollectionSortField($table);
+
+ return static::hasCollectionField($table, $column, $includeAlias);
+ }
+
+ public static function hasCollectionField($table, $column, $includeAlias = false, $skipAcl = false)
+ {
+ $tableObject = static::getCollection($table, [], false, $skipAcl);
+
+ $columns = $tableObject->getNonAliasFieldsName();
+ if ($includeAlias) {
+ $columns = array_merge($columns, $tableObject->getAliasFieldsName());
+ }
+
+ if (in_array($column, $columns)) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Gets the table sort column name
+ *
+ * @param $table
+ *
+ * @return string
+ */
+ public static function getCollectionSortField($table)
+ {
+ $tableObject = static::getCollection($table);
+
+ $sortColumnName = $tableObject->getSortingField();
+ if (!$sortColumnName) {
+ $sortColumnName = $tableObject->getPrimaryKeyName() ?: 'id';
+ }
+
+ return $sortColumnName;
+ }
+
+ /**
+ * Has the authenticated user permission to view the given table
+ *
+ * @param $tableName
+ *
+ * @return bool
+ */
+ public static function canGroupReadCollection($tableName)
+ {
+ $acl = static::getAclInstance();
+
+ if (! $acl) {
+ return true;
+ }
+
+ return $acl->canRead($tableName);
+ }
+
+ /**
+ * Has the authenticated user permissions to read the given column
+ *
+ * @param $tableName
+ * @param $columnName
+ *
+ * @return bool
+ */
+ public static function canReadField($tableName, $columnName)
+ {
+ $acl = static::getAclInstance();
+
+ if (! $acl) {
+ return true;
+ }
+
+ return $acl->canReadField($tableName, $columnName);
+ }
+
+ /**
+ * Get table primary key
+ * @param $tableName
+ * @return String|boolean - column name or false
+ */
+ public static function getCollectionPrimaryKey($tableName)
+ {
+ if (isset(self::$_primaryKeys[$tableName])) {
+ return self::$_primaryKeys[$tableName];
+ }
+
+ $schemaManager = static::getSchemaManagerInstance();
+
+ $columnName = $schemaManager->getPrimaryKey($tableName);
+
+ return self::$_primaryKeys[$tableName] = $columnName;
+ }
+
+ protected static function createParamArray($values, $prefix)
+ {
+ $result = [];
+
+ foreach ($values as $i => $field) {
+ $result[$prefix . $i] = $field;
+ }
+
+ return $result;
+ }
+}
diff --git a/src/core/Directus/Database/TableGateway/BaseTableGateway.php b/src/core/Directus/Database/TableGateway/BaseTableGateway.php
new file mode 100644
index 0000000000..f9d81a9d83
--- /dev/null
+++ b/src/core/Directus/Database/TableGateway/BaseTableGateway.php
@@ -0,0 +1,1610 @@
+table = $table;
+ $this->acl = $acl;
+
+ // @NOTE: temporary, do we need it here?
+ if ($this->primaryKeyFieldName === null) {
+ if ($primaryKeyName !== null) {
+ $this->primaryKeyFieldName = $primaryKeyName;
+ } else {
+ $tableObject = $this->getTableSchema();
+ if ($tableObject->getPrimaryField()) {
+ $this->primaryKeyFieldName = $tableObject->getPrimaryField()->getName();
+ }
+ }
+ }
+
+ // @NOTE: This will be substituted by a new Cache wrapper class
+ // $this->memcache = new MemcacheProvider();
+ if ($features === null) {
+ $features = new Feature\FeatureSet();
+ } else if ($features instanceof Feature\AbstractFeature) {
+ $features = [$features];
+ } else if (is_array($features)) {
+ $features = new Feature\FeatureSet($features);
+ }
+
+ $rowGatewayPrototype = new BaseRowGateway($this->primaryKeyFieldName, $table, $adapter, $this->acl);
+ $rowGatewayFeature = new RowGatewayFeature($rowGatewayPrototype);
+ $features->addFeature($rowGatewayFeature);
+
+ parent::__construct($table, $adapter, $features, $resultSetPrototype, $sql);
+
+ if (static::$container) {
+ $this->schemaManager = static::$container->get('schema_manager');
+ }
+ }
+
+ /**
+ * Static Factory Methods
+ */
+
+ /**
+ * Creates a table gateway based on a table's name
+ *
+ * Underscore to camelcase table name to namespaced table gateway classname,
+ * e.g. directus_users => \Directus\Database\TableGateway\DirectusUsersTableGateway
+ *
+ * @param string $table
+ * @param AdapterInterface $adapter
+ * @param null $acl
+ *
+ * @return RelationalTableGateway
+ */
+ public static function makeTableGatewayFromTableName($table, $adapter, $acl = null)
+ {
+ return TableGatewayFactory::create($table, [
+ 'adapter' => $adapter,
+ 'acl' => $acl
+ ]);
+ }
+
+ /**
+ * Make a new table gateway
+ *
+ * @param string $tableName
+ * @param AdapterInterface $adapter
+ * @param Acl $acl
+ *
+ * @return BaseTableGateway
+ */
+ public function makeTable($tableName, $adapter = null, $acl = null)
+ {
+ $adapter = is_null($adapter) ? $this->adapter : $adapter;
+ $acl = is_null($acl) ? $this->acl : $acl;
+
+ return static::makeTableGatewayFromTableName($tableName, $adapter, $acl);
+ }
+
+ public function getTableSchema($tableName = null)
+ {
+ if ($this->tableSchema !== null && ($tableName === null || $tableName === $this->getTable())) {
+ return $this->tableSchema;
+ }
+
+ if ($tableName === null) {
+ $tableName = $this->getTable();
+ }
+
+ $skipAcl = $this->acl === null;
+ $tableSchema = SchemaService::getCollection($tableName, [], false, $skipAcl);
+
+ if ($tableName === $this->getTable()) {
+ $this->tableSchema = $tableSchema;
+ }
+
+ return $tableSchema;
+ }
+
+ /**
+ * Gets the column schema (object)
+ *
+ * @param $columnName
+ * @param null $tableName
+ *
+ * @return Field
+ */
+ public function getField($columnName, $tableName = null)
+ {
+ if ($tableName === null) {
+ $tableName = $this->getTable();
+ }
+
+ $skipAcl = $this->acl === null;
+
+ return SchemaService::getField($tableName, $columnName, false, $skipAcl);
+ }
+
+ /**
+ * Gets the status column name
+ *
+ * @return string
+ */
+ public function getStatusFieldName()
+ {
+ return $this->getTableSchema()->getStatusField();
+ }
+
+ public function withKey($key, $resultSet)
+ {
+ $withKey = [];
+ foreach ($resultSet as $row) {
+ $withKey[$row[$key]] = $row;
+ }
+ return $withKey;
+ }
+
+ /**
+ * Create a new row
+ *
+ * @param null $table
+ * @param null $primaryKeyColumn
+ *
+ * @return BaseRowGateway
+ */
+ public function newRow($table = null, $primaryKeyColumn = null)
+ {
+ $table = is_null($table) ? $this->table : $table;
+ $primaryKeyColumn = is_null($primaryKeyColumn) ? $this->primaryKeyFieldName : $primaryKeyColumn;
+ $row = new BaseRowGateway($primaryKeyColumn, $table, $this->adapter, $this->acl);
+
+ return $row;
+ }
+
+ public function find($id, $pk_field_name = null)
+ {
+ if ($pk_field_name == null) {
+ $pk_field_name = $this->primaryKeyFieldName;
+ }
+
+ $record = $this->findOneBy($pk_field_name, $id);
+
+ return $record ? $this->parseRecordValuesByType($record) : null;
+ }
+
+ public function fetchAll($selectModifier = null)
+ {
+ return $this->select(function (Select $select) use ($selectModifier) {
+ if (is_callable($selectModifier)) {
+ $selectModifier($select);
+ }
+ });
+ }
+
+ /**
+ * @return array All rows in array form with record IDs for the array's keys.
+ */
+ public function fetchAllWithIdKeys($selectModifier = null)
+ {
+ $allWithIdKeys = [];
+ $all = $this->fetchAll($selectModifier)->toArray();
+ return $this->withKey('id', $all);
+ }
+
+ public function findOneBy($field, $value)
+ {
+ $rowset = $this->ignoreFilters()->select(function (Select $select) use ($field, $value) {
+ $select->limit(1);
+ $select->where->equalTo($field, $value);
+ });
+
+ $row = $rowset->current();
+ // Supposing this "one" doesn't exist in the DB
+ if (!$row) {
+ return false;
+ }
+
+ return $row->toArray();
+ }
+
+ public function findOneByArray(array $data)
+ {
+ $rowset = $this->select($data);
+
+ $row = $rowset->current();
+ // Supposing this "one" doesn't exist in the DB
+ if (!$row) {
+ return false;
+ }
+
+ return $row->toArray();
+ }
+
+ public function addOrUpdateRecordByArray(array $recordData, $collectionName = null)
+ {
+ $collectionName = is_null($collectionName) ? $this->table : $collectionName;
+ $collectionObject = $this->getTableSchema($collectionName);
+ foreach ($recordData as $columnName => $columnValue) {
+ $fieldObject = $collectionObject->getField($columnName);
+ // TODO: Should this be validate in here? should we let the database fails?
+ if (($fieldObject && is_array($columnValue) && (!$fieldObject->isJson() && !$fieldObject->isArray()))) {
+ // $table = is_null($tableName) ? $this->table : $tableName;
+ throw new SuppliedArrayAsColumnValue('Attempting to write an array as the value for column `' . $collectionName . '`.`' . $columnName . '.');
+ }
+ }
+
+ // @TODO: Dow we need to parse before insert?
+ // Commented out because date are not saved correctly in GMT
+ // $recordData = $this->parseRecord($recordData);
+
+ $TableGateway = $this->makeTable($collectionName);
+ $primaryKey = $TableGateway->primaryKeyFieldName;
+ $hasPrimaryKeyData = isset($recordData[$primaryKey]);
+ $rowExists = false;
+ $currentItem = null;
+ $originalFilename = null;
+
+ if ($hasPrimaryKeyData) {
+ $select = new Select($collectionName);
+ $select->columns(['*']);
+ $select->where([
+ $primaryKey => $recordData[$primaryKey]
+ ]);
+ $select->limit(1);
+ $result = $TableGateway->ignoreFilters()->selectWith($select);
+ $rowExists = $result->count() > 0;
+ if ($rowExists) {
+ $currentItem = $result->current()->toArray();
+ }
+
+ if ($collectionName === SchemaManager::COLLECTION_FILES) {
+ $originalFilename = ArrayUtils::get($currentItem, 'filename');
+ $recordData = array_merge([
+ 'filename' => $originalFilename
+ ], $recordData);
+ }
+ }
+
+ $afterAction = function ($collectionName, $recordData, $replace = false) use ($TableGateway) {
+ if ($collectionName == SchemaManager::COLLECTION_FILES && static::$container) {
+ $Files = static::$container->get('files');
+
+ $updateArray = [];
+ if ($Files->getSettings('file_naming') == 'file_id') {
+ $ext = $thumbnailExt = pathinfo($recordData['filename'], PATHINFO_EXTENSION);
+ $Files->rename($recordData['filename'], str_pad($recordData[$this->primaryKeyFieldName], 11, '0', STR_PAD_LEFT) . '.' . $ext, $replace);
+ $updateArray['filename'] = str_pad($recordData[$this->primaryKeyFieldName], 11, '0', STR_PAD_LEFT) . '.' . $ext;
+ $recordData['filename'] = $updateArray['filename'];
+ }
+
+ if (!empty($updateArray)) {
+ $Update = new Update($collectionName);
+ $Update->set($updateArray);
+ $Update->where([$TableGateway->primaryKeyFieldName => $recordData[$TableGateway->primaryKeyFieldName]]);
+ $TableGateway->updateWith($Update);
+ }
+ }
+ };
+
+ if ($rowExists) {
+ $Update = new Update($collectionName);
+ $Update->set($recordData);
+ $Update->where([
+ $primaryKey => $recordData[$primaryKey]
+ ]);
+ $TableGateway->updateWith($Update);
+
+ if ($collectionName == 'directus_files' && static::$container) {
+ if ($originalFilename && $recordData['filename'] !== $originalFilename) {
+ /** @var Files $Files */
+ $Files = static::$container->get('files');
+ $Files->delete(['filename' => $originalFilename]);
+ }
+ }
+
+ $afterAction($collectionName, $recordData, true);
+
+ $this->runHook('postUpdate', [$TableGateway, $recordData, $this->adapter, null]);
+ } else {
+ $recordData = $this->applyHook('collection.insert:before', $recordData, [
+ 'collection_name' => $collectionName
+ ]);
+ $recordData = $this->applyHook('collection.insert.' . $collectionName . ':before', $recordData);
+ $TableGateway->insert($recordData);
+
+ // Only get the last inserted id, if the column has auto increment value
+ $columnObject = $this->getTableSchema()->getField($primaryKey);
+ if ($columnObject->hasAutoIncrement()) {
+ $recordData[$primaryKey] = $TableGateway->getLastInsertValue();
+ }
+
+ $afterAction($collectionName, $recordData);
+
+ $this->runHook('postInsert', [$TableGateway, $recordData, $this->adapter, null]);
+ }
+
+ $columns = SchemaService::getAllNonAliasCollectionFieldNames($collectionName);
+ $recordData = $TableGateway->fetchAll(function ($select) use ($recordData, $columns, $primaryKey) {
+ $select
+ ->columns($columns)
+ ->limit(1);
+ $select->where->equalTo($primaryKey, $recordData[$primaryKey]);
+ })->current();
+
+ return $recordData;
+ }
+
+ public function drop($tableName = null)
+ {
+ if ($tableName == null) {
+ $tableName = $this->table;
+ }
+
+ if ($this->acl) {
+ $this->acl->enforceAlter($tableName);
+ }
+
+ $dropped = false;
+ if ($this->schemaManager->tableExists($tableName)) {
+ // get drop table query
+ $sql = new Sql($this->adapter);
+ $drop = new Ddl\DropTable($tableName);
+ $query = $sql->buildSqlString($drop);
+
+ $this->runHook('collection.drop:before', [$tableName]);
+
+ $dropped = $this->getAdapter()->query(
+ $query
+ )->execute();
+
+ $this->runHook('collection.drop', [$tableName]);
+ $this->runHook('collection.drop:after', [$tableName]);
+ }
+
+ $this->stopManaging();
+
+ return $dropped;
+ }
+
+ /**
+ * Stop managing a table by removing privileges, preferences columns and table information
+ *
+ * @param null $tableName
+ *
+ * @return bool
+ */
+ public function stopManaging($tableName = null)
+ {
+ if ($tableName == null) {
+ $tableName = $this->table;
+ }
+
+ // Remove table privileges
+ if ($tableName != SchemaManager::COLLECTION_PERMISSIONS) {
+ $privilegesTableGateway = new TableGateway(SchemaManager::COLLECTION_PERMISSIONS, $this->adapter);
+ $privilegesTableGateway->delete(['collection' => $tableName]);
+ }
+
+ // Remove columns from directus_columns
+ $columnsTableGateway = new TableGateway(SchemaManager::COLLECTION_FIELDS, $this->adapter);
+ $columnsTableGateway->delete([
+ 'collection' => $tableName
+ ]);
+
+ // Remove table from directus_tables
+ $tablesTableGateway = new TableGateway(SchemaManager::COLLECTION_COLLECTIONS, $this->adapter);
+ $tablesTableGateway->delete([
+ 'collection' => $tableName
+ ]);
+
+ // Remove table from directus_collection_presets
+ $preferencesTableGateway = new TableGateway(SchemaManager::COLLECTION_COLLECTION_PRESETS, $this->adapter);
+ $preferencesTableGateway->delete([
+ 'collection' => $tableName
+ ]);
+
+ return true;
+ }
+
+ public function dropField($columnName, $tableName = null)
+ {
+ if ($tableName == null) {
+ $tableName = $this->table;
+ }
+
+ if ($this->acl) {
+ $this->acl->enforceAlter($tableName);
+ }
+
+ if (!SchemaService::hasCollectionField($tableName, $columnName, true)) {
+ return false;
+ }
+
+ // Drop table column if is a non-alias column
+ if (!array_key_exists($columnName, array_flip(SchemaService::getAllAliasCollectionFields($tableName, true)))) {
+ $sql = new Sql($this->adapter);
+ $alterTable = new Ddl\AlterTable($tableName);
+ $dropColumn = $alterTable->dropColumn($columnName);
+ $query = $sql->getSqlStringForSqlObject($dropColumn);
+
+ $this->adapter->query(
+ $query
+ )->execute();
+ }
+
+ // Remove column from directus_columns
+ $columnsTableGateway = new TableGateway(SchemaManager::COLLECTION_FIELDS, $this->adapter);
+ $columnsTableGateway->delete([
+ 'table_name' => $tableName,
+ 'column_name' => $columnName
+ ]);
+
+ // Remove column from sorting column in directus_preferences
+ $preferencesTableGateway = new TableGateway(SchemaManager::COLLECTION_COLLECTION_PRESETS, $this->adapter);
+ $preferencesTableGateway->update([
+ 'sort' => $this->primaryKeyFieldName,
+ 'sort_order' => 'ASC'
+ ], [
+ 'table_name' => $tableName,
+ 'sort' => $columnName
+ ]);
+
+ return true;
+ }
+
+ /*
+ Temporary solutions to fix add column error
+ This add column is the same old-db add_column method
+ */
+ public function addColumn($tableName, $tableData)
+ {
+ // @TODO: enforce permission
+ $directus_types = ['MANYTOMANY', 'ONETOMANY', 'ALIAS'];
+ $relationshipType = ArrayUtils::get($tableData, 'relationship_type', null);
+ // TODO: list all types which need manytoone ui
+ // Hard-coded
+ $manytoones = ['single_file', 'many_to_one', 'many_to_one_typeahead', 'MANYTOONE'];
+
+ if (!in_array($relationshipType, $directus_types)) {
+ $this->addTableColumn($tableName, $tableData);
+ // Temporary solutions to #481, #645
+ if (array_key_exists('ui', $tableData) && in_array($tableData['ui'], $manytoones)) {
+ $tableData['relationship_type'] = 'MANYTOONE';
+ $tableData['junction_key_right'] = $tableData['column_name'];
+ }
+ }
+
+ //This is a 'virtual column'. Write to directus schema instead of MYSQL
+ $this->addVirtualColumn($tableName, $tableData);
+
+ return $tableData['column_name'];
+ }
+
+ // @TODO: TableGateway should not be handling table creation
+ protected function addTableColumn($tableName, $columnData)
+ {
+ $column_name = $columnData['column_name'];
+ $dataType = $columnData['data_type'];
+ $comment = $this->getAdapter()->getPlatform()->quoteValue(ArrayUtils::get($columnData, 'comment', ''));
+
+ if (array_key_exists('length', $columnData)) {
+ $charLength = $columnData['length'];
+ // SET and ENUM data type has its values in the char_length attribute
+ // each value are separated by commas
+ // it must be wrap into quotes
+ if (!$this->schemaManager->isFloatingPointType($dataType) && strpos($charLength, ',') !== false) {
+ $charLength = implode(',', array_map(function ($value) {
+ return '"' . trim($value) . '"';
+ }, explode(',', $charLength)));
+ }
+
+ $dataType = $dataType . '(' . $charLength . ')';
+ }
+
+ $default = '';
+ if (ArrayUtils::get($columnData, 'default_value')) {
+ $value = ArrayUtils::get($columnData, 'default_value');
+ $length = ArrayUtils::get($columnData, 'length');
+ $defaultValue = $this->schemaManager->castDefaultValue($value, $dataType, $length);
+
+ $default = ' DEFAULT ' . (is_string($defaultValue) ? sprintf('"%s"', $defaultValue) : $defaultValue);
+ }
+
+ // TODO: wrap this into an abstract DDL class
+ $sql = 'ALTER TABLE `' . $tableName . '` ADD COLUMN `' . $column_name . '` ' . $dataType . $default . ' COMMENT "' . $comment . '"';
+
+ $this->adapter->query($sql)->execute();
+ }
+
+ protected function addVirtualColumn($tableName, $columnData)
+ {
+ $alias_columns = ['table_name', 'column_name', 'data_type', 'related_table', 'junction_table', 'junction_key_left', 'junction_key_right', 'sort', 'ui', 'comment', 'relationship_type'];
+
+ $columnData['table_name'] = $tableName;
+ // NOTE: setting 9999 as default just because
+ $columnData['sort'] = ArrayUtils::get($columnData, 'sort', 9999);
+
+ $data = array_intersect_key($columnData, array_flip($alias_columns));
+ return $this->addOrUpdateRecordByArray($data, 'directus_columns');
+ }
+
+ public function castFloatIfNumeric(&$value, $key)
+ {
+ if ($key != 'table_name') {
+ $value = is_numeric($value) ? (float)$value : $value;
+ }
+ }
+
+ /**
+ * Convenience method for dumping a ZendDb Sql query object as debug output.
+ *
+ * @param SqlInterface $query
+ *
+ * @return null
+ */
+ public function dumpSql(SqlInterface $query)
+ {
+ $sql = new Sql($this->adapter);
+ $query = $sql->getSqlStringForSqlObject($query, $this->adapter->getPlatform());
+ return $query;
+ }
+
+ public function ignoreFilters()
+ {
+ $this->options['filter'] = false;
+
+ return $this;
+ }
+
+ /**
+ * @param Select $select
+ *
+ * @return ResultSet
+ *
+ * @throws \Directus\Permissions\Exception\ForbiddenFieldReadException
+ * @throws \Directus\Permissions\Exception\ForbiddenFieldWriteException
+ * @throws \Exception
+ */
+ protected function executeSelect(Select $select)
+ {
+ $useFilter = ArrayUtils::get($this->options, 'filter', true) !== false;
+ unset($this->options['filter']);
+
+ if ($this->acl) {
+ $this->enforceSelectPermission($select);
+ }
+
+ $selectState = $select->getRawState();
+ $selectCollectionName = $selectState['table'];
+
+ if ($useFilter) {
+ $selectState = $this->applyHooks([
+ 'collection.select:before',
+ 'collection.select.' . $selectCollectionName . ':before',
+ ], $selectState, [
+ 'collection_name' => $selectCollectionName
+ ]);
+
+ // NOTE: This can be a "dangerous" hook, so for now we only support columns
+ $select->columns(ArrayUtils::get($selectState, 'columns', ['*']));
+ }
+
+ try {
+ $result = parent::executeSelect($select);
+ } catch (UnexpectedValueException $e) {
+ throw new InvalidQueryException(
+ $this->dumpSql($select),
+ $e
+ );
+ }
+
+ if ($useFilter) {
+ $result = $this->applyHooks([
+ 'collection.select',
+ 'collection.select.' . $selectCollectionName
+ ], $result, [
+ 'selectState' => $selectState,
+ 'collection_name' => $selectCollectionName
+ ]);
+ }
+
+ return $result;
+ }
+
+ /**
+ * @param Insert $insert
+ *
+ * @return mixed
+ *
+ * @throws \Directus\Database\Exception\InvalidQueryException
+ */
+ protected function executeInsert(Insert $insert)
+ {
+ if ($this->acl) {
+ $this->enforceInsertPermission($insert);
+ }
+
+ $insertState = $insert->getRawState();
+ $insertTable = $this->getRawTableNameFromQueryStateTable($insertState['table']);
+ $insertData = $insertState['values'];
+ // Data to be inserted with the column name as assoc key.
+ $insertDataAssoc = array_combine($insertState['columns'], $insertData);
+
+ $this->runHook('collection.insert:before', [$insertTable, $insertDataAssoc]);
+ $this->runHook('collection.insert.' . $insertTable . ':before', [$insertDataAssoc]);
+
+ try {
+ $result = parent::executeInsert($insert);
+ } catch (UnexpectedValueException $e) {
+ if (
+ strtolower($this->adapter->platform->getName()) === 'mysql'
+ && strpos(strtolower($e->getMessage()), 'duplicate entry') !== false
+ ) {
+ preg_match("/Duplicate entry '([^']+)' for key '([^']+)'/i", $e->getMessage(), $output);
+
+ if ($output) {
+ throw new DuplicateItemException($this->table, $output[1]);
+ }
+ }
+
+ throw new InvalidQueryException(
+ $this->dumpSql($insert),
+ $e
+ );
+ }
+
+ $insertTableGateway = $this->makeTable($insertTable);
+
+ // hotfix: directus_tables does not have auto generated value primary key
+ if ($this->getTable() === SchemaManager::COLLECTION_COLLECTIONS) {
+ $generatedValue = ArrayUtils::get($insertDataAssoc, $this->primaryKeyFieldName, 'table_name');
+ } else {
+ $generatedValue = $this->getLastInsertValue();
+ }
+
+ $resultData = $insertTableGateway->find($generatedValue);
+
+ $this->runHook('collection.insert', [$insertTable, $resultData]);
+ $this->runHook('collection.insert.' . $insertTable, [$resultData]);
+ $this->runHook('collection.insert:after', [$insertTable, $resultData]);
+ $this->runHook('collection.insert.' . $insertTable . ':after', [$resultData]);
+
+ return $result;
+ }
+
+ /**
+ * @param Update $update
+ *
+ * @return mixed
+ *
+ * @throws \Directus\Database\Exception\InvalidQueryException
+ */
+ protected function executeUpdate(Update $update)
+ {
+ $useFilter = ArrayUtils::get($this->options, 'filter', true) !== false;
+ unset($this->options['filter']);
+
+ if ($this->acl) {
+ $this->enforceUpdatePermission($update);
+ }
+
+ $updateState = $update->getRawState();
+ $updateTable = $this->getRawTableNameFromQueryStateTable($updateState['table']);
+ $updateData = $updateState['set'];
+
+ if ($useFilter) {
+ $updateData = $this->runBeforeUpdateHooks($updateTable, $updateData);
+ }
+
+ $update->set($updateData);
+
+ try {
+ $result = parent::executeUpdate($update);
+ } catch (UnexpectedValueException $e) {
+ throw new InvalidQueryException(
+ $this->dumpSql($update),
+ $e
+ );
+ }
+
+ if ($useFilter) {
+ $this->runAfterUpdateHooks($updateTable, $updateData);
+ }
+
+ return $result;
+ }
+
+ /**
+ * @param Delete $delete
+ *
+ * @return mixed
+ *
+ * @throws \Directus\Database\Exception\InvalidQueryException
+ */
+ protected function executeDelete(Delete $delete)
+ {
+ $ids = [];
+
+ if ($this->acl) {
+ $this->enforceDeletePermission($delete);
+ }
+
+ $deleteState = $delete->getRawState();
+ $deleteTable = $this->getRawTableNameFromQueryStateTable($deleteState['table']);
+
+ // Runs select PK with passed delete's $where before deleting, to use those for the even hook
+ if ($pk = $this->primaryKeyFieldName) {
+ $select = $this->sql->select();
+ $select->where($deleteState['where']);
+ $select->columns([$pk]);
+ $results = parent::executeSelect($select);
+
+ foreach($results as $result) {
+ $ids[] = $result['id'];
+ }
+ }
+
+ // skipping everything, if there is nothing to delete
+ if ($ids) {
+ $delete = $this->sql->delete();
+ $expression = new In($pk, $ids);
+ $delete->where($expression);
+
+ foreach ($ids as $id) {
+ $deleteData = ['id' => $id];
+ $this->runHook('collection.delete:before', [$deleteTable, $deleteData]);
+ $this->runHook('collection.delete.' . $deleteTable . ':before', [$deleteData]);
+ }
+
+ try {
+ $result = parent::executeDelete($delete);
+ } catch (UnexpectedValueException $e) {
+ throw new InvalidQueryException(
+ $this->dumpSql($delete),
+ $e
+ );
+ }
+
+ foreach ($ids as $id) {
+ $deleteData = ['id' => $id];
+ $this->runHook('collection.delete', [$deleteTable, $deleteData]);
+ $this->runHook('collection.delete:after', [$deleteTable, $deleteData]);
+ $this->runHook('collection.delete.' . $deleteTable, [$deleteData]);
+ $this->runHook('collection.delete.' . $deleteTable . ':after', [$deleteData]);
+ }
+
+ return $result;
+ }
+ }
+
+ protected function getRawTableNameFromQueryStateTable($table)
+ {
+ if (is_string($table)) {
+ return $table;
+ }
+
+ if (is_array($table)) {
+ // The only value is the real table name (key is alias).
+ return array_pop($table);
+ }
+
+ throw new \InvalidArgumentException('Unexpected parameter of type ' . get_class($table));
+ }
+
+ /**
+ * Convert dates to ISO 8601 format
+ *
+ * @param array $records
+ * @param Collection $tableSchema
+ * @param null $tableName
+ *
+ * @return array|mixed
+ */
+ public function convertDates(array $records, Collection $tableSchema, $tableName = null)
+ {
+ $tableName = $tableName === null ? $this->table : $tableName;
+ $isCustomTable = !$this->schemaManager->isDirectusCollection($tableName);
+ $hasSystemDateColumn = $this->schemaManager->hasSystemDateField($tableName);
+
+ if (!$hasSystemDateColumn && $isCustomTable) {
+ return $records;
+ }
+
+ // ==========================================================================
+ // hotfix: records sometimes are no set as an array of rows.
+ // NOTE: this code is duplicate @see: AbstractSchema::parseRecordValuesByType
+ // ==========================================================================
+ $singleRecord = false;
+ if (!ArrayUtils::isNumericKeys($records)) {
+ $records = [$records];
+ $singleRecord = true;
+ }
+
+ foreach ($records as $index => $row) {
+ foreach ($tableSchema->getFields() as $column) {
+ $canConvert = in_array(strtolower($column->getType()), ['timestamp', 'datetime']);
+ // Directus convert all dates to ISO to all datetime columns in the core tables
+ // and any columns using system date interfaces (date_created or date_modified)
+ if ($isCustomTable && !$column->isSystemDate()) {
+ $canConvert = false;
+ }
+
+ if ($canConvert) {
+ $columnName = $column->getName();
+
+ if (isset($row[$columnName])) {
+ $datetime = DateTimeUtils::createFromDefaultFormat($row[$columnName], 'UTC');
+ $datetime->switchToTimeZone(get_user_timezone());
+ $records[$index][$columnName] = $datetime->toISO8601Format();
+ }
+ }
+ }
+ }
+
+ return $singleRecord ? reset($records) : $records;
+ }
+
+ /**
+ * Parse records value by its column type
+ *
+ * @param array $records
+ * @param null $tableName
+ *
+ * @return array
+ */
+ protected function parseRecordValuesByType(array $records, $tableName = null)
+ {
+ // NOTE: Performance spot
+ $tableName = $tableName === null ? $this->table : $tableName;
+ // Get the columns directly from the source
+ // otherwise will keep in a circle loop loading Acl Instances
+ $columns = SchemaService::getSchemaManagerInstance()->getFields($tableName);
+
+ return $this->schemaManager->castRecordValues($records, $columns);
+ }
+
+ /**
+ * Parse Records values (including format date by ISO 8601) by its column type
+ *
+ * @param $records
+ * @param null $tableName
+ *
+ * @return array|mixed
+ */
+ public function parseRecord($records, $tableName = null)
+ {
+ // NOTE: Performance spot
+ if (is_array($records)) {
+ $tableName = $tableName === null ? $this->table : $tableName;
+ $records = $this->parseRecordValuesByType($records, $tableName);
+ $tableSchema = $this->getTableSchema($tableName);
+ $records = $this->convertDates($records, $tableSchema, $tableName);
+ }
+
+ return $records;
+ }
+
+ /**
+ * Enforce permission on Select
+ *
+ * @param Select $select
+ *
+ * @throws \Exception
+ */
+ protected function enforceSelectPermission(Select $select)
+ {
+ $selectState = $select->getRawState();
+ $table = $this->getRawTableNameFromQueryStateTable($selectState['table']);
+
+ // @TODO: enforce view permission
+
+ // Enforce field read blacklist on Select's main table
+ try {
+ // @TODO: Enforce must return a list of columns without the blacklist
+ // when asterisk (*) is used
+ // and only throw and error when all the selected columns are blacklisted
+ $this->acl->enforceReadField($table, $selectState['columns']);
+ } catch (\Exception $e) {
+ if ($selectState['columns'][0] != '*') {
+ throw $e;
+ }
+
+ $selectState['columns'] = SchemaService::getAllNonAliasCollectionFieldsName($table);
+ $this->acl->enforceReadField($table, $selectState['columns']);
+ }
+
+ // Enforce field read blacklist on Select's join tables
+ foreach ($selectState['joins'] as $join) {
+ $joinTable = $this->getRawTableNameFromQueryStateTable($join['name']);
+ $this->acl->enforceReadField($joinTable, $join['columns']);
+ }
+ }
+
+ /**
+ * Enforce permission on Insert
+ *
+ * @param Insert $insert
+ *
+ * @throws \Exception
+ */
+ public function enforceInsertPermission(Insert $insert)
+ {
+ $insertState = $insert->getRawState();
+ $insertTable = $this->getRawTableNameFromQueryStateTable($insertState['table']);
+
+ $statusValue = null;
+ $statusField = $this->getTableSchema()->getStatusField();
+ if ($statusField) {
+ $valueKey = array_search($statusField->getName(), $insertState['columns']);
+ if ($valueKey !== false) {;
+ $statusValue = ArrayUtils::get($insertState['values'], $valueKey);
+ } else {
+ $statusValue = $statusField->getDefaultValue();
+ }
+ }
+
+ $this->acl->enforceCreate($insertTable, $statusValue);
+ }
+
+ /**
+ * @param Builder $builder
+ */
+ protected function enforceReadPermission(Builder $builder)
+ {
+ // ----------------------------------------------------------------------------
+ // Make sure the user has permission to at least their items
+ // ----------------------------------------------------------------------------
+ $this->acl->enforceReadOnce($this->table);
+ $collectionObject = $this->getTableSchema();
+ $userCreatedField = $collectionObject->getUserCreateField();
+ $statusField = $collectionObject->getStatusField();
+
+ // If there's not user created interface, user must have full read permission
+ if (!$userCreatedField && !$statusField) {
+ $this->acl->enforceReadAll($this->table);
+ return;
+ }
+
+ // User can read all items, nothing else to check
+ if ($this->acl->canReadAll($this->table)) {
+ return;
+ }
+
+ $groupUsersId = get_user_ids_in_group($this->acl->getRolesId());
+ $authenticatedUserId = $this->acl->getUserId();
+ $statuses = $this->acl->getCollectionStatuses($this->table);
+
+ if (empty($statuses)) {
+ $ownerIds = [$authenticatedUserId];
+ if ($this->acl->canReadFromGroup($this->table)) {
+ $ownerIds = array_merge(
+ $ownerIds,
+ $groupUsersId
+ );
+ }
+
+ $builder->whereIn($userCreatedField->getName(), $ownerIds);
+ } else {
+ $collection = $this->table;
+ $builder->nestWhere(function (Builder $builder) use ($collection, $statuses, $statusField, $userCreatedField, $groupUsersId, $authenticatedUserId) {
+ foreach ($statuses as $status) {
+ $canReadAll = $this->acl->canReadAll($collection, $status);
+ $canReadMine = $this->acl->canReadMine($collection, $status);
+
+ if ((!$canReadAll && !$userCreatedField) || !$canReadMine) {
+ continue;
+ }
+
+ $ownerIds = $canReadAll ? null : [$authenticatedUserId];
+ $canReadFromGroup = $this->acl->canReadFromGroup($collection, $status);
+ if (!$canReadAll && $canReadFromGroup) {
+ $ownerIds = array_merge(
+ $ownerIds,
+ $groupUsersId
+ );
+ }
+
+ $builder->nestOrWhere(function (Builder $builder) use ($statuses, $ownerIds, $statusField, $userCreatedField, $status) {
+ if ($ownerIds) {
+ $builder->whereIn($userCreatedField->getName(), $ownerIds);
+ }
+
+ $builder->whereEqualTo($statusField->getName(), $status);
+ });
+ }
+
+
+ });
+ }
+ }
+
+ /**
+ * Enforce permission on Update
+ *
+ * @param Update $update
+ *
+ * @throws \Exception
+ */
+ public function enforceUpdatePermission(Update $update)
+ {
+ if ($this->acl->canUpdateAll($this->table)) {
+ return;
+ }
+
+ $collectionObject = $this->getTableSchema();
+ $currentUserId = $this->acl->getUserId();
+ $updateState = $update->getRawState();
+ $updateTable = $this->getRawTableNameFromQueryStateTable($updateState['table']);
+ $select = $this->sql->select();
+ $select->where($updateState['where']);
+ $select->limit(1);
+ $item = $this->ignoreFilters()->selectWith($select)->toArray();
+ $item = reset($item);
+ $statusId = null;
+
+ // Item not found, item cannot be updated
+ if (!$item) {
+ throw new ForbiddenCollectionUpdateException($updateTable);
+ }
+
+ // Enforce write field blacklist
+ $this->acl->enforceWriteField($updateTable, array_keys($updateState['set']));
+
+ if ($collectionObject->hasStatusField()) {
+ $statusField = $this->getTableSchema()->getStatusField();
+ $statusId = $item[$statusField->getName()];
+ }
+
+ // User Created Interface not found, item cannot be updated
+ $itemOwnerField = $this->getTableSchema()->getUserCreateField();
+ if (!$itemOwnerField) {
+ $this->acl->enforceUpdateAll($updateTable, $statusId);
+ return;
+ }
+
+ // Owner not found, item cannot be updated
+ $owner = get_item_owner($updateTable, $item[$collectionObject->getPrimaryKeyName()]);
+ if (!is_array($owner)) {
+ throw new ForbiddenCollectionUpdateException($updateTable);
+ }
+
+ $userItem = $currentUserId === $owner['id'];
+ $hasRole = $this->acl->hasRole($owner['role']);
+ if (!$userItem && !$hasRole && !$this->acl->canUpdateAll($updateTable, $statusId)) {
+ throw new ForbiddenCollectionUpdateException($updateTable);
+ }
+
+ if (!$userItem && $hasRole) {
+ $this->acl->enforceUpdateFromGroup($updateTable, $statusId);
+ } else if ($userItem) {
+ $this->acl->enforceUpdate($updateTable, $statusId);
+ }
+ }
+
+ /**
+ * Enforce permission on Delete
+ *
+ * @param Delete $delete
+ *
+ * @throws ForbiddenCollectionDeleteException
+ */
+ public function enforceDeletePermission(Delete $delete)
+ {
+ $collectionObject = $this->getTableSchema();
+ $currentUserId = $this->acl->getUserId();
+ $deleteState = $delete->getRawState();
+ $deleteTable = $this->getRawTableNameFromQueryStateTable($deleteState['table']);
+ // $cmsOwnerColumn = $this->acl->getCmsOwnerColumnByTable($deleteTable);
+ // $canBigDelete = $this->acl->hasTablePrivilege($deleteTable, 'bigdelete');
+ // $canDelete = $this->acl->hasTablePrivilege($deleteTable, 'delete');
+ // $aclErrorPrefix = $this->acl->getErrorMessagePrefix();
+
+ $select = $this->sql->select();
+ $select->where($deleteState['where']);
+ $select->limit(1);
+ $item = $this->ignoreFilters()->selectWith($select)->toArray();
+ $item = reset($item);
+ $statusId = null;
+
+ // Item not found, item cannot be updated
+ if (!$item) {
+ throw new ItemNotFoundException();
+ }
+
+ if ($collectionObject->hasStatusField()) {
+ $statusField = $this->getTableSchema()->getStatusField();
+ $statusId = $item[$statusField->getName()];
+ }
+
+ // User Created Interface not found, item cannot be updated
+ $itemOwnerField = $this->getTableSchema()->getUserCreateField();
+ if (!$itemOwnerField) {
+ $this->acl->enforceDeleteAll($deleteTable, $statusId);
+ return;
+ }
+
+ // Owner not found, item cannot be updated
+ $owner = get_item_owner($deleteTable, $item[$collectionObject->getPrimaryKeyName()]);
+ if (!is_array($owner)) {
+ throw new ForbiddenCollectionDeleteException($deleteTable);
+ }
+
+ $userItem = $currentUserId === $owner['id'];
+ $hasRole = $this->acl->hasRole($owner['role']);
+ if (!$userItem && !$hasRole && !$this->acl->canDeleteAll($deleteTable, $statusId)) {
+ throw new ForbiddenCollectionDeleteException($deleteTable);
+ }
+
+ if (!$userItem && $hasRole) {
+ $this->acl->enforceDeleteFromGroup($deleteTable, $statusId);
+ } else if ($userItem) {
+ $this->acl->enforceDelete($deleteTable, $statusId);
+ }
+
+ // @todo: clean way
+ // @TODO: this doesn't need to be bigdelete
+ // the user can only delete their own entry
+ // if ($deleteTable === 'directus_bookmarks') {
+ // $canBigDelete = true;
+ // }
+
+ // @TODO: Update conditions
+ // =============================================================================
+ // Cannot delete if there's no magic owner column and can't big delete
+ // All deletes are "big" deletes if there is no magic owner column.
+ // =============================================================================
+ // if (false === $cmsOwnerColumn && !$canBigDelete) {
+ // throw new ForbiddenCollectionDeleteException($aclErrorPrefix . 'The table `' . $deleteTable . '` is missing the `user_create_column` within `directus_collections` (BigHardDelete Permission Forbidden)');
+ // } else if (!$canBigDelete) {
+ // // Who are the owners of these rows?
+ // list($predicateResultQty, $predicateOwnerIds) = $this->acl->getCmsOwnerIdsByTableGatewayAndPredicate($this, $deleteState['where']);
+ // if (!in_array($currentUserId, $predicateOwnerIds)) {
+ // // $exceptionMessage = "Table harddelete access forbidden on $predicateResultQty `$deleteTable` table records owned by the authenticated CMS user (#$currentUserId).";
+ // $groupsTableGateway = $this->makeTable('directus_roles');
+ // $group = $groupsTableGateway->find($this->acl->getGroupId());
+ // $exceptionMessage = '[' . $group['name'] . '] permissions only allow you to [delete] your own items.';
+ // // $aclErrorPrefix = $this->acl->getErrorMessagePrefix();
+ // throw new ForbiddenCollectionDeleteException($exceptionMessage);
+ // }
+ // }
+ }
+
+ /**
+ * Get the column identifier with the specific quote and table prefixed
+ *
+ * @param string $column
+ * @param string|null $table
+ *
+ * @return string
+ */
+ public function getColumnIdentifier($column, $table = null)
+ {
+ $platform = $this->getAdapter()->getPlatform();
+
+ // TODO: find a common place to share this code
+ // It is a duplicated code from Builder.php
+ if (strpos($column, $platform->getIdentifierSeparator()) === false) {
+ $column = implode($platform->getIdentifierSeparator(), [$table, $column]);
+ }
+
+ return $column;
+ }
+
+ /**
+ * Get the column name from the identifier
+ *
+ * @param string $column
+ *
+ * @return string
+ */
+ public function getColumnFromIdentifier($column)
+ {
+ $platform = $this->getAdapter()->getPlatform();
+
+ // TODO: find a common place to share this code
+ // It is duplicated code in Builder.php
+ if (strpos($column, $platform->getIdentifierSeparator()) !== false) {
+ $identifierParts = explode($platform->getIdentifierSeparator(), $column);
+ $column = array_pop($identifierParts);
+ }
+
+ return $column;
+ }
+
+ /**
+ * Get the table name from the identifier
+ *
+ * @param string $column
+ * @param string|null $table
+ *
+ * @return string
+ */
+ public function getTableFromIdentifier($column, $table = null)
+ {
+ $platform = $this->getAdapter()->getPlatform();
+
+ if ($table === null) {
+ $table = $this->getTable();
+ }
+
+ // TODO: find a common place to share this code
+ // It is duplicated code in Builder.php
+ if (strpos($column, $platform->getIdentifierSeparator()) !== false) {
+ $identifierParts = explode($platform->getIdentifierSeparator(), $column);
+ $table = array_shift($identifierParts);
+ }
+
+ return $table;
+ }
+
+ /**
+ * Gets schema manager
+ *
+ * @return SchemaManager|null
+ */
+ public function getSchemaManager()
+ {
+ return $this->schemaManager;
+ }
+
+ /**
+ * Set application container
+ *
+ * @param $container
+ */
+ public static function setContainer($container)
+ {
+ static::$container = $container;
+ }
+
+ /**
+ * @return Container
+ */
+ public static function getContainer()
+ {
+ return static::$container;
+ }
+
+ public static function setHookEmitter($emitter)
+ {
+ static::$emitter = $emitter;
+ }
+
+ public function runHook($name, $args = null)
+ {
+ if (static::$emitter) {
+ static::$emitter->execute($name, $args);
+ }
+ }
+
+ /**
+ * Apply a list of hook against the given data
+ *
+ * @param array $names
+ * @param null $data
+ * @param array $attributes
+ *
+ * @return array|\ArrayObject|null
+ */
+ public function applyHooks(array $names, $data = null, array $attributes = [])
+ {
+ foreach ($names as $name) {
+ $data = $this->applyHook($name, $data, $attributes);
+ }
+
+ return $data;
+ }
+
+ /**
+ * Apply hook against the given data
+ *
+ * @param $name
+ * @param null $data
+ * @param array $attributes
+ *
+ * @return \ArrayObject|array|null
+ */
+ public function applyHook($name, $data = null, array $attributes = [])
+ {
+ // TODO: Ability to run multiple hook names
+ // $this->applyHook('hook1,hook2');
+ // $this->applyHook(['hook1', 'hook2']);
+ // ----------------------------------------------------------------------------
+ // TODO: Move this to a separate class to handle common events
+ // $this->applyNewRecord($table, $record);
+ if (static::$emitter && static::$emitter->hasFilterListeners($name)) {
+ $isResultSet = $data instanceof ResultSetInterface;
+ $resultSet = null;
+
+ if ($isResultSet) {
+ $resultSet = $data;
+ $data = $resultSet->toArray();
+ }
+
+ $data = static::$emitter->apply($name, $data, $attributes);
+
+ if ($isResultSet && $resultSet) {
+ $data = new \ArrayObject($data);
+ $resultSet->initialize($data->getIterator());
+ $data = $resultSet;
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * Run before table update hooks and filters
+ *
+ * @param string $updateCollectionName
+ * @param array $updateData
+ *
+ * @return array|\ArrayObject
+ */
+ protected function runBeforeUpdateHooks($updateCollectionName, $updateData)
+ {
+ // Filters
+ $updateData = $this->applyHook('collection.update:before', $updateData, [
+ 'collection_name' => $updateCollectionName
+ ]);
+ $updateData = $this->applyHook('collection.update.' . $updateCollectionName . ':before', $updateData);
+
+ // Hooks
+ $this->runHook('collection.update:before', [$updateCollectionName, $updateData]);
+ $this->runHook('collection.update.' . $updateCollectionName . ':before', [$updateData]);
+
+ return $updateData;
+ }
+
+ /**
+ * Run after table update hooks and filters
+ *
+ * @param string $updateTable
+ * @param string $updateData
+ */
+ protected function runAfterUpdateHooks($updateTable, $updateData)
+ {
+ $this->runHook('collection.update', [$updateTable, $updateData]);
+ $this->runHook('collection.update:after', [$updateTable, $updateData]);
+ $this->runHook('collection.update.' . $updateTable, [$updateData]);
+ $this->runHook('collection.update.' . $updateTable . ':after', [$updateData]);
+ }
+
+ /**
+ * Gets Directus settings (from DB)
+ *
+ * @param null|string $scope
+ * @param null|string $key
+ *
+ * @return mixed
+ */
+ public function getSettings($scope, $key = null)
+ {
+ $settings = [];
+
+ if (!static::$container) {
+ return $settings;
+ }
+
+ if ($key !== null) {
+ $settings = get_directus_setting($scope, $key);
+ } else {
+ $settings = get_kv_directus_settings($scope);
+ }
+
+ return $settings;
+ }
+
+ /**
+ * Get the table statuses
+ *
+ * @return array
+ */
+ public function getAllStatuses()
+ {
+ $statuses = [];
+ $statusMapping = $this->getStatusMapping();
+
+ if ($statusMapping) {
+ $statuses = $statusMapping->getAllStatusesValue();
+ }
+
+ return $statuses;
+ }
+
+ /**
+ * Gets the table published statuses
+ *
+ * @return array
+ */
+ public function getPublishedStatuses()
+ {
+ return $this->getStatuses('published');
+ }
+
+ /**
+ * Gets the table statuses with the given type
+ *
+ * @param $type
+ *
+ * @return array
+ */
+ protected function getStatuses($type)
+ {
+ $statuses = [];
+ $statusMapping = $this->getStatusMapping();
+
+ if ($statusMapping) {
+ switch ($type) {
+ case 'published':
+ $statuses = $statusMapping->getPublishedStatusesValue();
+ break;
+ }
+ }
+
+ return $statuses;
+ }
+
+ /**
+ * Gets the collection status mapping
+ *
+ * @return StatusMapping|null
+ *
+ * @throws CollectionHasNotStatusInterface
+ * @throws Exception
+ */
+ protected function getStatusMapping()
+ {
+ if (!$this->getTableSchema()->hasStatusField()) {
+ throw new CollectionHasNotStatusInterface($this->table);
+ }
+
+ $collectionStatusMapping = $this->getTableSchema()->getStatusMapping();
+ if (!$collectionStatusMapping) {
+ if (!static::$container) {
+ throw new Exception('collection status interface is missing status mapping and the system was unable to find the global status mapping');
+ }
+
+ $collectionStatusMapping = static::$container->get('status_mapping');
+ }
+
+ $this->validateStatusMapping($collectionStatusMapping);
+
+ return $collectionStatusMapping;
+ }
+
+ /**
+ * Validates a status mapping against the field type
+ *
+ * @param StatusMapping $statusMapping
+ *
+ * @throws CollectionHasNotStatusInterface
+ * @throws StatusMappingEmptyException
+ * @throws StatusMappingWrongValueTypeException
+ */
+ protected function validateStatusMapping(StatusMapping $statusMapping)
+ {
+ if ($statusMapping->isEmpty()) {
+ throw new StatusMappingEmptyException($this->table);
+ }
+
+ $statusField = $this->getTableSchema()->getStatusField();
+ if (!$statusField) {
+ throw new CollectionHasNotStatusInterface($this->table);
+ }
+
+ $type = 'string';
+ if (DataTypes::isNumericType($statusField->getOriginalType())) {
+ $type = 'numeric';
+ }
+
+ foreach ($statusMapping as $status) {
+ if (!call_user_func('is_' . $type, $status->getValue())) {
+ throw new StatusMappingWrongValueTypeException($type, $statusField->getName(), $this->table);
+ }
+ }
+ }
+}
diff --git a/src/core/Directus/Database/TableGateway/DirectusActivityTableGateway.php b/src/core/Directus/Database/TableGateway/DirectusActivityTableGateway.php
new file mode 100644
index 0000000000..db632b5bac
--- /dev/null
+++ b/src/core/Directus/Database/TableGateway/DirectusActivityTableGateway.php
@@ -0,0 +1,262 @@
+ 'DESC'];
+ $params = $this->applyDefaultEntriesSelectParams($params);
+ $builder = new Builder($this->getAdapter());
+ $builder->from($this->getTable());
+
+ // TODO: Move this to applyDefaultEntriesSelectParams method
+ $tableSchema = $this->getTableSchema();
+ $columns = SchemaService::getAllCollectionFieldsName($tableSchema->getName());
+ if (ArrayUtils::has($params, 'columns')) {
+ $columns = ArrayUtils::get($params, 'columns');
+ }
+
+ $builder->columns($columns);
+ $hasActiveColumn = $tableSchema->hasStatusColumn();
+
+ $builder = $this->applyParamsToTableEntriesSelect($params, $builder, $tableSchema, $hasActiveColumn);
+ $select = $builder->buildSelect();
+
+ $select
+ ->where
+ ->nest
+ ->isNull('parent_id')
+ ->OR
+ ->equalTo('type', 'FILES')
+ ->unnest;
+
+ $rowset = $this->selectWith($select);
+ $rowset = $rowset->toArray();
+
+ $countTotalWhere = new Where;
+ $countTotalWhere
+ ->isNull('parent_id')
+ ->OR
+ ->equalTo('type', 'FILES');
+
+ return $this->wrapData($this->parseRecord($rowset), false, ArrayUtils::get($params, 'meta', 0));
+ }
+
+ public function fetchRevisions($row_id, $table_name)
+ {
+ $columns = ['id', 'action', 'user', 'datetime'];
+
+ $sql = new Sql($this->adapter);
+ $select = $sql->select()
+ ->from($this->table)
+ ->columns($columns)
+ ->order('id DESC');
+ $select
+ ->where
+ ->equalTo('row_id', $row_id)
+ ->AND
+ ->equalTo('table_name', $table_name);
+
+ $result = $this->selectWith($select);
+ $result = $result->toArray();
+
+ return $this->loadMetadata($this->parseRecord($result));
+ }
+
+ public function recordLogin($userId)
+ {
+ $logData = [
+ 'type' => self::TYPE_LOGIN,
+ 'collection' => 'directus_users',
+ 'action' => self::ACTION_LOGIN,
+ 'user' => $userId,
+ 'item' => $userId,
+ 'datetime' => DateTimeUtils::nowInUTC()->toString(),
+ 'ip' => get_request_ip(),
+ 'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : ''
+ ];
+
+ $insert = new Insert($this->getTable());
+ $insert
+ ->values($logData);
+
+ $this->insertWith($insert);
+ }
+
+ /**
+ * Records a message activity
+ *
+ * @param $data
+ *
+ * @return \Directus\Database\RowGateway\BaseRowGateway
+ */
+ public function recordMessage($data)
+ {
+ $logData = array_merge($data, [
+ 'type' => self::TYPE_MESSAGE,
+ 'action' => static::ACTION_ADD,
+ 'datetime' => DateTimeUtils::nowInUTC()->toString(),
+ 'ip' => get_request_ip(),
+ 'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : ''
+ ]);
+
+ return $this->updateRecord($logData);
+ }
+
+ /**
+ * Get the last update date from a list of row ids in the given table
+ *
+ * @param string $table
+ * @param mixed $ids
+ * @param array $params
+ *
+ * @return array|null
+ */
+ public function getLastUpdated($table, $ids, array $params = [])
+ {
+ if (!is_array($ids)) {
+ $ids = [$ids];
+ }
+
+ $sql = new Sql($this->adapter);
+ $select = $sql->select($this->getTable());
+
+ $select->columns([
+ 'row_id',
+ 'user',
+ 'datetime' => new Expression('MAX(datetime)')
+ ]);
+
+ $select->where([
+ 'table_name' => $table,
+ 'type' => 'ENTRY',
+ new In('action', ['UPDATE', 'ADD']),
+ new In('row_id', $ids)
+ ]);
+
+ $select->group(['row_id', 'user']);
+ $select->order(['datetime' => 'DESC']);
+
+ $statement = $this->sql->prepareStatementForSqlObject($select);
+ $result = iterator_to_array($statement->execute());
+
+ return $this->wrapData($this->parseRecord($result), false, ArrayUtils::get($params, 'meta', 0));
+ }
+
+ public function getMetadata($table, $id)
+ {
+ $sql = new Sql($this->adapter);
+ $select = $sql->select($this->getTable());
+
+ $select->columns([
+ 'action',
+ 'user',
+ 'datetime' => new Expression('MAX(datetime)')
+ ]);
+
+ $on = 'directus_users.id = directus_activity.user';
+ $select->join('directus_users', $on, []);
+
+ $select->where([
+ 'table_name' => $table,
+ 'row_id' => $id,
+ 'type' => $table === 'directus_files' ? static::TYPE_FILES : static::TYPE_ENTRY,
+ new In('action', ['ADD', 'UPDATE'])
+ ]);
+
+ $select->group([
+ 'action',
+ 'user'
+ ]);
+
+ $select->limit(2);
+
+ $statement = $this->sql->prepareStatementForSqlObject($select);
+ $result = iterator_to_array($statement->execute());
+ $result = $this->parseRecord($result);
+
+ $data = [
+ 'created_on' => null,
+ 'created_by' => null,
+ 'updated_on' => null,
+ 'updated_by' => null
+ ];
+
+ foreach ($result as $row) {
+ switch (ArrayUtils::get($row, 'action')) {
+ case static::ACTION_ADD:
+ $data['created_by'] = $row['user'];
+ $data['created_on'] = $row['datetime'];
+ break;
+ case static::ACTION_UPDATE:
+ $data['updated_by'] = $row['user'];
+ $data['updated_on'] = $row['datetime'];
+ break;
+ }
+ }
+
+ if (!$data['updated_by'] && !$data['updated_on']) {
+ $data['updated_on'] = $data['created_on'];
+ $data['updated_by'] = $data['created_by'];
+ }
+
+ return $data;
+ }
+}
diff --git a/src/core/Directus/Database/TableGateway/DirectusCollectionPresetsTableGateway.php b/src/core/Directus/Database/TableGateway/DirectusCollectionPresetsTableGateway.php
new file mode 100644
index 0000000000..cd776b65ea
--- /dev/null
+++ b/src/core/Directus/Database/TableGateway/DirectusCollectionPresetsTableGateway.php
@@ -0,0 +1,364 @@
+ 'id',
+ 'sort_order' => 'ASC',
+ 'status' => '1,2',
+ 'title' => null
+ ];
+
+ public static $defaultPreferencesValuesByTable = [
+ 'directus_files' => [
+ 'sort' => 'date_uploaded',
+ 'sort_order' => 'DESC',
+ 'columns_visible' => 'name,title,caption,type,size,user,date_uploaded'
+ ]
+ ];
+
+ public function applyDefaultPreferences($table, $preferences)
+ {
+ // Table-specific default values
+ if (array_key_exists($table, self::$defaultPreferencesValuesByTable)) {
+ $tableDefaultPreferences = self::$defaultPreferencesValuesByTable[$table];
+ foreach ($tableDefaultPreferences as $field => $defaultValue) {
+ if (!isset($preferences[$field])) {
+ $preferences[$field] = $defaultValue;
+ }
+ }
+ }
+
+ // Global default values
+ $primaryKeyFieldName = SchemaService::getCollectionPrimaryKey($table);
+ if ($primaryKeyFieldName) {
+ self::$defaultPreferencesValues['sort'] = $primaryKeyFieldName;
+ }
+
+ foreach (self::$defaultPreferencesValues as $field => $defaultValue) {
+ if (!isset($preferences[$field]) || ('0' !== $preferences[$field] && empty($preferences[$field]))) {
+ if (!isset($preferences[$field])) {
+ $preferences[$field] = $defaultValue;
+ }
+ }
+ }
+
+ if (isset($preferences['sort'])) {
+ if (!SchemaService::hasCollectionSortField($table)) {
+ $preferences['sort'] = SchemaService::getCollectionSortField($table);
+ }
+ }
+
+ return $preferences;
+ }
+
+ public function constructPreferences($user_id, $table, $preferences = null, $title = null)
+ {
+ if ($preferences) {
+ $newPreferencesData = false;
+
+ // @todo enforce non-empty set
+ if (empty($preferences['columns_visible'])) {
+ $newPreferencesData = true;
+ $columns_visible = SchemaService::getCollectionFields($table, 6);
+ $preferences['columns_visible'] = implode(',', $columns_visible);
+ }
+
+ $preferencesDefaultsApplied = $this->applyDefaultPreferences($table, $preferences);
+ if (count(array_diff($preferences, $preferencesDefaultsApplied))) {
+ $newPreferencesData = true;
+ }
+ $preferences = $preferencesDefaultsApplied;
+ if ($newPreferencesData) {
+ $id = $this->addOrUpdateRecordByArray($preferences);
+ }
+ return $preferences;
+ }
+
+ $insert = new Insert($this->table);
+
+ // User doesn't have any preferences for this table yet. Please create!
+ $columns_visible = SchemaService::getCollectionFields($table, 6);
+ $data = [
+ 'user' => $user_id,
+ 'columns_visible' => implode(',', $columns_visible),
+ 'table_name' => $table,
+ 'title' => $title
+ ];
+
+ if (SchemaService::hasCollectionSortField($table)) {
+ $data['sort'] = SchemaService::getCollectionSortField($table);
+ }
+
+ $data = $this->applyDefaultPreferences($table, $data);
+
+ $insert
+ ->values($data);
+
+ $this->insertWith($insert);
+
+ return $data;
+ }
+
+ public function fetchByUserAndTableAndTitle($user_id, $table, $title = null, array $columns = [])
+ {
+ $select = new Select($this->table);
+
+ if (!empty($columns)) {
+ $select->columns(array_merge([$this->primaryKeyFieldName], $columns));
+ }
+
+ $select->limit(1);
+ $select
+ ->where
+ ->equalTo('table_name', $table)
+ ->equalTo('user', $user_id);
+
+ if ($title) {
+ $select->where->equalTo('title', $title);
+ } else {
+ $select->where->isNull('title');
+ }
+
+ $preferences = $this
+ ->selectWith($select)
+ ->current();
+
+ if ($preferences) {
+ $preferences = $preferences->toArray();
+ }
+
+ if ($preferences) {
+ $preferences = $this->constructPreferences($user_id, $table, $preferences);
+ }
+
+ if ($preferences && !empty($columns)) {
+ $preferences = ArrayUtils::pick($preferences, $columns);
+ }
+
+ return $this->parseRecord($preferences);
+ }
+
+ /**
+ * @deprecated
+ * @param $user_id
+ * @param $title
+ * @return array
+ */
+ public function fetchByUserAndTitle($user_id, $title)
+ {
+ $result = $this->fetchEntityByUserAndTitle($user_id, $title);
+ return (isset($result['data'])) ? $result['data'] : [];
+
+ }
+
+ /**
+ * @param int $user_id
+ * @param string $title
+ * @param array $params
+ *
+ * @return array|mixed
+ */
+ public function fetchEntityByUserAndTitle($user_id, $title, array $params = [])
+ {
+ // TODO: Merge with fetchByUserAndTableAndTitle
+ $fields = ArrayUtils::get($params, 'fields');
+ if (!empty($fields)) {
+ if (!is_array($fields)) {
+ $fields = StringUtils::csv($fields);
+ }
+
+ $params['fields'] = array_merge(['table_name'], $fields);
+ }
+
+ $result = $this->loadItems(array_merge($params, [
+ 'single' => true,
+ 'filters' => [
+ 'user' => $user_id,
+ 'title' => $title
+ ]
+ ]));
+
+ $result = $result
+ ? $this->constructPreferences($user_id, $result['table_name'], $result)
+ : [];
+
+ if (!empty($fields)) {
+ $result = ArrayUtils::pick($result, $fields);
+ }
+
+ return ['data' => $result];
+ }
+
+ /*
+ * Temporary while I figured out why the method above
+ * doesn't not construct preferences on table without preferences.
+ */
+ public function fetchByUserAndTable($user_id, $table, array $columns = [])
+ {
+ $select = new Select($this->table);
+
+ if (!empty($columns)) {
+ $select->columns([$this->primaryKeyFieldName], $columns);
+ }
+
+ $select->limit(1);
+ $select
+ ->where
+ ->equalTo('table_name', $table)
+ ->equalTo('user', $user_id);
+
+ $preferences = $this
+ ->selectWith($select)
+ ->current();
+
+ if (!$preferences) {
+ return $this->constructPreferences($user_id, $table);
+ }
+
+ if ($preferences) {
+ $preferences = $preferences->toArray();
+ }
+
+ if ($preferences && !empty($columns)) {
+ $preferences = ArrayUtils::pick($preferences, $columns);
+ }
+
+ return $this->parseRecord($preferences);
+ }
+
+ public function updateDefaultByName($user_id, $table, $data)
+ {
+ $update = new Update($this->table);
+ unset($data['id']);
+ unset($data['title']);
+ unset($data['table_name']);
+ unset($data['user']);
+ if (!isset($data) || !is_array($data)) {
+ $data = [];
+ }
+ $update->set($data)
+ ->where
+ ->equalTo('table_name', $table)
+ ->equalTo('user', $user_id)
+ ->isNull('title');
+ $this->updateWith($update);
+ }
+
+ // @param $assoc return associative array with table_name as keys
+ public function fetchAllByUser($user_id, $assoc = false)
+ {
+ $select = new Select($this->table);
+ $select->columns([
+ 'id',
+ 'user',
+ 'table_name',
+ 'columns_visible',
+ 'sort',
+ 'sort_order',
+ 'status',
+ 'title',
+ 'search_string',
+ 'list_view_options'
+ ]);
+
+ $select->where->equalTo('user', $user_id)
+ ->isNull('title');
+
+ $coreTables = $this->schemaManager->getDirectusTables(static::$IGNORED_TABLES);
+
+ $select->where->addPredicate(new NotIn('table_name', $coreTables));
+ $metadata = new \Zend\Db\Metadata\Metadata($this->getAdapter());
+
+ $tables = $metadata->getTableNames();
+
+ $tables = array_diff($tables, $coreTables);
+
+ $rows = $this->selectWith($select)->toArray();
+
+ $preferences = [];
+ $tablePrefs = [];
+
+ foreach ($rows as $row) {
+ $tablePrefs[$row['table_name']] = $row;
+ }
+
+ //Get Default Preferences
+ foreach ($tables as $key => $table) {
+ // Honor ACL. Skip the tables that the user doesn't have access too
+ if (!SchemaService::canGroupReadCollection($table)) {
+ continue;
+ }
+
+ $tableName = $table;
+
+ if (!isset($tablePrefs[$table])) {
+ $table = null;
+ } else {
+ $table = $tablePrefs[$table];
+ }
+
+ if (!isset($table['user'])) {
+ $table = null;
+ }
+
+ $table = $this->constructPreferences($user_id, $tableName, $table);
+ $preferences[$tableName] = $table;
+ }
+
+ return $preferences;
+ }
+
+ public function fetchSavedPreferencesByUserAndTable($user_id, $table)
+ {
+ $select = new Select($this->table);
+ $select
+ ->where
+ ->equalTo('table_name', $table)
+ ->equalTo('user', $user_id)
+ ->isNotNull('title');
+
+ $preferences = $this
+ ->selectWith($select);
+
+ if ($preferences) {
+ $preferences = $preferences->toArray();
+ }
+
+ return $preferences;
+ }
+}
diff --git a/src/core/Directus/Database/TableGateway/DirectusCollectionsTableGateway.php b/src/core/Directus/Database/TableGateway/DirectusCollectionsTableGateway.php
new file mode 100644
index 0000000000..bdab7d217d
--- /dev/null
+++ b/src/core/Directus/Database/TableGateway/DirectusCollectionsTableGateway.php
@@ -0,0 +1,18 @@
+getTable());
+ $select
+ ->columns(['id', 'message_id', 'recipient', 'read'])
+ ->where->in('message_id', $messageIds);
+
+ $records = $this->selectWith($select)->toArray();
+ $recipientMap = [];
+
+ foreach ($records as $record) {
+ $messageId = $record['message_id'];
+ $recipient = $record['recipient'];
+ $read = (bool) $record['read'];
+
+ if (!array_key_exists($messageId, $recipientMap)) {
+ $recipientMap[$messageId] = [];
+ }
+
+ $recipientMap[$messageId]['recipients'][] = $recipient;
+ if ($read) {
+ $recipientMap[$messageId]['reads'][] = $recipient;
+ }
+ }
+
+ return $recipientMap;
+ }
+
+ public function markAsRead($messageIds, $uid)
+ {
+ $update = new Update($this->getTable());
+ $update
+ ->set(['read' => 1])
+ ->where->in('message_id', $messageIds)
+ ->and
+ ->where->equalTo('recipient', $uid);
+
+ return $this->updateWith($update);
+ }
+
+ public function countMessages($uid)
+ {
+ $fetchFn = function () use ($uid) {
+ $select = new Select($this->table);
+
+ $select
+ ->columns([
+ 'recipient',
+ 'read' => new Expression('SUM(IF(`read`=1,1,0))'),
+ 'total' => new Expression('COUNT(`id`)'),
+ 'max_id' => new Expression('MAX(`message_id`)')
+ ])
+ ->where->equalTo('recipient', $uid);
+
+ $select->group(['recipient']);
+
+ $result = $this->selectWith($select)->current();
+ $result = $result ? $result->toArray() : [];
+
+ // convert all elements into integer
+ foreach ($result as $key => $value) {
+ $result[$key] = (int) $value;
+ }
+
+ return $result;
+ };
+
+ $result = $fetchFn();
+
+ if ($result) {
+ $result['unread'] = $result['total'] - $result['read'];
+ }
+
+ return $result;
+ }
+
+ public function getMessagesNewerThan($maxId, $currentUser)
+ {
+ $fetchFn = function () use ($maxId, $currentUser) {
+ $select = new Select($this->getTable());
+ $select
+ ->columns(['id', 'message_id'])
+ ->join('directus_messages', 'directus_messages_recipients.message_id = directus_messages.id', ['response_to'])
+ ->where
+ ->greaterThan('message_id', $maxId)
+ ->and
+ ->equalTo('recipient', $currentUser)
+ ->and
+ ->equalTo('read', 0);
+ $result = $this->selectWith($select)->toArray();
+ return $result;
+ };
+ // $cacheKey = MemcacheProvider::getKeyDirectusMessagesNewerThan($maxId, $currentUser);
+ // $result = $this->memcache->getOrCache($cacheKey, $fetchFn, 1800);
+ $result = $fetchFn();
+
+ $messageThreads = [];
+
+ foreach ($result as $message) {
+ $messageThreads[] = empty($message['response_to']) ? $message['message_id'] : $message['response_to'];
+ }
+
+ return array_values(array_unique($messageThreads));
+ }
+
+ public function archiveMessages($userId, $messagesIds)
+ {
+ $payload = ['archived' => 1];
+
+ $select = new Select('directus_messages');
+ $select
+ ->columns(['id'])
+ ->where
+ ->in('id', $messagesIds)
+ ->or
+ ->in('response_to', $messagesIds);
+
+ $result = $this->selectWith($select);
+
+ if (!$result) {
+ return false;
+ }
+
+ $result = $result->toArray();
+
+ $responsesIds = [];
+ foreach ($result as $item) {
+ array_push($responsesIds, ArrayUtils::get($item, 'id'));
+ }
+
+ $update = new Update($this->getTable());
+ $update->set($payload);
+ $update
+ ->where
+ ->equalTo('recipient', $userId)
+ ->in('message_id', $responsesIds);
+
+ return $this->updateWith($update);
+ }
+}
diff --git a/src/core/Directus/Database/TableGateway/DirectusMessagesTableGateway.php b/src/core/Directus/Database/TableGateway/DirectusMessagesTableGateway.php
new file mode 100644
index 0000000000..89b11b527c
--- /dev/null
+++ b/src/core/Directus/Database/TableGateway/DirectusMessagesTableGateway.php
@@ -0,0 +1,238 @@
+ null
+ ];
+
+ $payload = array_merge($defaultValues, $payload);
+
+ $insert = new Insert($this->getTable());
+ $insert
+ ->columns(['from', 'subject', 'message'])
+ ->values([
+ 'from' => $from,
+ 'subject' => $payload['subject'],
+ 'message' => $payload['message'],
+ 'datetime' => DateTimeUtils::nowInUTC()->toString(),
+ 'attachment' => ArrayUtils::get($payload, 'attachment'),
+ 'comment_metadata' => ArrayUtils::get($payload, 'comment_metadata'),
+ 'response_to' => $payload['response_to']
+ ]);
+
+ $this->insertWith($insert);
+
+ $messageId = $this->lastInsertValue;
+
+ // Insert recipients
+ $values = [];
+ foreach ($recipients as $recipient) {
+ $read = 0;
+ if ((int)$recipient == (int)$from) {
+ $read = 1;
+ }
+ $values[] = '(' . $messageId . ', ' . $recipient . ', ' . $read . ')';
+ }
+
+ $valuesString = implode(',', $values);
+
+ // TODO: sanitize and implement ACL
+ $sql = 'INSERT INTO directus_messages_recipients (`message_id`, `recipient`, `read`) VALUES ' . $valuesString;
+
+ $this->adapter->query($sql, Adapter::QUERY_MODE_EXECUTE);
+
+ return $messageId;
+ }
+
+ public function fetchMessageThreads($ids, $uid, array $states = [])
+ {
+ if (empty($states)) {
+ $states = [0];
+ }
+
+ $select = new Select($this->getTable());
+ $select
+ ->columns([
+ 'id',
+ 'from',
+ 'subject',
+ 'message',
+ 'attachment',
+ 'datetime',
+ 'response_to',
+ 'comment_metadata'
+ ])
+ ->join('directus_messages_recipients', 'directus_messages.id = directus_messages_recipients.message_id', ['read', 'archived'])
+ ->where
+ ->equalTo('directus_messages_recipients.recipient', $uid)
+ ->and
+ ->in('directus_messages_recipients.archived', $states)
+ ->and
+ ->where
+ ->nest
+ ->in('directus_messages.response_to', $ids)
+ ->or
+ ->in('directus_messages.id', $ids)
+ ->unnest;
+
+ $result = $this->selectWith($select)->toArray();
+
+ foreach ($result as &$message) {
+ $message = $this->parseRecordValuesByType($message, 'directus_messages_recipients');
+ }
+
+ return $result;
+ }
+
+ public function fetchMessageWithRecipients($id, $uid)
+ {
+ $result = $this->fetchMessagesInbox($uid, $id);
+
+ return count($result) > 0 ? $result[0] : [];
+ }
+
+ public function fetchMessagesInbox($uid, $messageId = null, $params = [])
+ {
+ $messageIds = [];
+ $params['columns'] = ArrayUtils::get($params, 'columns', 'id');
+ $table = $this;
+
+ // ----------------------------------------------------------------------------
+ // NOTE: state are a fake way to call the "state" of a message
+ // 0 means in the inbox
+ // 1 means in the archive box
+ // ----------------------------------------------------------------------------
+ $defaultState = '0';
+ $states = ArrayUtils::get($params, 'states', $defaultState);
+ if (!$states) {
+ $states = $defaultState;
+ }
+
+ $states = explode(',', $states);
+
+ $result = $this->loadItems($params, function (Builder $query) use ($uid, $table, $messageId, $states) {
+ $query->join('directus_messages_recipients', 'directus_messages_recipients.message_id = directus_messages.id', [
+ 'recipient'
+ ]);
+
+ $query->whereEqualTo('directus_messages_recipients.recipient', $uid);
+ $query->whereIn('directus_messages_recipients.archived', $states);
+
+ if ($messageId) {
+ if (! is_array($messageId)) {
+ $messageId = [$messageId];
+ }
+
+ $query->whereIn('directus_messages.id', $messageId);
+ }
+
+ return $query;
+ });
+
+ foreach ($result as $message) {
+ $messageIds[] = $message['id'];
+ }
+
+ if (count($messageIds) === 0) {
+ return [];
+ };
+
+ $result = $this->fetchMessageThreads($messageIds, $uid, $states);
+
+ if (count($result) === 0) {
+ return [];
+ }
+
+ $resultLookup = [];
+ $ids = [];
+
+ // Grab ids;
+ foreach ($result as $item) {
+ $ids[] = $item['id'];
+ }
+
+ $directusMessagesTableGateway = new DirectusMessagesRecipientsTableGateway($this->adapter, $this->acl);
+ $recipients = $directusMessagesTableGateway->fetchMessageRecipients($ids);
+
+ foreach ($result as $item) {
+ $recipientsData = $recipients[$item['id']];
+ $item['responses'] = ['data' => []];
+ $item['recipients'] = implode(',', ArrayUtils::get($recipientsData, 'recipients', []));
+ $item['reads'] = implode(',', ArrayUtils::get($recipientsData, 'reads', []));
+ $resultLookup[$item['id']] = $item;
+ }
+
+ foreach ($result as $item) {
+ if ($item['response_to'] != null) {
+ // Move it to resultLookup
+ $message = $resultLookup[$item['id']];
+ unset($resultLookup[$item['id']]);
+ $message = $this->parseRecord($message);
+ $resultLookup[$item['response_to']]['responses']['data'][] = $message;
+ }
+ }
+
+ $result = array_values($resultLookup);
+ foreach ($result as $key => &$row) {
+ $row = $this->parseRecord($row);
+ }
+
+ // Add date_updated
+ // Update read
+ foreach ($result as &$message) {
+ $responses = $message['responses']['data'];
+
+ $lastResponse = (end($responses));
+ if ($lastResponse) {
+ $message['date_updated'] = $lastResponse['datetime'];
+ } else {
+ $message['date_updated'] = $message['datetime'];
+ }
+ }
+
+ return $result;
+ }
+
+ public function fetchMessagesInboxWithHeaders($uid, $messageIds = null, $params = [])
+ {
+ $messagesRecipientsTableGateway = new DirectusMessagesRecipientsTableGateway($this->adapter, $this->acl);
+ $result = $messagesRecipientsTableGateway->countMessages($uid);
+ $result['data'] = $this->fetchMessagesInbox($uid, $messageIds, $params);
+
+ return $result;
+ }
+
+ public function fetchComments($commentMetadata)
+ {
+ $select = new Select($this->table);
+ $select
+ ->where->equalTo('comment_metadata', $commentMetadata);
+
+ $result = $this->selectWith($select)->toArray();
+
+ return $result;
+ }
+}
diff --git a/src/core/Directus/Database/TableGateway/DirectusPermissionsTableGateway.php b/src/core/Directus/Database/TableGateway/DirectusPermissionsTableGateway.php
new file mode 100644
index 0000000000..d0f034cbbe
--- /dev/null
+++ b/src/core/Directus/Database/TableGateway/DirectusPermissionsTableGateway.php
@@ -0,0 +1,299 @@
+ $this->table]);
+
+ $subSelect = new Select(['ur' => 'directus_user_roles']);
+ $subSelect->where->equalTo('user', $userId);
+ $subSelect->limit(1);
+
+ $select->join(
+ ['ur' => $subSelect],
+ 'p.role = ur.role',
+ [
+ 'user_role' => 'role'
+ ],
+ $select::JOIN_RIGHT
+ );
+
+ $select->where->equalTo('ur.user', $userId);
+
+ $statement = $this->sql->prepareStatementForSqlObject($select);
+ $result = $statement->execute();
+
+ $permissionsByCollection = [];
+ foreach ($result as $permission) {
+ foreach ($permission as $field => &$value) {
+ if (in_array($field, ['read_field_blacklist', 'write_field_blacklist'])) {
+ $value = explode(',', $value);
+ }
+ }
+
+ ArrayUtils::rename($permission, 'user_role', 'role');
+ $permissionsByCollection[$permission['collection']][] = $this->parseRecord($permission);
+ }
+
+ return $permissionsByCollection;
+ }
+
+ // @TODO: move it to another object.
+ private function isCurrentUserAdmin()
+ {
+ if (!$this->acl) {
+ return true;
+ }
+
+ //Dont let non-admins have alter privilege
+ return ($this->acl->getGroupId() == 1) ? true : false;
+ }
+
+ private function verifyPrivilege($attributes)
+ {
+ // Making sure alter is set for admin only.
+ if (array_key_exists('allow_alter', $attributes)) {
+ if ($this->isCurrentUserAdmin()) {
+ $attributes['allow_alter'] = 1;
+ } else {
+ $attributes['allow_alter'] = 0;
+ }
+ }
+
+ return $attributes;
+ }
+
+ /**
+ * Get Permissions for the given Group ID
+ * @param $groupId
+ *
+ * @return array
+ */
+ public function getGroupPrivileges($groupId)
+ {
+ return $this->fetchGroupPrivileges($groupId);
+ }
+
+ public function fetchGroupPrivileges($groupId, $statusId = false)
+ {
+ $select = new Select($this->table);
+ $select->where->equalTo('group', $groupId);
+
+ if ($statusId !== false) {
+ if ($statusId === null) {
+ $select->where->isNull('status');
+ } else {
+ $select->where->equalTo('status', $statusId);
+ }
+ }
+
+ $rowset = $this->selectWith($select);
+ $rowset = $rowset->toArray();
+
+ $privilegesByTable = [];
+ foreach ($rowset as $row) {
+ foreach ($row as $field => &$value) {
+ if (in_array($field, ['read_field_blacklist', 'write_field_blacklist'])) {
+ $value = explode(',', $value);
+ }
+ }
+
+ $privilegesByTable[$row['collection']][] = $this->parseRecord($row);
+ }
+
+ return $privilegesByTable;
+ }
+
+ public function fetchById($privilegeId)
+ {
+ $select = new Select($this->table);
+ $select->where->equalTo('id', $privilegeId);
+ $rowset = $this->selectWith($select);
+ $rowset = $rowset->toArray();
+
+ return $this->parseRecord(current($rowset));
+ }
+
+ // @todo This currently only supports permissions,
+ // include blacklists when there is a UI for it
+ public function insertPrivilege($attributes)
+ {
+ $attributes = $this->verifyPrivilege($attributes);
+ // @todo: this should fallback on field default value
+ if (!isset($attributes['status_id'])) {
+ $attributes['status_id'] = NULL;
+ }
+
+ $attributes = $this->getFillableFields($attributes);
+
+ $insert = new Insert($this->getTable());
+ $insert
+ ->columns(array_keys($attributes))
+ ->values($attributes);
+ $this->insertWith($insert);
+
+ $privilegeId = $this->lastInsertValue;
+
+ return $this->fetchById($privilegeId);
+ }
+
+ public function getFillableFields($attributes)
+ {
+ return $data = array_intersect_key($attributes, array_flip($this->fillable));
+ }
+
+ // @todo This currently only supports permissions,
+ // include blacklists when there is a UI for it
+ public function updatePrivilege($attributes)
+ {
+ $attributes = $this->verifyPrivilege($attributes);
+
+ $data = $this->getFillableFields($attributes);
+
+ $update = new Update($this->getTable());
+ $update->where->equalTo('id', $attributes['id']);
+ $update->set($data);
+ $this->updateWith($update);
+
+ return $this->fetchById($attributes['id']);
+ }
+
+ public function fetchPerTable($groupId, $tableName = null, array $columns = [])
+ {
+ // Don't include tables that can't have privileges changed
+ /*$blacklist = array(
+ 'directus_columns',
+ 'directus_messages_recipients',
+ 'directus_preferences',
+ 'directus_privileges',
+ 'directus_settings',
+ 'directus_social_feeds',
+ 'directus_social_posts',
+ 'directus_storage_adapters',
+ 'directus_tab_privileges',
+ 'directus_tables',
+ 'directus_ui',
+ 'directus_users_copy'
+ );*/
+ $blacklist = [];
+
+
+ $select = new Select($this->table);
+ if (!empty($columns)) {
+ // Force the primary key
+ // It's going to be removed below
+ $select->columns(array_merge([$this->primaryKeyFieldName], $columns));
+ }
+
+ $select->where->equalTo('group', $groupId);
+ if (!is_null($tableName)) {
+ $select->where->equalTo('collection', $tableName);
+ $select->limit(1);
+ }
+ $rowset = $this->selectWith($select);
+ $rowset = $rowset->toArray();
+
+ $tableSchema = new SchemaService();
+ $tables = $tableSchema->getTablenames();
+ $privileges = [];
+ $privilegesHash = [];
+
+ foreach ($rowset as $item) {
+ if (in_array($item['collection'], $blacklist)) {
+ continue;
+ }
+
+ if (!empty($columns)) {
+ $item = ArrayUtils::pick($item, $columns);
+ }
+
+ $privilegesHash[$item['collection']] = $item;
+ $privileges[] = $item;
+ }
+
+ foreach ($tables as $table) {
+ if (in_array($table, $blacklist)) {
+ continue;
+ }
+
+ if (array_key_exists($table['name'], $privilegesHash)) {
+ continue;
+ }
+
+ if (!is_null($tableName)) {
+ continue;
+ }
+
+ $item = ['collection' => $table['name'], 'group' => $groupId, 'status_id' => null];
+
+ $privileges[] = $item;
+ }
+
+ // sort ascending
+ usort($privileges, function ($a, $b) {
+ return strcmp($a['collection'], $b['collection']);
+ });
+
+ $privileges = is_null($tableName) ? $privileges : reset($privileges);
+
+ return $this->parseRecord($privileges);
+ }
+
+ public function fetchGroupPrivilegesRaw($group_id)
+ {
+ $select = new Select($this->table);
+ $select->where->equalTo('group', $group_id);
+ $rowset = $this->selectWith($select);
+ $rowset = $rowset->toArray();
+
+ return $this->parseRecord($rowset);
+ }
+
+ public function findByStatus($collection, $group_id, $status_id)
+ {
+ $select = new Select($this->table);
+ $select->where
+ ->equalTo('collection', $collection)
+ ->equalTo('group', $group_id)
+ ->equalTo('status', $status_id);
+ $rowset = $this->selectWith($select);
+ $rowset = $rowset->toArray();
+ return current($rowset);
+ }
+}
diff --git a/src/core/Directus/Database/TableGateway/DirectusRolesTableGateway.php b/src/core/Directus/Database/TableGateway/DirectusRolesTableGateway.php
new file mode 100644
index 0000000000..725dc2f587
--- /dev/null
+++ b/src/core/Directus/Database/TableGateway/DirectusRolesTableGateway.php
@@ -0,0 +1,19 @@
+ 20,
+ 'offset' => 0,
+ 'search' => null,
+ 'meta' => 0,
+ 'status' => null
+ ];
+
+ protected $operatorShorthand = [
+ 'eq' => ['operator' => 'equal_to', 'not' => false],
+ '=' => ['operator' => 'equal_to', 'not' => false],
+ 'neq' => ['operator' => 'equal_to', 'not' => true],
+ '!=' => ['operator' => 'equal_to', 'not' => true],
+ '<>' => ['operator' => 'equal_to', 'not' => true],
+ 'in' => ['operator' => 'in', 'not' => false],
+ 'nin' => ['operator' => 'in', 'not' => true],
+ 'lt' => ['operator' => 'less_than', 'not' => false],
+ 'lte' => ['operator' => 'less_than_or_equal', 'not' => false],
+ 'gt' => ['operator' => 'greater_than', 'not' => false],
+ 'gte' => ['operator' => 'greater_than_or_equal', 'not' => false],
+
+ 'nlike' => ['operator' => 'like', 'not' => true],
+ 'contains' => ['operator' => 'like'],
+ 'ncontains' => ['operator' => 'like', 'not' => true],
+
+ '<' => ['operator' => 'less_than', 'not' => false],
+ '<=' => ['operator' => 'less_than_or_equal', 'not' => false],
+ '>' => ['operator' => 'greater_than', 'not' => false],
+ '>=' => ['operator' => 'greater_than_or_equal', 'not' => false],
+
+ 'nnull' => ['operator' => 'null', 'not' => true],
+
+ 'nempty' => ['operator' => 'empty', 'not' => true],
+
+ 'nhas' => ['operator' => 'has', 'not' => true],
+
+ 'nbetween' => ['operator' => 'between', 'not' => true],
+ ];
+
+ public function deleteRecord($id, array $params = [])
+ {
+ // TODO: Add "item" hook, different from "table" hook
+ $success = $this->delete([
+ $this->primaryKeyFieldName => $id
+ ]);
+
+ if (!$success) {
+ throw new ErrorException(
+ sprintf('Error deleting a record in %s with id %s', $this->table, $id)
+ );
+ }
+
+ if ($this->table !== SchemaManager::COLLECTION_ACTIVITY) {
+ $parentLogEntry = BaseRowGateway::makeRowGatewayFromTableName('id', 'directus_activity', $this->adapter);
+ $logData = [
+ 'type' => DirectusActivityTableGateway::makeLogTypeFromTableName($this->table),
+ 'action' => DirectusActivityTableGateway::ACTION_DELETE,
+ 'user' => $this->acl->getUserId(),
+ 'datetime' => DateTimeUtils::nowInUTC()->toString(),
+ 'ip' => get_request_ip(),
+ 'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '',
+ 'collection' => $this->table,
+ 'item' => $id,
+ 'message' => ArrayUtils::get($params, 'activity_message')
+ ];
+ $parentLogEntry->populate($logData, false);
+ $parentLogEntry->save();
+ }
+ }
+
+ /**
+ * @param array $data
+ * @param array $params
+ *
+ * @return BaseRowGateway
+ */
+ public function updateRecord($data, array $params = [])
+ {
+ return $this->manageRecordUpdate($this->getTable(), $data, $params);
+ }
+
+ /**
+ * @param $data
+ * @param array $params
+ *
+ * @return BaseRowGateway
+ */
+ public function revertRecord($data, array $params = [])
+ {
+ return $this->updateRecord($data, array_merge($params, ['revert' => true]));
+ }
+
+ /**
+ * @param string $tableName
+ * @param array $recordData
+ * @param array $params
+ * @param null $childLogEntries
+ * @param bool $parentCollectionRelationshipsChanged
+ * @param array $parentData
+ *
+ * @return BaseRowGateway
+ */
+ public function manageRecordUpdate($tableName, $recordData, array $params = [], &$childLogEntries = null, &$parentCollectionRelationshipsChanged = false, $parentData = [])
+ {
+ $TableGateway = $this;
+ if ($tableName !== $this->getTable()) {
+ $TableGateway = new RelationalTableGateway($tableName, $this->adapter, $this->acl);
+ }
+
+ $activityEntryMode = ArrayUtils::get($params, 'activity_mode', static::ACTIVITY_ENTRY_MODE_PARENT);
+ $recordIsNew = !array_key_exists($TableGateway->primaryKeyFieldName, $recordData);
+
+ $tableSchema = SchemaService::getCollection($tableName);
+
+ $currentUserId = $this->acl ? $this->acl->getUserId() : null;
+ $isAdmin = $this->acl ? $this->acl->isAdmin() : false;
+
+ // Do not let non-admins make admins
+ // TODO: Move to hooks
+ if ($tableName == 'directus_users' && !$isAdmin) {
+ if (isset($recordData['group']) && $recordData['group']['id'] == 1) {
+ unset($recordData['group']);
+ }
+ }
+
+ $thisIsNested = ($activityEntryMode == self::ACTIVITY_ENTRY_MODE_CHILD);
+
+ // Recursive functions will change this value (by reference) as necessary
+ // $nestedCollectionRelationshipsChanged = $thisIsNested ? $parentCollectionRelationshipsChanged : false;
+ $nestedCollectionRelationshipsChanged = false;
+ if ($thisIsNested) {
+ $nestedCollectionRelationshipsChanged = &$parentCollectionRelationshipsChanged;
+ }
+
+ // Recursive functions will append to this array by reference
+ // $nestedLogEntries = $thisIsNested ? $childLogEntries : [];
+ $nestedLogEntries = [];
+ if ($thisIsNested) {
+ $nestedLogEntries = &$childLogEntries;
+ }
+
+ // Update and/or Add Many-to-One Associations
+ $recordData = $TableGateway->addOrUpdateManyToOneRelationships($tableSchema, $recordData, $nestedLogEntries, $nestedCollectionRelationshipsChanged);
+
+ $parentRecordWithoutAlias = [];
+ foreach ($recordData as $key => $data) {
+ $column = $tableSchema->getField($key);
+
+ // TODO: To work with files
+ // As `data` is not set as alias for files we are checking for actual aliases
+ if ($column && $column->isAlias()) {
+ continue;
+ }
+
+ $parentRecordWithoutAlias[$key] = $data;
+ }
+
+ // NOTE: set the primary key to null
+ // to default the value to whatever increment value is next
+ // avoiding the error of inserting nothing
+ if (empty($parentRecordWithoutAlias)) {
+ $parentRecordWithoutAlias[$tableSchema->getPrimaryKeyName()] = null;
+ }
+
+ // If more than the record ID is present.
+ $newRecordObject = null;
+ $parentRecordChanged = $this->recordDataContainsNonPrimaryKeyData($recordData);
+
+ if ($parentRecordChanged) {
+ // Update the parent row, w/ any new association fields replaced by their IDs
+ $newRecordObject = $TableGateway
+ ->addOrUpdateRecordByArray($parentRecordWithoutAlias);
+ if (!$newRecordObject) {
+ return [];
+ }
+
+ if ($newRecordObject) {
+ $newRecordObject = $newRecordObject->toArray();
+ }
+ }
+
+ // Do it this way, because & byref for outcome of ternary operator spells trouble
+ $draftRecord = &$parentRecordWithoutAlias;
+ if ($recordIsNew) {
+ $draftRecord = &$newRecordObject;
+ }
+
+ // Restore X2M relationship / alias fields to the record representation & process these relationships.
+ $collectionColumns = $tableSchema->getAliasFields();
+ foreach ($collectionColumns as $collectionColumn) {
+ $colName = $collectionColumn->getName();
+ if (isset($recordData[$colName])) {
+ $draftRecord[$colName] = $recordData[$colName];
+ }
+ }
+
+ // parent
+ if ($activityEntryMode === self::ACTIVITY_ENTRY_MODE_PARENT) {
+ $parentData = [
+ 'item' => array_key_exists($this->primaryKeyFieldName, $recordData) ? $recordData[$this->primaryKeyFieldName] : null,
+ 'collection' => $tableName
+ ];
+ }
+
+ $draftRecord = $TableGateway->addOrUpdateToManyRelationships($tableSchema, $draftRecord, $nestedLogEntries, $nestedCollectionRelationshipsChanged, $parentData);
+ $rowId = $draftRecord[$this->primaryKeyFieldName];
+
+ $columnNames = SchemaService::getAllNonAliasCollectionFieldNames($tableName);
+ $TemporaryTableGateway = new TableGateway($tableName, $this->adapter);
+ $fullRecordData = $TemporaryTableGateway->select(function ($select) use ($rowId, $columnNames) {
+ $select->where->equalTo($this->primaryKeyFieldName, $rowId);
+ $select->limit(1)->columns($columnNames);
+ })->current();
+
+ if (!$fullRecordData) {
+ $recordType = $recordIsNew ? 'new' : 'pre-existing';
+ throw new \RuntimeException('Attempted to load ' . $recordType . ' record post-insert with empty result. Lookup via row id: ' . print_r($rowId, true));
+ }
+
+ $fullRecordData = (array) $fullRecordData;
+ $deltaRecordData = $recordIsNew ? $parentRecordWithoutAlias : array_intersect_key((array)$parentRecordWithoutAlias, $fullRecordData);
+
+ $statusField = $tableSchema->getStatusField();
+ if ($recordIsNew) {
+ $logEntryAction = DirectusActivityTableGateway::ACTION_ADD;
+ } else if (ArrayUtils::get($params, 'revert') === true) {
+ $logEntryAction = DirectusActivityTableGateway::ACTION_REVERT;
+ } else {
+ $logEntryAction = DirectusActivityTableGateway::ACTION_UPDATE;
+
+ try {
+ if (
+ $statusField
+ && ArrayUtils::has($deltaRecordData, $statusField->getName())
+ && in_array(
+ ArrayUtils::get($deltaRecordData, $tableSchema->getStatusField()->getName()),
+ $this->getStatusMapping()->getSoftDeleteStatusesValue()
+ )
+ ) {
+ $logEntryAction = DirectusActivityTableGateway::ACTION_SOFT_DELETE;
+ }
+ } catch (\Exception $e) {
+ // the field doesn't have a status mapping
+ }
+ }
+
+ switch ($activityEntryMode) {
+ // Activity logging is enabled, and I am a nested action
+ case self::ACTIVITY_ENTRY_MODE_CHILD:
+ $childLogEntries[] = [
+ 'type' => DirectusActivityTableGateway::makeLogTypeFromTableName($this->table),
+ 'action' => $logEntryAction,
+ 'user' => $currentUserId,
+ 'datetime' => DateTimeUtils::nowInUTC()->toString(),
+ 'ip' => get_request_ip(),
+ 'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '',
+ 'collection' => $tableName,
+ 'parent_item' => isset($parentData['item']) ? $parentData['item'] : null,
+ 'parent_collection' => isset($parentData['collection']) ? $parentData['collection'] : null,
+ 'data' => json_encode($fullRecordData),
+ 'delta' => !empty($deltaRecordData) ? json_encode($deltaRecordData) : null,
+ 'parent_changed' => boolval($parentRecordChanged),
+ 'item' => $rowId,
+ 'message' => null
+ ];
+ if ($recordIsNew) {
+ /**
+ * This is a nested call, creating a new record w/in a foreign collection.
+ * Indicate by reference that the top-level record's relationships have changed.
+ */
+ $parentCollectionRelationshipsChanged = true;
+ }
+ break;
+
+ case self::ACTIVITY_ENTRY_MODE_PARENT:
+ // Does this act deserve a log?
+ $parentRecordNeedsLog = $nestedCollectionRelationshipsChanged || $parentRecordChanged;
+ /**
+ * NESTED QUESTIONS!
+ * @todo what do we do if the foreign record OF a foreign record changes?
+ * is that activity entry also directed towards this parent activity entry?
+ * @todo how should nested activity entries relate to the revision histories of foreign items?
+ * @todo one day: treat children as parents if this top-level record was not modified.
+ */
+ // Produce log if something changed.
+ if ($parentRecordChanged || $nestedCollectionRelationshipsChanged) {
+ // Save parent log entry
+ $parentLogEntry = BaseRowGateway::makeRowGatewayFromTableName('id', 'directus_activity', $this->adapter);
+ $logData = [
+ 'type' => DirectusActivityTableGateway::makeLogTypeFromTableName($this->table),
+ 'action' => $logEntryAction,
+ 'user' => $currentUserId,
+ 'datetime' => DateTimeUtils::nowInUTC()->toString(),
+ 'ip' => get_request_ip(),
+ 'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '',
+ 'collection' => $tableName,
+ 'item' => $rowId,
+ 'message' => ArrayUtils::get($params, 'activity_message')
+ ];
+ $parentLogEntry->populate($logData, false);
+ $parentLogEntry->save();
+
+ // Add Revisions
+ $revisionTableGateway = new RelationalTableGateway(SchemaManager::COLLECTION_REVISIONS, $this->adapter);
+ $revisionTableGateway->insert([
+ 'activity' => $parentLogEntry->getId(),
+ 'collection' => $tableName,
+ 'item' => $rowId,
+ 'data' => json_encode($fullRecordData),
+ 'delta' => !empty($deltaRecordData) ? json_encode($deltaRecordData) : null,
+ 'parent_item' => null,
+ 'parent_collection' => null,
+ 'parent_changed' => null,//boolval($parentRecordChanged)
+ ]);
+
+ // Update & insert nested activity entries
+ $ActivityGateway = new DirectusActivityTableGateway($this->adapter);
+ foreach ($nestedLogEntries as $entry) {
+ // TODO: ought to insert these in one batch
+ $ActivityGateway->insert(ArrayUtils::omit($entry, [
+ 'parent_item',
+ 'parent_collection',
+ 'data',
+ 'delta',
+ 'parent_changed',
+ ]));
+ $revisionTableGateway->insert([
+ 'activity' => $ActivityGateway->lastInsertValue,
+ 'collection' => ArrayUtils::get($entry, 'collection'),
+ 'item' => ArrayUtils::get($entry, 'item'),
+ 'data' => ArrayUtils::get($entry, 'data'),
+ 'delta' => ArrayUtils::get($entry, 'delta'),
+ 'parent_item' => ArrayUtils::get($entry, 'parent_item'),
+ 'parent_collection' => ArrayUtils::get($entry, 'parent_collection'),
+ 'parent_changed' => ArrayUtils::get($entry, 'parent_changed')
+ ]);
+ }
+ }
+ break;
+ }
+
+ // Yield record object
+ $recordGateway = new BaseRowGateway($TableGateway->primaryKeyFieldName, $tableName, $this->adapter, $this->acl);
+ $recordGateway->populate($this->parseRecord($fullRecordData), true);
+
+ return $recordGateway;
+ }
+
+ /**
+ * @param Collection $schema The table schema array.
+ * @param array $parentRow The parent record being updated.
+ * @return array
+ */
+ public function addOrUpdateManyToOneRelationships($schema, $parentRow, &$childLogEntries = null, &$parentCollectionRelationshipsChanged = false)
+ {
+ // Create foreign row and update local column with the data id
+ foreach ($schema->getFields() as $field) {
+ $fieldName = $field->getName();
+
+ if (!$field->isManyToOne()) {
+ continue;
+ }
+
+ // Ignore absent values & non-arrays
+ if (!isset($parentRow[$fieldName]) || !is_array($parentRow[$fieldName])) {
+ continue;
+ }
+
+ // Ignore non-arrays and empty collections
+ if (empty($parentRow[$fieldName])) {
+ // Once they're managed, remove the foreign collections from the record array
+ unset($parentRow[$fieldName]);
+ continue;
+ }
+
+ $foreignDataSet = $parentRow[$fieldName];
+ $foreignRow = $foreignDataSet;
+ $foreignTableName = $field->getRelationship()->getCollectionB();
+ $foreignTableSchema = $this->getTableSchema($foreignTableName);
+ $primaryKey = $foreignTableSchema->getPrimaryKeyName();
+ $ForeignTable = new RelationalTableGateway($foreignTableName, $this->adapter, $this->acl);
+
+ if ($primaryKey && ArrayUtils::get($foreignRow, $this->deleteFlag) === true) {
+ $Where = new Where();
+ $Where->equalTo($primaryKey, $foreignRow[$primaryKey]);
+ $ForeignTable->delete($Where);
+
+ $parentRow[$fieldName] = $field->isNullable() ? null : 0;
+
+ continue;
+ }
+
+ // Update/Add foreign record
+ if ($this->recordDataContainsNonPrimaryKeyData($foreignRow, $foreignTableSchema->getPrimaryKeyName())) {
+ // NOTE: using manageRecordUpdate instead of addOrUpdateRecordByArray to update related data
+ $foreignRow = $this->manageRecordUpdate($foreignTableName, $foreignRow);
+ }
+
+ $parentRow[$fieldName] = $foreignRow[$primaryKey];
+ }
+
+ return $parentRow;
+ }
+
+ /**
+ * @param Collection $schema The table schema array.
+ * @param array $parentRow The parent record being updated.
+ * @return array
+ */
+ public function addOrUpdateToManyRelationships($schema, $parentRow, &$childLogEntries = null, &$parentCollectionRelationshipsChanged = false, $parentData = [])
+ {
+ // Create foreign row and update local column with the data id
+ foreach ($schema->getFields() as $field) {
+ $fieldName = $field->getName();
+
+ if (!$field->hasRelationship()) {
+ continue;
+ }
+
+ // Ignore absent values & non-arrays
+ if (!isset($parentRow[$fieldName]) || !is_array($parentRow[$fieldName])) {
+ continue;
+ }
+
+ $relationship = $field->getRelationship();
+ $fieldIsCollectionAssociation = $relationship->isToMany();
+
+ // Ignore non-arrays and empty collections
+ if (empty($parentRow[$fieldName])) {//} || ($fieldIsOneToMany && )) {
+ // Once they're managed, remove the foreign collections from the record array
+ unset($parentRow[$fieldName]);
+ continue;
+ }
+
+ $foreignDataSet = $parentRow[$fieldName];
+
+ /** One-to-Many, Many-to-Many */
+ if ($fieldIsCollectionAssociation) {
+ $this->enforceColumnHasNonNullValues($relationship->toArray(), ['collection_b', 'field_a'], $this->table);
+ $foreignTableName = $relationship->getCollectionB();
+ $foreignJoinColumn = $relationship->getFieldB();
+ switch ($relationship->getType()) {
+ /** One-to-Many */
+ case FieldRelationship::ONE_TO_MANY:
+ $ForeignTable = new RelationalTableGateway($foreignTableName, $this->adapter, $this->acl);
+ foreach ($foreignDataSet as &$foreignRecord) {
+ if (empty($foreignRecord)) {
+ continue;
+ }
+
+ // TODO: Fix a bug when fetching a single column
+ // before fetching all columns from a table
+ // due to our basic "cache" implementation on schema layer
+ $hasPrimaryKey = isset($foreignRecord[$ForeignTable->primaryKeyFieldName]);
+
+ if ($hasPrimaryKey && ArrayUtils::get($foreignRecord, $this->deleteFlag) === true) {
+ $Where = new Where();
+ $Where->equalTo($ForeignTable->primaryKeyFieldName, $foreignRecord[$ForeignTable->primaryKeyFieldName]);
+ $ForeignTable->delete($Where);
+
+ continue;
+ }
+
+ // only add parent id's to items that are lacking the parent column
+ if (!array_key_exists($foreignJoinColumn, $foreignRecord)) {
+ $foreignRecord[$foreignJoinColumn] = $parentRow['id'];
+ }
+
+ $foreignRecord = $this->manageRecordUpdate(
+ $foreignTableName,
+ $foreignRecord,
+ ['activity_mode' => self::ACTIVITY_ENTRY_MODE_CHILD],
+ $childLogEntries,
+ $parentCollectionRelationshipsChanged,
+ $parentData
+ );
+ }
+ break;
+
+ /** Many-to-Many */
+ case FieldRelationship::MANY_TO_MANY:
+ $foreignJoinColumn = $relationship->getJunctionKeyB();
+ /**
+ * [+] Many-to-Many payloads declare collection items this way:
+ * $parentRecord['collectionName1'][0-9]['data']; // record key-value array
+ * [+] With optional association metadata:
+ * $parentRecord['collectionName1'][0-9]['id']; // for updating a pre-existing junction row
+ * $parentRecord['collectionName1'][0-9]['active']; // for disassociating a junction via the '0' value
+ */
+
+ $this->enforceColumnHasNonNullValues($relationship->toArray(), ['junction_collection', 'junction_key_a'], $this->table);
+ $junctionTableName = $relationship->getJunctionCollection();//$column['relationship']['junction_table'];
+ $junctionKeyLeft = $relationship->getJunctionKeyA();//$column['relationship']['junction_key_left'];
+ $junctionKeyRight = $relationship->getJunctionKeyB();//$column['relationship']['junction_key_right'];
+ $JunctionTable = new RelationalTableGateway($junctionTableName, $this->adapter, $this->acl);
+ $ForeignTable = new RelationalTableGateway($foreignTableName, $this->adapter, $this->acl);
+ foreach ($foreignDataSet as $junctionRow) {
+ /** This association is designated for removal */
+ $hasPrimaryKey = isset($junctionRow[$JunctionTable->primaryKeyFieldName]);
+
+ if ($hasPrimaryKey && ArrayUtils::get($junctionRow, $this->deleteFlag) === true) {
+ $Where = new Where;
+ $Where->equalTo($JunctionTable->primaryKeyFieldName, $junctionRow[$JunctionTable->primaryKeyFieldName]);
+ $JunctionTable->delete($Where);
+ // Flag the top-level record as having been altered.
+ // (disassociating w/ existing M2M collection entry)
+ $parentCollectionRelationshipsChanged = true;
+ continue;
+ }
+
+ /** Update foreign record */
+ $foreignRecord = ArrayUtils::get($junctionRow, $junctionKeyRight, []);
+ if (is_array($foreignRecord)) {
+ $foreignRecord = $ForeignTable->manageRecordUpdate(
+ $foreignTableName,
+ $foreignRecord,
+ ['activity_mode' => self::ACTIVITY_ENTRY_MODE_CHILD],
+ $childLogEntries,
+ $parentCollectionRelationshipsChanged,
+ $parentData
+ );
+ $foreignJoinColumnKey = $foreignRecord[$ForeignTable->primaryKeyFieldName];
+ } else {
+ $foreignJoinColumnKey = $foreignRecord;
+ }
+
+ // Junction/Association row
+ $junctionTableRecord = [
+ $junctionKeyLeft => $parentRow[$this->primaryKeyFieldName],
+ $foreignJoinColumn => $foreignJoinColumnKey
+ ];
+
+ // Update fields on the Junction Record
+ $junctionTableRecord = array_merge($junctionRow, $junctionTableRecord);
+
+ $foreignRecord = (array)$foreignRecord;
+
+ $relationshipChanged = $this->recordDataContainsNonPrimaryKeyData($foreignRecord, $ForeignTable->primaryKeyFieldName) ||
+ $this->recordDataContainsNonPrimaryKeyData($junctionTableRecord, $JunctionTable->primaryKeyFieldName);
+
+ // Update Foreign Record
+ if ($relationshipChanged) {
+ $JunctionTable->addOrUpdateRecordByArray($junctionTableRecord, $junctionTableName);
+ }
+ }
+ break;
+ }
+ // Once they're managed, remove the foreign collections from the record array
+ unset($parentRow[$fieldName]);
+ }
+ }
+
+ return $parentRow;
+ }
+
+ public function applyDefaultEntriesSelectParams(array $params)
+ {
+ // NOTE: Performance spot
+ // TODO: Split this, into default and process params
+ $defaultParams = $this->defaultEntriesSelectParams;
+ $defaultLimit = $this->getSettings('global', 'default_limit');
+
+ // Set default rows limit from db settings
+ if ($defaultLimit) {
+ $defaultParams['limit'] = (int)$defaultLimit;
+ }
+
+ $id = ArrayUtils::get($params, 'id');
+ if ($id && count(StringUtils::csv((string) $id)) == 1) {
+ $params['single'] = true;
+ }
+
+ // Fetch only one if single param is set
+ if (ArrayUtils::get($params, 'single')) {
+ $params['limit'] = 1;
+ }
+
+ // Remove the columns parameters
+ // Until we call it fields internally
+ if (ArrayUtils::has($params, 'columns')) {
+ ArrayUtils::remove($params, 'columns');
+ }
+
+ // NOTE: Let's use "columns" instead of "fields" internally for the moment
+ if (ArrayUtils::has($params, 'fields')) {
+ $params['fields'] = ArrayUtils::get($params, 'fields');
+ // ArrayUtils::remove($params, 'fields');
+ }
+
+ $tableSchema = $this->getTableSchema();
+ $sortingField = $tableSchema->getSortingField();
+ $defaultParams['sort'] = $sortingField ? $sortingField->getName() : $this->primaryKeyFieldName;
+
+ // Is not there a sort column?
+ $tableColumns = array_flip(SchemaService::getCollectionFields($this->table, null, true));
+ if (!$this->primaryKeyFieldName || !array_key_exists($this->primaryKeyFieldName, $tableColumns)) {
+ unset($defaultParams['sort']);
+ }
+
+ if (!$this->getTableSchema()->hasStatusField() || ArrayUtils::get($params, 'status') === '*') {
+ ArrayUtils::remove($params, 'status');
+ } else if (!ArrayUtils::has($params, 'id') && !ArrayUtils::has($params, 'status')) {
+ $defaultParams['status'] = $this->getPublishedStatuses();
+ } else if (ArrayUtils::has($params, 'status') && is_string(ArrayUtils::get($params, 'status'))) {
+ $params['status'] = StringUtils::csv($params['status']);
+ }
+
+ $params = array_merge($defaultParams, $params);
+
+ if (ArrayUtils::get($params, 'sort')) {
+ $params['sort'] = StringUtils::csv($params['sort']);
+ }
+
+ // convert csv columns into array
+ $columns = convert_param_columns(ArrayUtils::get($params, 'fields', []));
+
+ // Add columns to params if it's not empty.
+ // otherwise remove from params
+ if (!empty($columns)) {
+ $params['fields'] = $columns;
+ } else {
+ ArrayUtils::remove($params, 'fields');
+ }
+
+ if ($params['limit'] === null) {
+ ArrayUtils::remove($params, 'limit');
+ }
+
+ array_walk($params, [$this, 'castFloatIfNumeric']);
+
+ return $params;
+ }
+
+ /**
+ * @param array $params
+ * @param Builder $builder
+ *
+ * @return Builder
+ */
+ public function applyParamsToTableEntriesSelect(array $params, Builder $builder)
+ {
+ // ----------------------------------------------------------------------------
+ // STATUS VALUES
+ // ----------------------------------------------------------------------------
+ $statusField = $this->getTableSchema()->getStatusField();
+ $permissionStatuses = $this->acl->getCollectionStatusesReadPermission($this->getTable());
+ if ($statusField && is_array($permissionStatuses)) {
+ $paramStatuses = ArrayUtils::get($params, 'status');
+ if (is_array($paramStatuses)) {
+ $permissionStatuses = ArrayUtils::intersection(
+ $permissionStatuses,
+ $paramStatuses
+ );
+ }
+
+ $params['status'] = $permissionStatuses;
+ }
+
+ // @TODO: Query Builder Object
+ foreach($params as $type => $argument) {
+ $method = 'process' . ucfirst($type);
+ if (method_exists($this, $method)) {
+ call_user_func_array([$this, $method], [$builder, $argument]);
+ }
+ }
+
+ $this->applyLegacyParams($builder, $params);
+
+ return $builder;
+ }
+
+ /**
+ * Relational Getter
+ * NOTE: equivalent to old DB#get_entries
+ *
+ * @param array $params
+ *
+ * @return array
+ */
+ public function getEntries($params = [])
+ {
+ if (!is_array($params)) {
+ $params = [];
+ }
+
+ return $this->getItems($params);
+ }
+
+ /**
+ * Get table items
+ *
+ * @param array $params
+ *
+ * @return array|mixed
+ */
+ public function getItems(array $params = [])
+ {
+ $entries = $this->loadItems($params);
+
+ $single = ArrayUtils::has($params, 'id') || ArrayUtils::has($params, 'single');
+ $meta = ArrayUtils::get($params, 'meta', 0);
+
+ return $this->wrapData($entries, $single, $meta);
+ }
+
+ /**
+ * wrap the query result into the api response format
+ *
+ * TODO: This will be soon out of TableGateway
+ *
+ * @param array $data
+ * @param bool $single
+ * @param bool $meta
+ *
+ * @return array
+ */
+ public function wrapData($data, $single = false, $meta = false)
+ {
+ $result = [];
+
+ if ($meta) {
+ if (!is_array($meta)) {
+ $meta = StringUtils::csv($meta);
+ }
+
+ $result['meta'] = $this->createMetadata($data, $single, $meta);
+ }
+
+ $result['data'] = $data;
+
+ return $result;
+ }
+
+ public function loadMetadata($data, $single = false)
+ {
+ return $this->wrapData($data, $single);
+ }
+
+ public function createMetadata($entriesData, $single, $list = [])
+ {
+ $singleEntry = $single || !ArrayUtils::isNumericKeys($entriesData);
+ $metadata = $this->createGlobalMetadata($singleEntry, $list);
+
+ if (!$singleEntry) {
+ $metadata = array_merge($metadata, $this->createEntriesMetadata($entriesData, $list));
+ }
+
+ return $metadata;
+ }
+
+ /**
+ * Creates the "global" metadata
+ *
+ * @param bool $single
+ * @param array $list
+ *
+ * @return array
+ */
+ public function createGlobalMetadata($single, array $list = [])
+ {
+ $allKeys = ['collection', 'type'];
+ $metadata = [];
+
+ if (empty($list) || in_array('*', $list)) {
+ $list = $allKeys;
+ }
+
+ if (in_array('collection', $list)) {
+ $metadata['collection'] = $this->getTable();
+ }
+
+ if (in_array('type', $list)) {
+ $metadata['type'] = $single ? 'item' : 'collection';
+ }
+
+ return $metadata;
+ }
+
+ /**
+ * Create entries metadata
+ *
+ * @param array $entries
+ * @param array $list
+ *
+ * @return array
+ */
+ public function createEntriesMetadata(array $entries, array $list = [])
+ {
+ $allKeys = ['result_count', 'total_count', 'status'];
+ $tableSchema = $this->getTableSchema($this->table);
+
+ $metadata = [];
+
+ if (empty($list) || in_array('*', $list)) {
+ $list = $allKeys;
+ }
+
+ if (in_array('result_count', $list)) {
+ $metadata['result_count'] = count($entries);
+ }
+
+ if (in_array('total_count', $list)) {
+ $metadata['total_count'] = $this->countTotal();
+ }
+
+ if ($tableSchema->hasStatusField() && in_array('status', $list)) {
+ $statusCount = $this->countByStatus();
+ $metadata['status'] = $statusCount;
+ }
+
+ return $metadata;
+ }
+
+ /**
+ * Load Table entries
+ *
+ * @param array $params
+ * @param \Closure|null $queryCallback
+ *
+ * @return array
+ *
+ * @throws Exception\InvalidFieldException
+ * @throws Exception\ItemNotFoundException
+ */
+ public function loadItems(array $params = [], \Closure $queryCallback = null)
+ {
+ $collectionObject = $this->getTableSchema();
+
+ $params = $this->applyDefaultEntriesSelectParams($params);
+ $fields = ArrayUtils::get($params, 'fields');
+
+ if (is_array($fields)) {
+ $this->validateFields($fields);
+ }
+
+ // TODO: Check for all collections + fields permission/existence before querying
+ // TODO: Create a new TableGateway Query Builder based on Query\Builder
+ $builder = new Builder($this->getAdapter());
+ $builder->from($this->getTable());
+
+ $selectedFields = array_merge(
+ [$collectionObject->getPrimaryKeyName()],
+ $this->getSelectedNonAliasFields($fields ?: ['*'])
+ );
+
+ $statusField = $collectionObject->getStatusField();
+ if ($statusField && $this->acl->getCollectionStatuses($this->table)) {
+ $selectedFields = array_merge($selectedFields, [$statusField->getName()]);
+ }
+
+ $builder->columns($selectedFields);
+
+ $builder = $this->applyParamsToTableEntriesSelect(
+ $params,
+ $builder
+ );
+
+ $this->enforceReadPermission($builder);
+
+ if ($queryCallback !== null) {
+ $builder = $queryCallback($builder);
+ }
+
+ // Run the builder Select with this tablegateway
+ // to run all the hooks against the result
+ $results = $this->selectWith($builder->buildSelect())->toArray();
+
+ if (!$results && ArrayUtils::has($params, 'single')) {
+ if (ArrayUtils::has($params, 'id')) {
+ $message = sprintf('Item with id "%s" not found', $params['id']);
+ } else {
+ $message = 'Item not found';
+ }
+
+ throw new Exception\ItemNotFoundException($message);
+ }
+
+ // ==========================================================================
+ // Perform data casting based on the column types in our schema array
+ // and Convert dates into ISO 8601 Format
+ // ==========================================================================
+ $results = $this->parseRecord($results);
+
+ $columnsDepth = ArrayUtils::deepLevel(get_unflat_columns($fields));
+ if ($columnsDepth > 0) {
+ $relatedFields = $this->getSelectedRelatedFields($fields);
+
+ $relationalParams = [
+ 'meta' => ArrayUtils::get($params, 'meta'),
+ 'lang' => ArrayUtils::get($params, 'lang')
+ ];
+
+ $results = $this->loadRelationalData(
+ $results,
+ get_array_flat_columns($relatedFields),
+ $relationalParams
+ );
+ }
+
+ // When the params column list doesn't include the primary key
+ // it should be included because each row gateway expects the primary key
+ // after all the row gateway are created and initiated it only returns the chosen columns
+ if ($fields && !array_key_exists('*', get_unflat_columns($fields))) {
+ $visibleColumns = $this->getSelectedFields($fields);
+ $results = array_map(function ($entry) use ($visibleColumns) {
+ foreach ($entry as $key => $value) {
+ if (!in_array($key, $visibleColumns)) {
+ $entry = ArrayUtils::omit($entry, $key);
+ }
+ }
+
+ return $entry;
+ }, $results);
+ }
+
+ if ($statusField && $this->acl->getCollectionStatuses($this->table)) {
+ foreach ($results as $index => &$item) {
+ $statusId = ArrayUtils::get($item, $statusField->getName());
+ $blacklist = $this->acl->getReadFieldBlacklist($this->table, $statusId);
+ $item = ArrayUtils::omit($item, $blacklist);
+ if (empty($item)) {
+ unset($results[$index]);
+ }
+ }
+
+ $results = array_values($results);
+ }
+
+ if (ArrayUtils::has($params, 'single')) {
+ $results = reset($results);
+ }
+
+ return $results ? $results : [];
+ }
+
+ /**
+ * Load Table entries
+ *
+ * Alias of loadItems
+ *
+ * @param array $params
+ * @param \Closure|null $queryCallback
+ *
+ * @return mixed
+ */
+ public function loadEntries(array $params = [], \Closure $queryCallback = null)
+ {
+ return $this->loadItems($params, $queryCallback);
+ }
+
+ /**
+ * Loads all relational data by depth level
+ *
+ * @param $result
+ * @param array|null $columns
+ * @param array $params
+ *
+ * @return array
+ */
+ protected function loadRelationalData($result, array $columns = [], array $params = [])
+ {
+ $result = $this->loadManyToOneRelationships($result, $columns, $params);
+ $result = $this->loadOneToManyRelationships($result, $columns, $params);
+ $result = $this->loadManyToManyRelationships($result, $columns, $params);
+
+ return $result;
+ }
+
+ /**
+ * Parse Filter "condition" (this is the filter key value)
+ *
+ * @param $condition
+ *
+ * @return array
+ */
+ protected function parseCondition($condition)
+ {
+ // TODO: Add a simplified option for logical
+ // adding an "or_" prefix
+ // filters[column][eq]=Value1&filters[column][or_eq]=Value2
+ $logical = null;
+ if (is_array($condition) && isset($condition['logical'])) {
+ $logical = $condition['logical'];
+ unset($condition['logical']);
+ }
+
+ $operator = is_array($condition) ? key($condition) : '=';
+ $value = is_array($condition) ? current($condition) : $condition;
+ $not = false;
+
+ return [
+ 'operator' => $operator,
+ 'value' => $value,
+ 'not' => $not,
+ 'logical' => $logical
+ ];
+ }
+
+ protected function parseDotFilters(Builder $mainQuery, array $filters)
+ {
+ foreach ($filters as $column => $condition) {
+ if (!is_string($column) || strpos($column, '.') === false) {
+ continue;
+ }
+
+ $columnList = $columns = explode('.', $column);
+ $columnsTable = [
+ $this->getTable()
+ ];
+
+ $nextColumn = array_shift($columnList);
+ $nextTable = $this->getTable();
+ $relational = SchemaService::hasRelationship($nextTable, $nextColumn);
+
+ while ($relational) {
+ $nextTable = SchemaService::getRelatedCollectionName($nextTable, $nextColumn);
+ $nextColumn = array_shift($columnList);
+ $relational = SchemaService::hasRelationship($nextTable, $nextColumn);
+ $columnsTable[] = $nextTable;
+ }
+
+ // if one of the column in the list has not relationship
+ // it will break the loop before going over all the columns
+ // which we will call this as column not found
+ // TODO: Better error message
+ if (!empty($columnList)) {
+ throw new Exception\FieldNotFoundException($nextColumn);
+ }
+
+ // Remove the original filter column with dot-notation
+ unset($filters[$column]);
+
+ // Reverse all the columns from comments.author.id to id.author.comments
+ // To filter from the most deep relationship to their parents
+ $columns = explode('.', column_identifier_reverse($column));
+ $columnsTable = array_reverse($columnsTable, true);
+
+ $mainColumn = array_pop($columns);
+ $mainTable = array_pop($columnsTable);
+
+ // the main query column
+ // where the filter is going to be applied
+ $column = array_shift($columns);
+ $table = array_shift($columnsTable);
+
+ $query = new Builder($this->getAdapter());
+ $mainTableObject = $this->getTableSchema($table);
+ $query->columns([$mainTableObject->getPrimaryField()->getName()]);
+ $query->from($table);
+
+ $this->doFilter($query, $column, $condition, $table);
+
+ $index = 0;
+ foreach ($columns as $key => $column) {
+ ++$index;
+
+ $oldQuery = $query;
+ $query = new Builder($this->getAdapter());
+ $collection = $this->getTableSchema($columnsTable[$key]);
+ $field = $collection->getField($column);
+
+ $selectColumn = $collection->getPrimaryField()->getName();
+ $table = $columnsTable[$key];
+
+ if ($field->isAlias()) {
+ $column = $collection->getPrimaryField()->getName();
+ }
+
+ if ($field->isManyToMany()) {
+ $selectColumn = $field->getRelationship()->getJunctionKeyA();
+ $column = $field->getRelationship()->getJunctionKeyB();
+ $table = $field->getRelationship()->getJunctionCollection();
+ }
+
+ $query->columns([$selectColumn]);
+ $query->from($table);
+ $query->whereIn($column, $oldQuery);
+ }
+
+ $collection = $this->getTableSchema($mainTable);
+ $field = $collection->getField($mainColumn);
+ $relationship = $field->getRelationship();
+
+ // TODO: Make all this whereIn duplication into a function
+ // TODO: Can we make the O2M simpler getting the parent id from itself
+ // right now is creating one unnecessary select
+ if ($field->isManyToMany() || $field->isOneToMany()) {
+ $mainColumn = $collection->getPrimaryField()->getName();
+ $oldQuery = $query;
+ $query = new Builder($this->getAdapter());
+
+ if ($field->isManyToMany()) {
+ $selectColumn = $relationship->getJunctionKeyB();
+ $table = $relationship->getJunctionCollection();
+ $column = $relationship->getJunctionKeyA();
+ } else {
+ $selectColumn = $column = $relationship->getJunctionKeyA();
+ $table = $relationship->getCollectionB();
+ }
+
+ $query->columns([$selectColumn]);
+ $query->from($table);
+ $query->whereIn(
+ $column,
+ $oldQuery
+ );
+ }
+
+ $this->doFilter(
+ $mainQuery,
+ $mainColumn,
+ [
+ 'in' => $query
+ ],
+ $mainTable
+ );
+ }
+
+ return $filters;
+ }
+
+ protected function doFilter(Builder $query, $column, $condition, $table)
+ {
+ $fieldName = $this->getColumnFromIdentifier($column);
+ $field = $this->getField(
+ $fieldName,
+ // $table will be the default value to get
+ // if the column has not identifier format
+ $this->getTableFromIdentifier($column, $table)
+ );
+
+ if (!$field) {
+ throw new Exception\InvalidFieldException($fieldName);
+ }
+
+ $condition = $this->parseCondition($condition);
+ $operator = ArrayUtils::get($condition, 'operator');
+ $value = ArrayUtils::get($condition, 'value');
+ $not = ArrayUtils::get($condition, 'not');
+ $logical = ArrayUtils::get($condition, 'logical');
+
+ // TODO: if there's more, please add a better way to handle all this
+ if ($field->isToMany()) {
+ // translate some non-x2m relationship filter to x2m equivalent (if exists)
+ switch ($operator) {
+ case 'empty':
+ // convert x2m empty
+ // to not has at least one record
+ $operator = 'has';
+ $not = true;
+ $value = 1;
+ break;
+ }
+ }
+
+ // Get information about the operator shorthand
+ if (ArrayUtils::has($this->operatorShorthand, $operator)) {
+ $operatorShorthand = $this->operatorShorthand[$operator];
+ $operator = ArrayUtils::get($operatorShorthand, 'operator', $operator);
+ $not = ArrayUtils::get($operatorShorthand, 'not', !$value);
+ }
+
+ $operatorName = StringUtils::underscoreToCamelCase(strtolower($operator), true);
+ $method = 'where' . ($not === true ? 'Not' : '') . $operatorName;
+ if (!method_exists($query, $method)) {
+ return false;
+ }
+
+ $splitOperators = ['between', 'in'];
+ // TODO: Add exception for API 2.0
+ if (in_array($operator, $splitOperators) && is_scalar($value)) {
+ $value = explode(',', $value);
+ }
+
+ $arguments = [$column, $value];
+
+ if (isset($logical)) {
+ $arguments[] = null;
+ $arguments[] = $logical;
+ }
+
+ if (in_array($operator, ['all', 'has']) && $field->isToMany()) {
+ if ($operator == 'all' && is_string($value)) {
+ $value = array_map(function ($item) {
+ return trim($item);
+ }, explode(',', $value));
+ } else if ($operator == 'has') {
+ $value = (int) $value;
+ }
+
+ $primaryKey = $this->getTableSchema($table)->getPrimaryField()->getName();
+ $relationship = $field->getRelationship();
+ if ($relationship->getType() == 'ONETOMANY') {
+ $arguments = [
+ $primaryKey,
+ $relationship->getCollectionB(),
+ null,
+ $relationship->getJunctionKeyB(),
+ $value
+ ];
+ } else {
+ $arguments = [
+ $primaryKey,
+ $relationship->getJunctionCollection(),
+ $relationship->getJunctionKeyA(),
+ $relationship->getJunctionKeyB(),
+ $value
+ ];
+ }
+ }
+
+ // TODO: Move this into QueryBuilder if possible
+ if (in_array($operator, ['like']) && $field->isManyToOne()) {
+ $relatedTable = $field->getRelationship()->getCollectionB();
+ $tableSchema = SchemaService::getCollection($relatedTable);
+ $relatedTableColumns = $tableSchema->getFields();
+ $relatedPrimaryColumnName = $tableSchema->getPrimaryField()->getName();
+ $query->orWhereRelational($this->getColumnFromIdentifier($column), $relatedTable, $relatedPrimaryColumnName, function (Builder $query) use ($column, $relatedTable, $relatedTableColumns, $value) {
+ $query->nestOrWhere(function (Builder $query) use ($relatedTableColumns, $relatedTable, $value) {
+ foreach ($relatedTableColumns as $column) {
+ // NOTE: Only search numeric or string type columns
+ $isNumeric = $this->getSchemaManager()->isNumericType($column->getType());
+ $isString = $this->getSchemaManager()->isStringType($column->getType());
+ if (!$column->isAlias() && ($isNumeric || $isString)) {
+ $query->orWhereLike($column->getName(), $value);
+ }
+ }
+ });
+ });
+ } else {
+ call_user_func_array([$query, $method], $arguments);
+ }
+ }
+
+ /**
+ * Process Select Filters (Where conditions)
+ *
+ * @param Builder $query
+ * @param array $filters
+ */
+ protected function processFilter(Builder $query, array $filters = [])
+ {
+ $filters = $this->parseDotFilters($query, $filters);
+
+ foreach ($filters as $column => $condition) {
+ if ($condition instanceof Filter) {
+ $column = $condition->getIdentifier();
+ $condition = $condition->getValue();
+ }
+
+ $this->doFilter($query, $column, $condition, $this->getTable());
+ }
+ }
+
+ /**
+ * Process column joins
+ *
+ * @param Builder $query
+ * @param array $joins
+ */
+ protected function processJoins(Builder $query, array $joins = [])
+ {
+ // @TODO allow passing columns
+ $columns = []; // leave as this and won't get any ambiguous columns
+ foreach ($joins as $table => $params) {
+ // TODO: Reduce this into a simpler instructions
+ // by simpler it means remove the duplicate join() line
+ if (isset($params['on'])) {
+ // simple joins style
+ // 'table' => ['on' => ['col1', 'col2'] ]
+ if (!isset($params['type'])) {
+ $params['type'] = 'INNER';
+ }
+
+ $params['on'] = implode('=', $params['on']);
+
+ $query->join($table, $params['on'], $columns, $params['type']);
+ } else {
+ // many join style
+ // 'table' => [ ['on' => ['col1', 'col2'] ] ]
+ foreach ($params as $method => $options) {
+ if (! isset($options['type'])) {
+ $options['type'] = 'INNER';
+ }
+ $query->join($table, $options['on'], $columns, $options['type']);
+ }
+ }
+ }
+ }
+
+ /**
+ * Process group-by
+ *
+ * @param Builder $query
+ * @param array|string $columns
+ */
+ protected function processGroups(Builder $query, $columns = [])
+ {
+ if (!is_array($columns)) {
+ $columns = explode(',', $columns);
+ }
+
+ $query->groupBy($columns);
+ }
+
+ /**
+ * Process Query search
+ *
+ * @param Builder $query
+ * @param $search
+ */
+ protected function processQ(Builder $query, $search)
+ {
+ $columns = SchemaService::getAllCollectionFields($this->getTable());
+ $table = $this->getTable();
+
+ $query->nestWhere(function (Builder $query) use ($columns, $search, $table) {
+ foreach ($columns as $column) {
+ // NOTE: Only search numeric or string type columns
+ $isNumeric = $this->getSchemaManager()->isNumericType($column->getType());
+ $isString = $this->getSchemaManager()->isStringType($column->getType());
+ if (!$isNumeric && !$isString) {
+ continue;
+ }
+
+ if ($column->isManyToOne()) {
+ $relationship = $column->getRelationship();
+ $relatedTable = $relationship->getCollectionB();
+ $tableSchema = SchemaService::getCollection($relatedTable);
+ $relatedTableColumns = $tableSchema->getFields();
+ $relatedPrimaryColumnName = $tableSchema->getPrimaryKeyName();
+ $query->orWhereRelational($column->getName(), $relatedTable, $relatedPrimaryColumnName, function (Builder $query) use ($column, $relatedTable, $relatedTableColumns, $search) {
+ $query->nestOrWhere(function (Builder $query) use ($relatedTableColumns, $relatedTable, $search) {
+ foreach ($relatedTableColumns as $column) {
+ // NOTE: Only search numeric or string type columns
+ $isNumeric = $this->getSchemaManager()->isNumericType($column->getType());
+ $isString = $this->getSchemaManager()->isStringType($column->getType());
+ if (!$column->isAlias() && ($isNumeric || $isString)) {
+ $query->orWhereLike($column->getName(), $search);
+ }
+ }
+ });
+ });
+ } else if ($column->isOneToMany()) {
+ $relationship = $column->getRelationship();
+ $relatedTable = $relationship->getCollectionB();
+ $relatedRightColumn = $relationship->getJunctionKeyB();
+ $relatedTableColumns = SchemaService::getAllCollectionFields($relatedTable);
+
+ $query->from($table);
+ // TODO: Test here it may be not setting the proper primary key name
+ $query->orWhereRelational($this->primaryKeyFieldName, $relatedTable, null, $relatedRightColumn, function(Builder $query) use ($column, $relatedTable, $relatedTableColumns, $search) {
+ foreach ($relatedTableColumns as $column) {
+ // NOTE: Only search numeric or string type columns
+ $isNumeric = $this->getSchemaManager()->isNumericType($column->getType());
+ $isString = $this->getSchemaManager()->isStringType($column->getType());
+ if (!$column->isAlias() && ($isNumeric || $isString)) {
+ $query->orWhereLike($column->getName(), $search, false);
+ }
+ }
+ });
+ } else if ($column->isManyToMany()) {
+ // @TODO: Implement Many to Many search
+ } else if (!$column->isAlias()) {
+ $query->orWhereLike($column->getName(), $search);
+ }
+ }
+ });
+ }
+
+ /**
+ * Process Select Order
+ *
+ * @param Builder $query
+ * @param array $columns
+ *
+ * @throws Exception\InvalidFieldException
+ */
+ protected function processSort(Builder $query, array $columns)
+ {
+ foreach ($columns as $column) {
+ $compact = compact_sort_to_array($column);
+ $orderBy = key($compact);
+ $orderDirection = current($compact);
+
+ if (!SchemaService::hasCollectionField($this->table, $orderBy, $this->acl === null)) {
+ throw new Exception\InvalidFieldException($column);
+ }
+
+ $query->orderBy($orderBy, $orderDirection);
+ }
+ }
+
+ /**
+ * Process Select Limit
+ *
+ * @param Builder $query
+ * @param int $limit
+ */
+ protected function processLimit(Builder $query, $limit)
+ {
+ $query->limit((int) $limit);
+ }
+
+ /**
+ * Process Select offset
+ *
+ * @param Builder $query
+ * @param int $offset
+ */
+ protected function processOffset(Builder $query, $offset)
+ {
+ $query->offset((int) $offset);
+ }
+
+ /**
+ * Apply legacy params to support old api requests
+ *
+ * @param Builder $query
+ * @param array $params
+ *
+ * @throws Exception\FieldNotFoundException
+ */
+ protected function applyLegacyParams(Builder $query, array $params = [])
+ {
+ $skipAcl = $this->acl === null;
+ if (ArrayUtils::get($params, 'status') && SchemaService::hasStatusField($this->getTable(), $skipAcl)) {
+ $statuses = $params['status'];
+ if (!is_array($statuses)) {
+ $statuses = array_map(function($item) {
+ return trim($item);
+ }, explode(',', $params['status']));
+ }
+
+ // $statuses = array_filter($statuses, function ($value) {
+ // return is_numeric($value);
+ // });
+
+ if ($statuses) {
+ $query->whereIn(SchemaService::getStatusFieldName(
+ $this->getTable(),
+ $this->acl === null
+ ), $statuses);
+ }
+ }
+
+ if (ArrayUtils::has($params, 'id')) {
+ $entriesIds = $params['id'];
+ if (is_string($entriesIds)) {
+ $entriesIds = StringUtils::csv($entriesIds, false);
+ }
+
+ if (!is_array($entriesIds)) {
+ $entriesIds = [$entriesIds];
+ }
+
+ $idsCount = count($entriesIds);
+ if ($idsCount > 0) {
+ $query->whereIn($this->primaryKeyFieldName, $entriesIds);
+ $query->limit($idsCount);
+ }
+ }
+
+ if (!ArrayUtils::has($params, 'q')) {
+ $search = ArrayUtils::get($params, 'search', '');
+
+ if ($search) {
+ $columns = SchemaService::getAllNonAliasCollectionFields($this->getTable());
+ $query->nestWhere(function (Builder $query) use ($columns, $search) {
+ foreach ($columns as $column) {
+ if ($column->getType() === 'VARCHAR' || $column->getType()) {
+ $query->whereLike($column->getName(), $search);
+ }
+ }
+ }, 'or');
+ }
+ }
+ }
+
+ /**
+ * Throws error if column or relation is missing values
+ * @param array $column One schema column representation.
+ * @param array $requiredKeys Values requiring definition.
+ * @param string $tableName
+ * @return void
+ * @throws \Directus\Database\Exception\RelationshipMetadataException If the required values are undefined.
+ */
+ private function enforceColumnHasNonNullValues($column, $requiredKeys, $tableName)
+ {
+ $erroneouslyNullKeys = [];
+ foreach ($requiredKeys as $key) {
+ if (!isset($column[$key]) || (strlen(trim($column[$key])) === 0)) {
+ $erroneouslyNullKeys[] = $key;
+ }
+ }
+ if (!empty($erroneouslyNullKeys)) {
+ $msg = 'Required column/ui metadata columns on table ' . $tableName . ' lack values: ';
+ $msg .= implode(' ', $requiredKeys);
+ throw new Exception\RelationshipMetadataException($msg);
+ }
+ }
+
+ /**
+ * Load one to many relational data
+ *
+ * @param array $entries
+ * @param Field[] $columns
+ * @param array $params
+ *
+ * @return bool|array
+ */
+ public function loadOneToManyRelationships($entries, $columns, array $params = [])
+ {
+ $columnsTree = get_unflat_columns($columns);
+ $visibleColumns = $this->getTableSchema()->getFields(array_keys($columnsTree));
+ foreach ($visibleColumns as $alias) {
+ if (!$alias->isAlias() || !$alias->isOneToMany()) {
+ continue;
+ }
+
+ $relatedTableName = $alias->getRelationship()->getCollectionB();
+ if ($this->acl && !SchemaService::canGroupReadCollection($relatedTableName)) {
+ continue;
+ }
+
+ $primaryKey = $this->primaryKeyFieldName;
+ $callback = function($row) use ($primaryKey) {
+ return ArrayUtils::get($row, $primaryKey, null);
+ };
+
+ $ids = array_unique(array_filter(array_map($callback, $entries)));
+ if (empty($ids)) {
+ continue;
+ }
+
+ // Only select the fields not on the currently authenticated user group's read field blacklist
+ $relationalColumnName = $alias->getRelationship()->getFieldB();
+ $tableGateway = new RelationalTableGateway($relatedTableName, $this->adapter, $this->acl);
+ $filterFields = get_array_flat_columns($columnsTree[$alias->getName()]);
+ $filters = [];
+ if (ArrayUtils::get($params, 'lang')) {
+ $langIds = StringUtils::csv(ArrayUtils::get($params, 'lang'));
+ $filters[$alias->getOptions('left_column_name')] = ['in' => $langIds];
+ }
+
+ $results = $tableGateway->loadEntries(array_merge([
+ 'fields' => array_merge([$relationalColumnName], $filterFields),
+ // Fetch all related data
+ 'limit' => -1,
+ 'filter' => array_merge($filters, [
+ $relationalColumnName => ['in' => $ids]
+ ]),
+ ], $params));
+
+ $relatedEntries = [];
+ $selectedFields = $tableGateway->getSelectedFields($filterFields);
+
+ foreach ($results as $row) {
+ // Quick fix
+ // @NOTE: When fetching a column that also has another relational field
+ // the value is not a scalar value but an array with all the data associated to it.
+ // @TODO: Make this result a object so it can be easy to interact.
+ // $row->getId(); RowGateway perhaps?
+ $relationalColumnId = $row[$relationalColumnName];
+ if (is_array($relationalColumnId)) {
+ $relationalColumnId = $relationalColumnId[$tableGateway->primaryKeyFieldName];
+ }
+
+ if (!in_array('*', $filterFields)) {
+ $row = ArrayUtils::pick(
+ $row,
+ $selectedFields
+ );
+ }
+
+ $relatedEntries[$relationalColumnId][] = $row;
+ }
+
+ // Replace foreign keys with foreign rows
+ $relationalColumnName = $alias->getName();
+ foreach ($entries as &$parentRow) {
+ // TODO: Remove all columns not from the original selection
+ // meaning remove the related column and primary key that were selected
+ // but weren't requested at first but were forced to be selected
+ // within directus as directus needs the related and the primary keys to work properly
+ $rows = ArrayUtils::get($relatedEntries, $parentRow[$primaryKey], []);
+ $rows = $this->applyHook('load.relational.onetomany', $rows, ['column' => $alias]);
+ $parentRow[$relationalColumnName] = $rows;
+ }
+ }
+
+ return $entries;
+ }
+
+ /**
+ * Load many to many relational data
+ *
+ * @param array $entries
+ * @param Field[] $columns
+ * @param array $params
+ *
+ * @return bool|array
+ */
+ public function loadManyToManyRelationships($entries, $columns, array $params = [])
+ {
+ $columnsTree = get_unflat_columns($columns);
+ $visibleFields = $this->getTableSchema()->getFields(array_keys($columnsTree));
+
+ foreach ($visibleFields as $alias) {
+ if (!$alias->isAlias() || !$alias->isManyToMany()) {
+ continue;
+ }
+
+ $relatedTableName = $alias->getRelationship()->getCollectionB();
+ if ($this->acl && !SchemaService::canGroupReadCollection($relatedTableName)) {
+ continue;
+ }
+
+ $primaryKey = $this->primaryKeyFieldName;
+ $callback = function($row) use ($primaryKey) {
+ return ArrayUtils::get($row, $primaryKey, null);
+ };
+
+ $ids = array_unique(array_filter(array_map($callback, $entries)));
+ if (empty($ids)) {
+ continue;
+ }
+
+ $junctionKeyLeftColumn = $alias->getRelationship()->getJunctionKeyA();
+ $junctionTableName = $alias->getRelationship()->getJunctionCollection();
+ $junctionTableGateway = new RelationalTableGateway($junctionTableName, $this->getAdapter(), $this->acl);
+ $junctionPrimaryKey = SchemaService::getCollectionPrimaryKey($junctionTableName);
+
+ $selectedFields = null;
+ $fields = $columnsTree[$alias->getName()];
+ if ($fields) {
+ $selectedFields = get_array_flat_columns($fields);
+ array_unshift($selectedFields, $junctionPrimaryKey);
+ }
+
+ $results = $junctionTableGateway->loadEntries(array_merge([
+ // Fetch all related data
+ 'limit' => -1,
+ // Add the aliases of the join columns to prevent being removed from array
+ // because there aren't part of the "visible" columns list
+ 'fields' => $selectedFields,
+ 'filter' => [
+ new In(
+ $junctionKeyLeftColumn,
+ $ids
+ )
+ ],
+ ], $params));
+
+ $relationalColumnName = $alias->getName();
+ $relatedEntries = [];
+ foreach ($results as $row) {
+ $relatedEntries[$row[$junctionKeyLeftColumn]][] = $row;
+ }
+
+ // Replace foreign keys with foreign rows
+ foreach ($entries as &$parentRow) {
+ $parentRow[$relationalColumnName] = ArrayUtils::get(
+ $relatedEntries,
+ $parentRow[$primaryKey],
+ []
+ );
+ }
+ }
+
+ return $entries;
+ }
+
+ /**
+ * Fetch related, foreign rows for a whole rowset's ManyToOne relationships.
+ * (Given a table's schema and rows, iterate and replace all of its foreign
+ * keys with the contents of these foreign rows.)
+ *
+ * @param array $entries Table rows
+ * @param Field[] $columns
+ * @param array $params
+ *
+ * @return array Revised table rows, now including foreign rows
+ *
+ * @throws Exception\RelationshipMetadataException
+ */
+ public function loadManyToOneRelationships($entries, $columns, array $params = [])
+ {
+ $columnsTree = get_unflat_columns($columns);
+ $visibleColumns = $this->getTableSchema()->getFields(array_keys($columnsTree));
+ foreach ($visibleColumns as $column) {
+ if (!$column->isManyToOne()) {
+ continue;
+ }
+
+ $relatedTable = $column->getRelationship()->getCollectionB();
+
+ // if user doesn't have permission to view the related table
+ // fill the data with only the id, which the user has permission to
+ if ($this->acl && !SchemaService::canGroupReadCollection($relatedTable)) {
+ $tableGateway = new RelationalTableGateway($relatedTable, $this->adapter, null);
+ $primaryKeyName = $tableGateway->primaryKeyFieldName;
+
+ foreach ($entries as $i => $entry) {
+ $entries[$i][$column->getName()] = [
+ $primaryKeyName => $entry[$column->getName()]
+ ];
+ }
+
+ continue;
+ }
+
+ $tableGateway = new RelationalTableGateway($relatedTable, $this->adapter, $this->acl);
+ $primaryKeyName = $tableGateway->primaryKeyFieldName;
+
+ if (!$relatedTable) {
+ $message = 'Non single_file Many-to-One relationship lacks `related_table` value.';
+
+ if ($column->getName()) {
+ $message .= ' Column: ' . $column->getName();
+ }
+
+ if ($column->getCollectionName()) {
+ $message .= ' Table: ' . $column->getCollectionName();
+ }
+
+ throw new Exception\RelationshipMetadataException($message);
+ }
+
+ // Aggregate all foreign keys for this relationship (for each row, yield the specified foreign id)
+ $relationalColumnName = $column->getName();
+ $yield = function ($row) use ($relationalColumnName, $entries, $primaryKeyName) {
+ if (array_key_exists($relationalColumnName, $row)) {
+ $value = $row[$relationalColumnName];
+ if (is_array($value)) {
+ $value = isset($value[$primaryKeyName]) ? $value[$primaryKeyName] : 0;
+ }
+
+ return $value;
+ }
+ };
+
+ $ids = array_unique(array_filter(array_map($yield, $entries)));
+ if (empty($ids)) {
+ continue;
+ }
+
+ $filterColumns = get_array_flat_columns($columnsTree[$column->getName()]);
+ // Fetch the foreign data
+ $results = $tableGateway->loadEntries(array_merge([
+ // Fetch all related data
+ 'limit' => -1,
+ // Make sure to include the primary key
+ 'fields' => array_merge([$primaryKeyName], $filterColumns),
+ 'filter' => [
+ $primaryKeyName => ['in' => $ids]
+ ],
+ ], $params));
+
+ $relatedEntries = [];
+ foreach ($results as $row) {
+ $rowId = $row[$primaryKeyName];
+ if (!in_array('*', $filterColumns)) {
+ $row = ArrayUtils::pick(
+ $row,
+ $tableGateway->getSelectedFields($filterColumns)
+ );
+ }
+
+ $relatedEntries[$rowId] = $row;
+
+ $tableGateway->wrapData(
+ $relatedEntries[$rowId],
+ true,
+ ArrayUtils::get($params, 'meta', 0)
+ );
+ }
+
+ // Replace foreign keys with foreign rows
+ foreach ($entries as &$parentRow) {
+ if (array_key_exists($relationalColumnName, $parentRow)) {
+ // @NOTE: Not always will be a integer
+ $foreign_id = (int)$parentRow[$relationalColumnName];
+ $parentRow[$relationalColumnName] = null;
+ // "Did we retrieve the foreign row with this foreign ID in our recent query of the foreign table"?
+ if (array_key_exists($foreign_id, $relatedEntries)) {
+ $parentRow[$relationalColumnName] = $relatedEntries[$foreign_id];
+ }
+ }
+ }
+ }
+
+ return $entries;
+ }
+
+ /**
+ *
+ * HELPER FUNCTIONS
+ *
+ **/
+
+ /**
+ * @param $fields
+ *
+ * @return array
+ */
+ public function getSelectedFields(array $fields)
+ {
+ return $this->replaceWildcardFieldWith(
+ $fields,
+ SchemaService::getAllCollectionFieldsName($this->getTable())
+ );
+ }
+
+ /**
+ * Gets the non alias fields from the selected fields
+ *
+ * @param array $fields
+ *
+ * @return array
+ */
+ public function getSelectedNonAliasFields(array $fields)
+ {
+ $nonAliasFields = SchemaService::getAllNonAliasCollectionFieldsName($this->getTableSchema()->getName());
+ $allFields = $this->replaceWildcardFieldWith(
+ $fields,
+ $nonAliasFields
+ );
+
+ // Remove alias fields
+ return ArrayUtils::intersection(
+ $allFields,
+ $nonAliasFields
+ );
+ }
+
+ /**
+ * Returns the related fields from the selected fields array
+ *
+ * @param array $fields
+ *
+ * @return array
+ */
+ public function getSelectedRelatedFields(array $fields)
+ {
+ $fieldsLevel = get_unflat_columns($fields);
+
+ foreach ($fieldsLevel as $parent => $children) {
+ if ($parent === '*') {
+ $parentFields = $fieldsLevel[$parent];
+ unset($fieldsLevel[$parent]);
+ $allFields = SchemaService::getAllCollectionFieldsName($this->getTable());
+ foreach ($allFields as $field) {
+ if (isset($fieldsLevel[$field])) {
+ continue;
+ }
+
+ $fieldsLevel[$field] = $parentFields;
+ }
+
+ break;
+ }
+ }
+
+ $relatedFields = ArrayUtils::intersection(
+ array_keys($fieldsLevel),
+ $this->getTableSchema()->getRelationalFieldsName()
+ );
+
+ return array_filter($fieldsLevel, function ($key) use ($relatedFields) {
+ return in_array($key, $relatedFields);
+ }, ARRAY_FILTER_USE_KEY);
+ }
+
+ /**
+ * Remove the wildcards fields and append the replacement fields
+ *
+ * @param array $fields
+ * @param array $replacementFields
+ *
+ * @return array
+ */
+ protected function replaceWildcardFieldWith(array $fields, array $replacementFields)
+ {
+ $selectedNames = get_columns_flat_at($fields, 0);
+ // remove duplicate field name
+ $selectedNames = array_unique($selectedNames);
+
+ $wildCardIndex = array_search('*', $selectedNames);
+ if ($wildCardIndex !== false) {
+ unset($selectedNames[$wildCardIndex]);
+ $selectedNames = array_merge($selectedNames, $replacementFields);
+ }
+
+ $pickedNames = array_filter($selectedNames, function ($value) {
+ return strpos($value, '-') !== 0;
+ });
+ $omittedNames = array_values(array_map(function ($value) {
+ return substr($value, 1);
+ }, array_filter($selectedNames, function ($value) {
+ return strpos($value, '-') === 0;
+ })));
+
+ return array_values(array_flip(ArrayUtils::omit(array_flip($pickedNames), $omittedNames)));
+ }
+
+ /**
+ * Throws an exception if any of the given field doesn't exists
+ *
+ * @param array $fields
+ *
+ * @throws Exception\InvalidFieldException
+ */
+ public function validateFields(array $fields)
+ {
+ $collection = $this->getTableSchema();
+ $selectedFields = $this->getSelectedFields($fields);
+
+ foreach ($selectedFields as $field) {
+ if (!$collection->hasField($field)) {
+ throw new Exception\InvalidFieldException($field);
+ }
+ }
+ }
+
+ /**
+ * Does this record representation contain non-primary-key information?
+ * Used to determine whether or not to update a foreign record, above and
+ * beyond simply assigning it to a parent.
+ * @param array|RowGateway $record
+ * @param string $pkFieldName
+ * @return boolean
+ */
+ public function recordDataContainsNonPrimaryKeyData($record, $pkFieldName = 'id')
+ {
+ if (is_subclass_of($record, 'Zend\Db\RowGateway\AbstractRowGateway')) {
+ $record = $record->toArray();
+ } elseif (!is_array($record)) {
+ throw new \InvalidArgumentException('$record must an array or a subclass of AbstractRowGateway');
+ }
+
+ $keyCount = count($record);
+
+ return array_key_exists($pkFieldName, $record) ? $keyCount > 1 : $keyCount > 0;
+ }
+
+ /**
+ * Update a collection of records within this table.
+ * @param array $entries Array of records.
+ * @return void
+ */
+ public function updateCollection($entries)
+ {
+ $entries = ArrayUtils::isNumericKeys($entries) ? $entries : [$entries];
+ foreach ($entries as $entry) {
+ $entry = $this->updateRecord($entry);
+ $entry->save();
+ }
+ }
+
+ /**
+ * Get the total entries count
+ *
+ * @param PredicateInterface|null $predicate
+ *
+ * @return int
+ */
+ public function countTotal(PredicateInterface $predicate = null)
+ {
+ $select = new Select($this->table);
+ $select->columns(['total' => new Expression('COUNT(*)')]);
+ if (!is_null($predicate)) {
+ $select->where($predicate);
+ }
+
+ $sql = new Sql($this->adapter, $this->table);
+ $statement = $sql->prepareStatementForSqlObject($select);
+ $results = $statement->execute();
+ $row = $results->current();
+
+ return (int) $row['total'];
+ }
+
+ /**
+ * Only run on tables which have an status column.
+ * @return array
+ */
+ public function countActive()
+ {
+ return $this->countByStatus();
+ }
+
+ public function countByStatus()
+ {
+ $collection = $this->schemaManager->getCollection($this->getTable());
+ if (!$collection->hasStatusField()) {
+ return [];
+ }
+
+ $statusFieldName = $collection->getStatusField()->getName();
+
+ $select = new Select($this->getTable());
+ $select
+ ->columns([$statusFieldName, 'quantity' => new Expression('COUNT(*)')])
+ ->group($statusFieldName);
+
+ $sql = new Sql($this->adapter, $this->table);
+ $statement = $sql->prepareStatementForSqlObject($select);
+ $results = $statement->execute();
+
+ $statusMap = $this->getStatusMapping();
+ $stats = [];
+ foreach ($results as $row) {
+ if (isset($row[$statusFieldName])) {
+ foreach ($statusMap as $status) {
+ if ($status->getValue() == $row[$statusFieldName]) {
+ $stats[$status->getName()] = (int) $row['quantity'];
+ }
+ }
+ }
+ }
+
+ $vals = [];
+ foreach ($statusMap as $value) {
+ array_push($vals, $value->getName());
+ }
+
+ $possibleValues = array_values($vals);
+ $makeMeZero = array_diff($possibleValues, array_keys($stats));
+ foreach ($makeMeZero as $unsetActiveColumn) {
+ $stats[$unsetActiveColumn] = 0;
+ }
+
+ $stats['total_entries'] = array_sum($stats);
+
+ return $stats;
+ }
+}
diff --git a/src/core/Directus/Database/TableGatewayFactory.php b/src/core/Directus/Database/TableGatewayFactory.php
new file mode 100644
index 0000000000..5ddbc7d46f
--- /dev/null
+++ b/src/core/Directus/Database/TableGatewayFactory.php
@@ -0,0 +1,63 @@
+ \Directus\Database\TableGateway\DirectusUsersTableGateway
+ *
+ * @param $tableName
+ * @param array $options
+ *
+ * @return mixed
+ */
+ public static function create($tableName, $options = [])
+ {
+ $tableGatewayClassName = Formatting::underscoreToCamelCase($tableName) . 'TableGateway';
+ $namespace = __NAMESPACE__ . '\\TableGateway\\';
+ $tableGatewayClassName = $namespace . $tableGatewayClassName;
+
+ $acl = ArrayUtils::get($options, 'acl');
+ $dbConnection = ArrayUtils::get($options, 'connection');
+
+ if (static::$container) {
+ if ($acl === null) {
+ $acl = static::$container->get('acl');
+ }
+
+ if ($dbConnection === null) {
+ // TODO: Replace "database" for "connection"
+ $dbConnection = static::$container->get('database');
+ }
+ }
+
+ if (!$acl) {
+ $acl = null;
+ }
+
+ if (class_exists($tableGatewayClassName)) {
+ $instance = new $tableGatewayClassName($dbConnection, $acl);
+ } else {
+ $instance = new RelationalTableGateway($tableName, $dbConnection, $acl);
+ }
+
+ return $instance;
+ }
+}
diff --git a/src/core/Directus/Database/composer.json b/src/core/Directus/Database/composer.json
new file mode 100644
index 0000000000..d573b000da
--- /dev/null
+++ b/src/core/Directus/Database/composer.json
@@ -0,0 +1,33 @@
+{
+ "name": "directus/database",
+ "description": "Directus Database Component",
+ "keywords": [
+ "directus",
+ "database"
+ ],
+ "license": "MIT",
+ "require": {
+ "php": ">=5.5.0",
+ "zendframework/zend-db": "dev-directus",
+ "directus/collection": "^1.0.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "~3.7.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "Directus\\Database\\": ""
+ }
+ },
+ "extra": {
+ "branch-alias": {
+ "dev-version/6.4": "0.9.x-dev"
+ }
+ },
+ "repositories": [
+ {
+ "type": "git",
+ "url": "https://github.com/wellingguzman/zend-db"
+ }
+ ]
+}
diff --git a/src/core/Directus/Embed/EmbedManager.php b/src/core/Directus/Embed/EmbedManager.php
new file mode 100644
index 0000000000..0cc4a6cbdf
--- /dev/null
+++ b/src/core/Directus/Embed/EmbedManager.php
@@ -0,0 +1,69 @@
+providers as $provider) {
+ if ($provider->validateURL($url)) {
+ return $provider->parse($url);
+ }
+ }
+
+ throw new \Exception('No Providers registered.');
+ }
+
+ /**
+ * Register a provider
+ * @param ProviderInterface $provider
+ * @return ProviderInterface
+ */
+ public function register(ProviderInterface $provider)
+ {
+ if (!array_key_exists($provider->getName(), $this->providers)) {
+ $this->providers[$provider->getName()] = $provider;
+ }
+
+ return $this->providers[$provider->getName()];
+ }
+
+ /**
+ * Get a registered provider
+ * @param $name
+ * @return ProviderInterface|null
+ */
+ public function get($name)
+ {
+ return array_key_exists($name, $this->providers) ? $this->providers[$name] : null;
+ }
+
+ /**
+ * Get a registered provider by embed type
+ * @param $type
+ * @return ProviderInterface|null
+ */
+ public function getByType($type)
+ {
+ preg_match('/embed\/([a-zA-Z0-9]+)/', $type, $matches);
+
+ $name = isset($matches[1]) ? $matches[1] : null;
+
+ return $name ? $this->get($name) : null;
+ }
+}
diff --git a/src/core/Directus/Embed/Provider/AbstractProvider.php b/src/core/Directus/Embed/Provider/AbstractProvider.php
new file mode 100644
index 0000000000..98973d363f
--- /dev/null
+++ b/src/core/Directus/Embed/Provider/AbstractProvider.php
@@ -0,0 +1,137 @@
+config = $config;
+ }
+
+ /**
+ * Parse a given URL
+ * @param $url
+ * @return mixed
+ */
+ public function parse($url)
+ {
+ if (!filter_var($url, FILTER_VALIDATE_URL)) {
+ throw new \InvalidArgumentException('Invalid or unsupported URL');
+ }
+
+ if (!$this->validateURL($url)) {
+ throw new \InvalidArgumentException(
+ sprintf('URL: "%s" cannot be parsed by "%s"', $url, get_class($this))
+ );
+ }
+
+ $embedID = $this->parseURL($url);
+
+ return $this->parseID($embedID);
+ }
+
+ /**
+ * Get the embed provider name
+ * @return string
+ */
+ public function getName()
+ {
+ return strtolower($this->name);
+ }
+
+ /**
+ * Get the embed type
+ * @return string
+ */
+ public function getType()
+ {
+ return 'embed/' . $this->getName();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getCode($data)
+ {
+ return StringUtils::replacePlaceholder($this->getFormatTemplate(), $data);
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getUrl($data)
+ {
+ return StringUtils::replacePlaceholder($this->getFormatUrl(), $data);
+ }
+
+ /**
+ * Get the HTML embed format template
+ * @return mixed
+ */
+ protected function getFormatTemplate()
+ {
+ return '';
+ }
+
+ /**
+ * Parse an embed ID
+ * @param $embedID
+ * @return array
+ */
+ public function parseID($embedID)
+ {
+ $defaultInfo = [
+ 'embed' => $embedID,
+ 'title' => sprintf('%s %s: %s', $this->getName(), $this->getProviderType(), $embedID),
+ 'filesize' => 0,
+ 'filename' => $this->getName() . '_' . $embedID . '.jpg',
+ 'type' => $this->getType()
+ ];
+
+ $info = array_merge($defaultInfo, $this->fetchInfo($embedID));
+ $info['html'] = $this->getCode($info);
+
+ return $info;
+ }
+
+ /**
+ * Get the provider type
+ * @return mixed
+ */
+ abstract public function getProviderType();
+
+ /**
+ * Parsing the url and returning the provider ID
+ * This is a method use for the extended class
+ * @param $url
+ * @return string
+ * @throws \Exception
+ */
+ abstract protected function parseURL($url);
+
+ /**
+ * Fetch the embed information
+ * @param $embedID
+ * @return array
+ */
+ abstract protected function fetchInfo($embedID);
+}
diff --git a/src/core/Directus/Embed/Provider/ProviderInterface.php b/src/core/Directus/Embed/Provider/ProviderInterface.php
new file mode 100644
index 0000000000..98465472c3
--- /dev/null
+++ b/src/core/Directus/Embed/Provider/ProviderInterface.php
@@ -0,0 +1,79 @@
+getThumbnail($result['thumbnail_large']);
+
+ return $info;
+ }
+
+ /**
+ * Fetch Video thumbnail data
+ * @param $thumb - url
+ * @return string
+ */
+ protected function getThumbnail($thumb)
+ {
+ $content = @file_get_contents($thumb);
+ $thumbnail = '';
+
+ if ($content) {
+ $thumbnail = 'data:image/jpeg;base64,' . base64_encode($content);
+ }
+
+ return $thumbnail;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function getFormatTemplate()
+ {
+ return '';
+ }
+}
diff --git a/src/core/Directus/Embed/Provider/YoutubeProvider.php b/src/core/Directus/Embed/Provider/YoutubeProvider.php
new file mode 100644
index 0000000000..19d8525580
--- /dev/null
+++ b/src/core/Directus/Embed/Provider/YoutubeProvider.php
@@ -0,0 +1,129 @@
+getThumbnail($videoID);
+
+ if (!isset($this->config['youtube_api_key']) || empty($this->config['youtube_api_key'])) {
+ return $info;
+ }
+
+ $youtubeFormatUrlString = 'https://www.googleapis.com/youtube/v3/videos?id=%s&key=%s&part=snippet,contentDetails';
+ $url = sprintf($youtubeFormatUrlString, $videoID, $this->config['youtube_api_key']);
+
+ $ch = curl_init();
+ curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_URL, $url);
+ $response = curl_exec($ch);
+
+ $content = json_decode($response);
+ if (!$content) {
+ return $info;
+ }
+
+ if (property_exists($content, 'error')) {
+ throw new \Exception('Bad YouTube API Key');
+ }
+
+ if (property_exists($content, 'items') && count($content->items) > 0) {
+ $videoDataSnippet = $content->items[0]->snippet;
+
+ $info['title'] = $videoDataSnippet->title;
+ $info['description'] = $videoDataSnippet->description;
+ $tags = '';
+ if (property_exists($videoDataSnippet, 'tags')) {
+ $tags = implode(',', $videoDataSnippet->tags);
+ }
+ $info['tags'] = $tags;
+
+ $videoContentDetails = $content->items[0]->contentDetails;
+ $videoStart = new \DateTime('@0'); // Unix epoch
+ $videoStart->add(new \DateInterval($videoContentDetails->duration));
+ $info['duration'] = $videoStart->format('U');
+ }
+
+ return $info;
+ }
+
+ /**
+ * Fetch Video thumbnail data
+ * @param $videoID
+ * @return string
+ */
+ protected function getThumbnail($videoID)
+ {
+ $content = @file_get_contents('http://img.youtube.com/vi/' . $videoID . '/0.jpg');
+
+ $thumbnail = '';
+ if ($content) {
+ $thumbnail = 'data:image/jpeg;base64,' . base64_encode($content);
+ }
+
+ return $thumbnail;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function getFormatTemplate()
+ {
+ return '';
+ }
+}
diff --git a/src/core/Directus/Exception/BadRequestException.php b/src/core/Directus/Exception/BadRequestException.php
new file mode 100644
index 0000000000..09a77d4b4c
--- /dev/null
+++ b/src/core/Directus/Exception/BadRequestException.php
@@ -0,0 +1,13 @@
+attributes;
+ }
+}
diff --git a/src/core/Directus/Exception/ForbiddenException.php b/src/core/Directus/Exception/ForbiddenException.php
new file mode 100644
index 0000000000..9e93f09853
--- /dev/null
+++ b/src/core/Directus/Exception/ForbiddenException.php
@@ -0,0 +1,8 @@
+uploadedError = $errorCode;
+ parent::__construct(get_uploaded_file_error($errorCode));
+ }
+
+ public function getErrorCode()
+ {
+ return static::ERROR_CODE + $this->uploadedError;
+ }
+}
diff --git a/src/core/Directus/Filesystem/Exception/FilesystemException.php b/src/core/Directus/Filesystem/Exception/FilesystemException.php
new file mode 100644
index 0000000000..8bd1dcf5da
--- /dev/null
+++ b/src/core/Directus/Filesystem/Exception/FilesystemException.php
@@ -0,0 +1,15 @@
+ '',
+ 'tags' => '',
+ 'location' => ''
+ ];
+
+ /**
+ * Hook Emitter Instance
+ *
+ * @var \Directus\Hook\Emitter
+ */
+ protected $emitter;
+
+ public function __construct($filesystem, $config, array $settings, $emitter)
+ {
+ $this->filesystem = $filesystem;
+ $this->config = $config;
+ $this->emitter = $emitter;
+ $this->filesSettings = $settings;
+ }
+
+ // @TODO: remove exists() and rename() method
+ // and move it to Directus\Filesystem Wrapper
+ public function exists($path)
+ {
+ return $this->filesystem->getAdapter()->has($path);
+ }
+
+ public function rename($path, $newPath, $replace = false)
+ {
+ if ($replace === true && $this->filesystem->exists($newPath)) {
+ $this->filesystem->getAdapter()->delete($newPath);
+ }
+
+ return $this->filesystem->getAdapter()->rename($path, $newPath);
+ }
+
+ public function delete($file)
+ {
+ if ($this->exists($file['filename'])) {
+ $this->emitter->run('files.deleting', [$file]);
+ $this->filesystem->getAdapter()->delete($file['filename']);
+ $this->emitter->run('files.deleting:after', [$file]);
+ }
+ }
+
+ /**
+ * Copy $_FILES data into directus media
+ *
+ * @param array $file $_FILES data
+ *
+ * @return array directus file info data
+ */
+ public function upload(array $file)
+ {
+ $filePath = $file['tmp_name'];
+ $fileName = $file['name'];
+
+ $fileData = array_merge($this->defaults, $this->processUpload($filePath, $fileName));
+
+ return [
+ 'type' => $fileData['type'],
+ 'name' => $fileData['name'],
+ 'title' => $fileData['title'],
+ 'tags' => $fileData['tags'],
+ 'description' => $fileData['caption'],
+ 'location' => $fileData['location'],
+ 'charset' => $fileData['charset'],
+ 'size' => $fileData['size'],
+ 'width' => $fileData['width'],
+ 'height' => $fileData['height'],
+ // @TODO: Returns date in ISO 8601 Ex: 2016-06-06T17:18:20Z
+ // see: https://en.wikipedia.org/wiki/ISO_8601
+ 'date_uploaded' => $fileData['date_uploaded'],// . ' UTC',
+ 'storage_adapter' => $fileData['storage_adapter']
+ ];
+ }
+
+ /**
+ * Get URL info
+ *
+ * @param string $url
+ *
+ * @return array
+ */
+ public function getLink($url)
+ {
+ // @TODO: use oEmbed
+ // @TODO: better provider url validation
+ // checking for 'youtube.com' for a valid youtube video is wrong
+ // we can also be using youtube.com/img/a/youtube/image.jpg
+ // which should fallback to ImageProvider
+ // instead checking for a url with 'youtube.com/watch' with v param or youtu.be/
+ $app = Application::getInstance();
+ $embedManager = $app->getContainer()->get('embed_manager');
+ try {
+ $info = $embedManager->parse($url);
+ } catch (\Exception $e) {
+ $info = $this->getImageFromURL($url);
+ }
+
+ if ($info) {
+ $info['upload_date'] = DateTimeUtils::nowInUTC()->toString();
+ $info['storage_adapter'] = $this->getConfig('adapter');
+ $info['charset'] = isset($info['charset']) ? $info['charset'] : '';
+ }
+
+ return $info;
+ }
+
+ /**
+ * Gets the mime-type from the content type
+ *
+ * @param $contentType
+ *
+ * @return string
+ */
+ protected function getMimeTypeFromContentType($contentType)
+ {
+ // split the data type if it has charset or boundaries set
+ // ex: image/jpg;charset=UTF8
+ if (strpos($contentType, ';') !== false) {
+ $contentType = array_map('trim', explode(';', $contentType));
+ }
+
+ if (is_array($contentType)) {
+ $contentType = $contentType[0];
+ }
+
+ return $contentType;
+ }
+
+ /**
+ * Get Image from URL
+ *
+ * @param $url
+ * @return array
+ */
+ protected function getImageFromURL($url)
+ {
+ stream_context_set_default([
+ 'http' => [
+ 'method' => 'HEAD'
+ ]
+ ]);
+
+ $urlHeaders = get_headers($url, 1);
+
+ stream_context_set_default([
+ 'http' => [
+ 'method' => 'GET'
+ ]
+ ]);
+
+ $info = [];
+
+ $contentType = $this->getMimeTypeFromContentType($urlHeaders['Content-Type']);
+
+ if (strpos($contentType, 'image/') === false) {
+ return $info;
+ }
+
+ $urlInfo = parse_url_file($url);
+ $content = file_get_contents($url);
+ if (!$content) {
+ return $info;
+ }
+
+ list($width, $height) = getimagesizefromstring($content);
+
+ $data = 'data:' . $contentType . ';base64,' . base64_encode($content);
+ $info['title'] = $urlInfo['filename'];
+ $info['name'] = $urlInfo['basename'];
+ $info['size'] = isset($urlHeaders['Content-Length']) ? $urlHeaders['Content-Length'] : 0;
+ $info['type'] = $contentType;
+ $info['width'] = $width;
+ $info['height'] = $height;
+ $info['data'] = $data;
+ $info['charset'] = 'binary';
+
+ return $info;
+ }
+
+ /**
+ * Get base64 data information
+ *
+ * @param $data
+ *
+ * @return array
+ */
+ public function getDataInfo($data)
+ {
+ if (strpos($data, 'data:') === 0) {
+ $parts = explode(',', $data);
+ $data = $parts[1];
+ }
+
+ $info = $this->getFileInfoFromData(base64_decode($data));
+
+ return array_merge(['data' => $data], $info);
+ }
+
+ /**
+ * Copy base64 data into Directus Media
+ *
+ * @param string $fileData - base64 data
+ * @param string $fileName - name of the file
+ * @param bool $replace
+ *
+ * @return array
+ */
+ public function saveData($fileData, $fileName, $replace = false)
+ {
+ $fileData = base64_decode($this->getDataInfo($fileData)['data']);
+
+ // @TODO: merge with upload()
+ $fileName = $this->getFileName($fileName, $replace !== true);
+
+ $filePath = $this->getConfig('root') . '/' . $fileName;
+
+ $this->emitter->run('files.saving', ['name' => $fileName, 'size' => strlen($fileData)]);
+ $this->write($fileName, $fileData, $replace);
+ $this->emitter->run('files.saving:after', ['name' => $fileName, 'size' => strlen($fileData)]);
+
+ unset($fileData);
+
+ $fileData = $this->getFileInfo($fileName);
+ $fileData['title'] = Formatting::fileNameToFileTitle($fileName);
+ $fileData['filename'] = basename($filePath);
+ $fileData['upload_date'] = DateTimeUtils::nowInUTC()->toString();
+ $fileData['storage_adapter'] = $this->config['adapter'];
+
+ $fileData = array_merge($this->defaults, $fileData);
+
+ return [
+ 'type' => $fileData['type'],
+ 'filename' => $fileData['filename'],
+ 'title' => $fileData['title'],
+ 'tags' => $fileData['tags'],
+ 'description' => $fileData['description'],
+ 'location' => $fileData['location'],
+ 'charset' => $fileData['charset'],
+ 'filesize' => $fileData['size'],
+ 'width' => $fileData['width'],
+ 'height' => $fileData['height'],
+ 'storage_adapter' => $fileData['storage_adapter']
+ ];
+ }
+
+ /**
+ * Save embed url into Directus Media
+ *
+ * @param array $fileInfo - File Data/Info
+ *
+ * @return array - file info
+ */
+ public function saveEmbedData(array $fileInfo)
+ {
+ if (!array_key_exists('type', $fileInfo) || strpos($fileInfo['type'], 'embed/') !== 0) {
+ return [];
+ }
+
+ $fileName = isset($fileInfo['filename']) ? $fileInfo['filename'] : md5(time()) . '.jpg';
+ $imageData = $this->saveData($fileInfo['data'], $fileName);
+
+ return array_merge($imageData, $fileInfo, [
+ 'filename' => $fileName
+ ]);
+ }
+
+ /**
+ * Get file info
+ *
+ * @param string $path - file path
+ * @param bool $outside - if the $path is outside of the adapter root path.
+ *
+ * @throws \RuntimeException
+ *
+ * @return array file information
+ */
+ public function getFileInfo($path, $outside = false)
+ {
+ if ($outside === true) {
+ $buffer = file_get_contents($path);
+ } else {
+ $buffer = $this->filesystem->getAdapter()->read($path);
+ }
+
+ return $this->getFileInfoFromData($buffer);
+ }
+
+ public function getFileInfoFromData($data)
+ {
+ if (!class_exists('\finfo')) {
+ throw new \RuntimeException('PHP File Information extension was not loaded.');
+ }
+
+ $finfo = new \finfo(FILEINFO_MIME);
+ $type = explode('; charset=', $finfo->buffer($data));
+
+ $mime = $type[0];
+ $charset = $type[1];
+ $typeTokens = explode('/', $mime);
+
+ $info = [
+ 'type' => $mime,
+ 'format' => $typeTokens[1],
+ 'charset' => $charset,
+ 'size' => strlen($data),
+ 'width' => null,
+ 'height' => null
+ ];
+
+ if ($typeTokens[0] == 'image') {
+ $meta = [];
+ // @TODO: use this as fallback for finfo?
+ $imageInfo = getimagesizefromstring($data, $meta);
+
+ $info['width'] = $imageInfo[0];
+ $info['height'] = $imageInfo[1];
+
+ if (isset($meta['APP13'])) {
+ $iptc = iptcparse($meta['APP13']);
+
+ if (isset($iptc['2#120'])) {
+ $info['caption'] = $iptc['2#120'][0];
+ }
+
+ if (isset($iptc['2#005']) && $iptc['2#005'][0] != '') {
+ $info['title'] = $iptc['2#005'][0];
+ }
+
+ if (isset($iptc['2#025'])) {
+ $info['tags'] = implode(',', $iptc['2#025']);
+ }
+
+ $location = [];
+ if (isset($iptc['2#090']) && $iptc['2#090'][0] != '') {
+ $location[] = $iptc['2#090'][0];
+ }
+
+ if (isset($iptc['2#095'][0]) && $iptc['2#095'][0] != '') {
+ $location[] = $iptc['2#095'][0];
+ }
+
+ if (isset($iptc['2#101']) && $iptc['2#101'][0] != '') {
+ $location[] = $iptc['2#101'][0];
+ }
+
+ $info['location'] = implode(', ', $location);
+ }
+ }
+
+ unset($data);
+
+ return $info;
+ }
+
+ /**
+ * Get file settings
+ *
+ * @param string $key - Optional setting key name
+ *
+ * @return mixed
+ */
+ public function getSettings($key = '')
+ {
+ if (!$key) {
+ return $this->filesSettings;
+ } else if (array_key_exists($key, $this->filesSettings)) {
+ return $this->filesSettings[$key];
+ }
+
+ return false;
+ }
+
+ /**
+ * Get filesystem config
+ *
+ * @param string $key - Optional config key name
+ *
+ * @return mixed
+ */
+ public function getConfig($key = '')
+ {
+ if (!$key) {
+ return $this->config;
+ } else if (array_key_exists($key, $this->config)) {
+ return $this->config[$key];
+ }
+
+ return false;
+ }
+
+ /**
+ * Writes the given data in the given location
+ *
+ * @param $location
+ * @param $data
+ * @param bool $replace
+ *
+ * @throws \RuntimeException
+ */
+ public function write($location, $data, $replace = false)
+ {
+ $this->filesystem->write($location, $data, $replace);
+ }
+
+ /**
+ * Reads and returns data from the given location
+ *
+ * @param $location
+ *
+ * @return bool|false|string
+ *
+ * @throws \Exception
+ */
+ public function read($location)
+ {
+ try {
+ return $this->filesystem->getAdapter()->read($location);
+ } catch (\Exception $e) {
+ throw $e;
+ }
+ }
+
+ /**
+ * Creates a new file for Directus Media
+ *
+ * @param string $filePath
+ * @param string $targetName
+ *
+ * @return array file info
+ */
+ private function processUpload($filePath, $targetName)
+ {
+ // set true as $filePath it's outside adapter path
+ // $filePath is on a temporary php directory
+ $fileData = $this->getFileInfo($filePath, true);
+ $mediaPath = $this->filesystem->getPath();
+
+ $fileData['title'] = Formatting::fileNameToFileTitle($targetName);
+
+ $targetName = $this->getFileName($targetName);
+ $finalPath = rtrim($mediaPath, '/') . '/' . $targetName;
+ $data = file_get_contents($filePath);
+
+ $this->emitter->run('files.saving', ['name' => $targetName, 'size' => strlen($data)]);
+ $this->write($targetName, $data);
+ $this->emitter->run('files.saving:after', ['name' => $targetName, 'size' => strlen($data)]);
+
+ $fileData['name'] = basename($finalPath);
+ $fileData['date_uploaded'] = DateTimeUtils::nowInUTC()->toString();
+ $fileData['storage_adapter'] = $this->config['adapter'];
+
+ return $fileData;
+ }
+
+ /**
+ * Sanitize title name from file name
+ *
+ * @param string $fileName
+ *
+ * @return string
+ */
+ private function sanitizeName($fileName)
+ {
+ // do not start with dot
+ $fileName = preg_replace('/^\./', 'dot-', $fileName);
+ $fileName = str_replace(' ', '_', $fileName);
+
+ return $fileName;
+ }
+
+ /**
+ * Add suffix number to file name if already exists.
+ *
+ * @param string $fileName
+ * @param string $targetPath
+ * @param int $attempt - Optional
+ *
+ * @return bool
+ */
+ private function uniqueName($fileName, $targetPath, $attempt = 0)
+ {
+ $info = pathinfo($fileName);
+ // @TODO: this will fail when the filename doesn't have extension
+ $ext = $info['extension'];
+ $name = basename($fileName, ".$ext");
+
+ $name = $this->sanitizeName($name);
+
+ $fileName = "$name.$ext";
+ if ($this->filesystem->exists($fileName)) {
+ $matches = [];
+ $trailingDigit = '/\-(\d)\.(' . $ext . ')$/';
+ if (preg_match($trailingDigit, $fileName, $matches)) {
+ // Convert "fname-1.jpg" to "fname-2.jpg"
+ $attempt = 1 + (int)$matches[1];
+ $newName = preg_replace($trailingDigit, "-{$attempt}.$ext", $fileName);
+ $fileName = basename($newName);
+ } else {
+ if ($attempt) {
+ $name = rtrim($name, $attempt);
+ $name = rtrim($name, '-');
+ }
+ $attempt++;
+ $fileName = $name . '-' . $attempt . '.' . $ext;
+ }
+ return $this->uniqueName($fileName, $targetPath, $attempt);
+ }
+
+ return $fileName;
+ }
+
+ /**
+ * Get file name based on file naming setting
+ *
+ * @param string $fileName
+ * @param bool $unique
+ *
+ * @return string
+ */
+ private function getFileName($fileName, $unique = true)
+ {
+ switch ($this->getSettings('file_naming')) {
+ case 'file_hash':
+ $fileName = $this->hashFileName($fileName);
+ break;
+ }
+
+ if ($unique) {
+ $fileName = $this->uniqueName($fileName, $this->filesystem->getPath());
+ }
+
+ return $fileName;
+ }
+
+ /**
+ * Hash file name
+ *
+ * @param string $fileName
+ *
+ * @return string
+ */
+ private function hashFileName($fileName)
+ {
+ $ext = pathinfo($fileName, PATHINFO_EXTENSION);
+ $fileHashName = md5(microtime() . $fileName);
+ return $fileHashName . '.' . $ext;
+ }
+
+ /**
+ * Get string between two string
+ *
+ * @param string $string
+ * @param string $start
+ * @param string $end
+ *
+ * @return string
+ */
+ private function get_string_between($string, $start, $end)
+ {
+ $string = ' ' . $string;
+ $ini = strpos($string, $start);
+ if ($ini == 0) return '';
+ $ini += strlen($start);
+ $len = strpos($string, $end, $ini) - $ini;
+ return substr($string, $ini, $len);
+ }
+
+ /**
+ * Get URL info
+ *
+ * @param string $link
+ *
+ * @return array
+ */
+ public function getLinkInfo($link)
+ {
+ $fileData = [];
+ $width = 0;
+ $height = 0;
+
+ $urlHeaders = get_headers($link, 1);
+ $contentType = $this->getMimeTypeFromContentType($urlHeaders['Content-Type']);
+
+ if (strpos($contentType, 'image/') === 0) {
+ list($width, $height) = getimagesize($link);
+ }
+
+ $urlInfo = pathinfo($link);
+ $linkContent = file_get_contents($link);
+ $url = 'data:' . $contentType . ';base64,' . base64_encode($linkContent);
+
+ $fileData = array_merge($fileData, [
+ 'type' => $contentType,
+ 'name' => $urlInfo['basename'],
+ 'title' => $urlInfo['filename'],
+ 'charset' => 'binary',
+ 'size' => isset($urlHeaders['Content-Length']) ? $urlHeaders['Content-Length'] : 0,
+ 'width' => $width,
+ 'height' => $height,
+ 'data' => $url,
+ 'url' => ($width) ? $url : ''
+ ]);
+
+ return $fileData;
+ }
+}
diff --git a/src/core/Directus/Filesystem/Filesystem.php b/src/core/Directus/Filesystem/Filesystem.php
new file mode 100644
index 0000000000..78a1ae1485
--- /dev/null
+++ b/src/core/Directus/Filesystem/Filesystem.php
@@ -0,0 +1,98 @@
+adapter = $adapter;
+ }
+
+ /**
+ * Check whether a file exists.
+ *
+ * @param string $path
+ *
+ * @return bool
+ */
+ public function exists($path)
+ {
+ return $this->adapter->has($path);
+ }
+
+ /**
+ * Reads and returns data from the given location
+ *
+ * @param $location
+ *
+ * @return bool|false|string
+ *
+ * @throws \Exception
+ */
+ public function read($location)
+ {
+ return $this->adapter->read($location);
+ }
+
+ /**
+ * Writes data to th given location
+ *
+ * @param string $location
+ * @param $data
+ * @param bool $replace
+ */
+ public function write($location, $data, $replace = false)
+ {
+ $throwException = function () use ($location) {
+ throw new ForbiddenException(sprintf('No permission to write: %s', $location));
+ };
+
+ if ($replace === true && $this->exists($location)) {
+ $this->getAdapter()->delete($location);
+ }
+
+ try {
+ if (!$this->getAdapter()->write($location, $data)) {
+ $throwException();
+ }
+ } catch (\Exception $e) {
+ $throwException();
+ }
+ }
+
+ /**
+ * Get the filesystem adapter (flysystem object)
+ *
+ * @return FlysystemInterface
+ */
+ public function getAdapter()
+ {
+ return $this->adapter;
+ }
+
+ /**
+ * Get Filesystem adapter path
+ *
+ * @param string $path
+ * @return string
+ */
+ public function getPath($path = '')
+ {
+ $adapter = $this->adapter->getAdapter();
+
+ if ($path) {
+ return $adapter->applyPathPrefix($path);
+ }
+
+ return $adapter->getPathPrefix();
+ }
+}
diff --git a/src/core/Directus/Filesystem/FilesystemFactory.php b/src/core/Directus/Filesystem/FilesystemFactory.php
new file mode 100644
index 0000000000..e3e9db4024
--- /dev/null
+++ b/src/core/Directus/Filesystem/FilesystemFactory.php
@@ -0,0 +1,55 @@
+getContainer()->get('path_base') . '/' . $root;
+ }
+
+ $root = $root ?: '/';
+
+ return new Flysystem(new LocalAdapter($root));
+ }
+
+ public static function createS3Adapter(Array $config, $rootKey = 'root')
+ {
+ $client = S3Client::factory([
+ 'credentials' => [
+ 'key' => $config['key'],
+ 'secret' => $config['secret'],
+ ],
+ 'region' => $config['region'],
+ 'version' => ($config['version'] ?: 'latest'),
+ ]);
+
+ return new Flysystem(new S3Adapter($client, $config['bucket'], array_get($config, $rootKey)));
+ }
+}
diff --git a/src/core/Directus/Filesystem/Thumbnail.php b/src/core/Directus/Filesystem/Thumbnail.php
new file mode 100644
index 0000000000..d024b5715e
--- /dev/null
+++ b/src/core/Directus/Filesystem/Thumbnail.php
@@ -0,0 +1,204 @@
+ 1) {
+ $newW = $thumbnailSize;
+ $newH = $thumbnailSize / $aspectRatio;
+ }
+ }
+
+ if ($cropEnabled) {
+ $imgResized = imagecreatetruecolor($thumbnailSize, $thumbnailSize);
+ } else {
+ $imgResized = imagecreatetruecolor($newW, $newH);
+ }
+
+ // Preserve transperancy for gifs and pngs
+ if ($format == 'gif' || $format == 'png') {
+ imagealphablending($imgResized, false);
+ imagesavealpha($imgResized, true);
+ $transparent = imagecolorallocatealpha($imgResized, 255, 255, 255, 127);
+ imagefilledrectangle($imgResized, 0, 0, $newW, $newH, $transparent);
+ }
+
+ imagecopyresampled($imgResized, $img, $x1, $y1, 0, 0, $newW, $newH, $w, $h);
+
+ imagedestroy($img);
+ return $imgResized;
+ }
+
+ /**
+ * Create a image from a non image file content. (Ex. PDF, PSD or TIFF)
+ *
+ * @param $content
+ * @param string $format
+ *
+ * @return bool|string
+ */
+ public static function createImageFromNonImage($content, $format = 'jpeg')
+ {
+ if (!extension_loaded('imagick')) {
+ return false;
+ }
+
+ $image = new \Imagick();
+ $image->readImageBlob($content);
+ $image->setIteratorIndex(0);
+ $image->setImageFormat($format);
+
+ return $image->getImageBlob();
+ }
+
+ public static function writeImage($extension, $path, $img, $quality)
+ {
+ ob_start();
+ // force $path to be NULL to dump writeImage on the stream
+ $path = NULL;
+ switch (strtolower($extension)) {
+ case 'jpg':
+ case 'jpeg':
+ imagejpeg($img, $path, $quality);
+ break;
+ case 'gif':
+ imagegif($img, $path);
+ break;
+ case 'png':
+ imagepng($img, $path);
+ break;
+ case 'pdf':
+ case 'psd':
+ case 'tif':
+ case 'tiff':
+ imagejpeg($img, $path, $quality);
+ break;
+ }
+ return ob_get_clean();
+ }
+
+ /**
+ * Gets the default thumbnail format
+ *
+ * @return string
+ */
+ public static function defaultFormat()
+ {
+ return static::$defaultFormat;
+ }
+
+ /**
+ * Gets supported formats
+ *
+ * @return array
+ */
+ public static function getFormatsSupported()
+ {
+ return array_merge(static::getImageFormatSupported(), static::getNonImageFormatSupported());
+ }
+
+ /**
+ * Gets image supported formats
+ *
+ * @return array
+ */
+ public static function getImageFormatSupported()
+ {
+ return Thumbnail::$imageFormatsSupported;
+ }
+
+ /**
+ * Gets non-image supported formats
+ *
+ * @return array
+ */
+ public static function getNonImageFormatSupported()
+ {
+ return static::$nonImageFormatsSupported;
+ }
+
+ /**
+ * If a given format/extension is a non-image supported to generate thumbnail
+ *
+ * @param $format
+ *
+ * @return bool
+ */
+ public static function isNonImageFormatSupported($format)
+ {
+ return in_array(strtolower($format), static::$nonImageFormatsSupported);
+ }
+}
diff --git a/src/core/Directus/Filesystem/Thumbnailer.php b/src/core/Directus/Filesystem/Thumbnailer.php
new file mode 100644
index 0000000000..6ffbe63079
--- /dev/null
+++ b/src/core/Directus/Filesystem/Thumbnailer.php
@@ -0,0 +1,503 @@
+files = $files;
+ $this->filesystem = $main;
+ $this->filesystemThumb = $thumb;
+ $this->config = $config;
+
+ $this->thumbnailParams = $this->extractThumbnailParams($path);
+
+ // check if the original file exists in storage
+ if (! $this->filesystem->exists($this->fileName)) {
+ throw new Exception($this->fileName . ' does not exist.'); // original file doesn't exist
+ }
+
+ // check if dimensions are supported
+ if (! $this->isSupportedThumbnailDimension($this->width, $this->height)) {
+ throw new Exception('Invalid dimensions.');
+ }
+
+ // check if action is supported
+ if ( $this->action && ! $this->isSupportedAction($this->action)) {
+ throw new Exception('Invalid action.');
+ }
+
+ // check if quality is supported
+ if ( $this->quality && ! $this->isSupportedQualityTag($this->quality)) {
+ throw new Exception('Invalid quality.');
+ }
+
+ // relative to configuration['filesystem']['root_thumb']
+ $this->thumbnailDir = $pathPrefix . '/' . $this->width . '/' . $this->height . ($this->action ? '/' . $this->action : '') . ($this->quality ? '/' . $this->quality : '');
+ } catch (Exception $e) {
+ throw $e;
+ }
+ }
+
+ /**
+ * Magic getter for thumbnailParams
+ *
+ * @param string $key
+ * @return string|int
+ */
+ public function __get($key)
+ {
+ return ArrayUtils::get($this->thumbnailParams, $key, null);
+ }
+
+ /**
+ * Return thumbnail as data
+ *
+ * @throws Exception
+ * @return string|null
+ */
+ public function get()
+ {
+ try {
+ if( $this->filesystemThumb->exists($this->thumbnailDir . '/' . $this->fileName) ) {
+ $img = $this->filesystemThumb->read($this->thumbnailDir . '/' . $this->fileName);
+ }
+
+ return isset($img) && $img ? $img : null;
+ }
+
+ catch (Exception $e) {
+ throw $e;
+ }
+ }
+
+ /**
+ * Get thumbnail mime type
+ *
+ * @throws Exception
+ * @return string
+ */
+ public function getThumbnailMimeType()
+ {
+ try {
+ if( $this->filesystemThumb->exists($this->thumbnailDir . '/' . $this->fileName) ) {
+ $img = Image::make($this->filesystemThumb->read($this->thumbnailDir . '/' . $this->fileName));
+ return $img->mime();
+ }
+
+ return 'application/octet-stream';
+ }
+
+ catch (Exception $e) {
+ throw $e;
+ }
+ }
+
+ /**
+ * Create thumbnail from image and `contain`
+ * http://image.intervention.io/api/resize
+ * https://css-tricks.com/almanac/properties/o/object-fit/
+ *
+ * @throws Exception
+ * @return string
+ */
+ public function contain()
+ {
+ try {
+ // action options
+ $options = $this->getSupportedActionOptions($this->action);
+
+ // open file image resource
+ $img = Image::make($this->filesystem->read($this->fileName));
+
+ // crop image
+ $img->resize($this->width, $this->height, function ($constraint) {
+ $constraint->aspectRatio();
+ });
+
+ if( ArrayUtils::get($options, 'resizeCanvas')) {
+ $img->resizeCanvas($this->width, $this->height, ArrayUtils::get($options, 'position', 'center'), ArrayUtils::get($options, 'resizeRelative', false), ArrayUtils::get($options, 'canvasBackground', [255, 255, 255, 0]));
+ }
+
+ $encodedImg = (string) $img->encode(ArrayUtils::get($this->thumbnailParams, 'fileExt'), ($this->quality ? $this->translateQuality($this->quality) : null));
+ $this->filesystemThumb->write($this->thumbnailDir . '/' . $this->fileName, $encodedImg);
+
+ return $encodedImg;
+ }
+
+ catch (Exception $e) {
+ throw $e;
+ }
+ }
+
+ /**
+ * Create thumbnail from image and `crop`
+ * http://image.intervention.io/api/fit
+ * https://css-tricks.com/almanac/properties/o/object-fit/
+ *
+ * @throws Exception
+ * @return string
+ */
+ public function crop()
+ {
+ try {
+ // action options
+ $options = $this->getSupportedActionOptions($this->action);
+
+ // open file image resource
+ $img = Image::make($this->filesystem->read($this->fileName));
+
+ // resize/crop image
+ $img->fit($this->width, $this->height, function($constraint){}, ArrayUtils::get($options, 'position', 'center'));
+
+ $encodedImg = (string) $img->encode(ArrayUtils::get($this->thumbnailParams, 'fileExt'), ($this->quality ? $this->translateQuality($this->quality) : null));
+ $this->filesystemThumb->write($this->thumbnailDir . '/' . $this->fileName, $encodedImg);
+
+ return $encodedImg;
+ }
+
+ catch (Exception $e) {
+ throw $e;
+ }
+ }
+
+ /**
+ * Parse url and extract thumbnail params
+ *
+ * @param string $thumbnailUrlPath
+ * @throws Exception
+ * @return array
+ */
+ public function extractThumbnailParams($thumbnailUrlPath)
+ {
+ try {
+ if ($this->thumbnailParams) {
+ return $this->thumbnailParams;
+ }
+
+ $urlSegments = explode('/', $thumbnailUrlPath);
+
+ // pull the env out of the segments
+ array_shift($urlSegments);
+
+ if (! $urlSegments) {
+ throw new Exception('Invalid thumbnailUrlPath.');
+ }
+
+ // pop off the filename
+ $fileName = ArrayUtils::pop($urlSegments);
+
+ // make sure filename is valid
+ $info = pathinfo($fileName);
+ if (! $this->isSupportedFileExtension((ArrayUtils::get($info, 'extension')))) {
+ throw new Exception('Invalid file extension.');
+ }
+
+ $thumbnailParams = [
+ 'fileName' => ArrayUtils::get($info, 'filename') . '.' . strtolower(ArrayUtils::get($info, 'extension')),
+ 'fileExt' => ArrayUtils::get($info, 'extension')
+ ];
+
+ foreach ($urlSegments as $segment) {
+
+ if (! $segment) continue;
+
+ $hasWidth = ArrayUtils::get($thumbnailParams, 'width');
+ $hasHeight = ArrayUtils::get($thumbnailParams, 'height');
+ // extract width and height
+ if ((!$hasWidth || !$hasHeight) && is_numeric($segment)) {
+ if (!$hasWidth) {
+ ArrayUtils::set($thumbnailParams, 'width', $segment);
+ } else if (!$hasHeight) {
+ ArrayUtils::set($thumbnailParams, 'height', $segment);
+ }
+ }
+
+ // extract action and quality
+ else {
+ if (!ArrayUtils::get($thumbnailParams, 'action')) {
+ ArrayUtils::set($thumbnailParams, 'action', $segment);
+ } else if (!ArrayUtils::get($thumbnailParams, 'quality')) {
+ ArrayUtils::set($thumbnailParams, 'quality', $segment);
+ }
+ }
+ }
+
+ // validate
+ if (! ArrayUtils::contains($thumbnailParams, [
+ 'width',
+ 'height'
+ ])) {
+ throw new Exception('No height or width provided.');
+ }
+
+ // set default action, if needed
+ if (! ArrayUtils::exists($thumbnailParams, 'action')) {
+ ArrayUtils::set($thumbnailParams, 'action', null);
+ }
+
+ // set quality to null, if needed
+ if (! ArrayUtils::exists($thumbnailParams, 'quality')) {
+ ArrayUtils::set($thumbnailParams, 'quality', null);
+ }
+
+ return $thumbnailParams;
+ }
+
+ catch (Exception $e) {
+ throw $e;
+ }
+ }
+
+ /**
+ * Translate quality text to number and return
+ *
+ * @param string $qualityText
+ * @return number
+ */
+ public function translateQuality($qualityText)
+ {
+ $quality = 60;
+ if (!is_numeric($qualityText)) {
+ $quality = ArrayUtils::get($this->getSupportedQualityTags(), $qualityText, $quality);
+ }
+
+ return $quality;
+ }
+
+ /**
+ * Check if given file extension is supported
+ *
+ * @param int $ext
+ * @return boolean
+ */
+ public function isSupportedFileExtension($ext)
+ {
+ return in_array(strtolower($ext), $this->getSupportedFileExtensions());
+ }
+
+ /**
+ * Return supported image file types
+ *
+ * @return array
+ */
+ public function getSupportedFileExtensions()
+ {
+ return Thumbnail::getFormatsSupported();
+ }
+
+ /**
+ * Check if given dimension is supported
+ *
+ * @param int $width
+ * @param int $height
+ * @return boolean
+ */
+ public function isSupportedThumbnailDimension($width, $height)
+ {
+ return in_array($width . 'x' . $height, $this->getSupportedThumbnailDimensions());
+ }
+
+ /**
+ * Return supported thumbnail file dimesions
+ *
+ * @return array
+ */
+ public function getSupportedThumbnailDimensions()
+ {
+ $defaultDimension = '200x200';
+
+ $dimensions = $this->parseCSV(
+ ArrayUtils::get($this->getConfig(), 'dimensions')
+ );
+
+ if (!in_array($defaultDimension, $dimensions)) {
+ array_unshift($dimensions, $defaultDimension);
+ }
+
+ return $dimensions;
+ }
+
+ /**
+ * Check if given action is supported
+ *
+ * @param int $action
+ * @return boolean
+ */
+ public function isSupportedAction($action)
+ {
+ return ArrayUtils::has($this->getSupportedActions(), $action);
+ }
+
+ /**
+ * Return supported actions
+ *
+ * @return array
+ */
+ public function getSupportedActions()
+ {
+ return $this->getActions();
+ }
+
+ /**
+ * Check if given quality is supported
+ *
+ * @param $qualityTag
+ *
+ * @return bool
+ */
+ public function isSupportedQualityTag($qualityTag)
+ {
+ return ArrayUtils::has($this->getSupportedQualityTags(), $qualityTag);
+ }
+
+ /**
+ * Return supported thumbnail qualities
+ *
+ * @return array
+ */
+ public function getSupportedQualityTags()
+ {
+ $defaultQualityTags = [
+ 'poor' => 25,
+ 'good' => 50,
+ 'better' => 75,
+ 'best' => 100,
+ ];
+
+ $qualityTags = ArrayUtils::get($this->getConfig(), 'quality_tags') ?: [];
+ if (is_string($qualityTags)) {
+ $qualityTags = json_decode($qualityTags, true);
+ }
+
+ return array_merge($defaultQualityTags, $qualityTags);
+ }
+
+ /**
+ * Return supported action options as set in config
+ *
+ * @param string $action
+ *
+ * @return array
+ */
+ public function getSupportedActionOptions($action)
+ {
+ return ArrayUtils::get($this->getActions(), $action) ?: [];
+ }
+
+ /**
+ * Returns a list of supported actions
+ *
+ * @return array
+ */
+ public function getActions()
+ {
+ $actions = ArrayUtils::get($this->getConfig(), 'actions');
+ if (is_string($actions) && !empty($actions)) {
+ $actions = json_decode($actions, true);
+ }
+
+ return array_merge($this->getDefaultActions(), (array)$actions);
+ }
+
+ /**
+ * Return a list of the default supported actions
+ *
+ * @return array
+ */
+ public function getDefaultActions()
+ {
+ return [
+ 'contain' => [
+ 'options' => [
+ 'resizeCanvas' => false, // http://image.intervention.io/api/resizeCanvas
+ 'position' => 'center',
+ 'resizeRelative' => false,
+ 'canvasBackground' => 'ccc', // http://image.intervention.io/getting_started/formats
+ ]
+ ],
+ 'crop' => [
+ 'options' => [
+ 'position' => 'center', // http://image.intervention.io/api/fit
+ ]
+ ]
+ ];
+ }
+
+ /**
+ * Merge file and thumbnailer config settings and return
+ *
+ * @throws Exception
+ * @return array
+ */
+ public function getConfig()
+ {
+ return $this->config;
+ }
+
+ /**
+ * Parse csv string to an array
+ *
+ * @param string $value
+ *
+ * @return array
+ */
+ protected function parseCSV($value)
+ {
+ if (is_string($value)) {
+ $value = StringUtils::csv(
+ $value
+ );
+ } else {
+ $value = (array)$value;
+ }
+
+ return $value;
+ }
+}
diff --git a/src/core/Directus/Filesystem/composer.json b/src/core/Directus/Filesystem/composer.json
new file mode 100644
index 0000000000..119056cdb6
--- /dev/null
+++ b/src/core/Directus/Filesystem/composer.json
@@ -0,0 +1,24 @@
+{
+ "name": "directus/filesystem",
+ "description": "Directus Filesystem Library",
+ "keywords": [
+ "directus",
+ "filesystem"
+ ],
+ "license": "MIT",
+ "require": {
+ "php": ">=5.4.0",
+ "league/flysystem": "^1.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "Directus\\Filesystem\\": ""
+ }
+ },
+ "minimum-stability": "dev",
+ "extra": {
+ "branch-alias": {
+ "dev-d64": "0.9.x-dev"
+ }
+ }
+}
diff --git a/src/core/Directus/Hash/Exception/HasherNotFoundException.php b/src/core/Directus/Hash/Exception/HasherNotFoundException.php
new file mode 100644
index 0000000000..6bb78e68f5
--- /dev/null
+++ b/src/core/Directus/Hash/Exception/HasherNotFoundException.php
@@ -0,0 +1,17 @@
+registerDefaultHashers();
+
+ foreach ($hashers as $hasher) {
+ $this->register($hasher);
+ }
+ }
+
+ /**
+ * Hash a given string into the given algorithm
+ *
+ * @param string $string
+ * @param array $options
+ *
+ * @return string
+ */
+ public function hash($string, array $options = [])
+ {
+ $hasher = ArrayUtils::pull($options, 'hasher', 'core');
+
+ return $this->get($hasher)->hash($string, $options);
+ }
+
+ /**
+ * Register a hasher
+ *
+ * @param HasherInterface $hasher
+ */
+ public function register(HasherInterface $hasher)
+ {
+ $this->hashers[$hasher->getName()] = $hasher;
+ }
+
+ public function registerDefaultHashers()
+ {
+ $hashers = [
+ CoreHasher::class,
+ BCryptHasher::class,
+ MD5Hasher::class,
+ Sha1Hasher::class,
+ Sha224Hasher::class,
+ Sha256Hasher::class,
+ Sha384Hasher::class,
+ Sha512Hasher::class
+ ];
+
+ foreach ($hashers as $hasher) {
+ $this->register(new $hasher());
+ }
+ }
+
+ /**
+ * @param $name
+ *
+ * @return HasherInterface
+ *
+ * @throws HasherNotFoundException
+ */
+ public function get($name)
+ {
+ $hasher = ArrayUtils::get($this->hashers, $name);
+
+ if (!$hasher) {
+ throw new HasherNotFoundException($name);
+ }
+
+ return $hasher;
+ }
+}
diff --git a/src/core/Directus/Hash/Hasher/AbstractHashHasher.php b/src/core/Directus/Hash/Hasher/AbstractHashHasher.php
new file mode 100644
index 0000000000..e1c68f56b7
--- /dev/null
+++ b/src/core/Directus/Hash/Hasher/AbstractHashHasher.php
@@ -0,0 +1,14 @@
+getName(), $string);
+ }
+}
diff --git a/src/core/Directus/Hash/Hasher/BCryptHasher.php b/src/core/Directus/Hash/Hasher/BCryptHasher.php
new file mode 100644
index 0000000000..5f72624874
--- /dev/null
+++ b/src/core/Directus/Hash/Hasher/BCryptHasher.php
@@ -0,0 +1,22 @@
+=5.5.0",
+ "directus/utils": "^1.0.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "Directus\\Hash\\": ""
+ }
+ },
+ "extra": {
+ "branch-alias": {
+ "dev-version/6.4": "0.9.x-dev"
+ }
+ }
+}
diff --git a/src/core/Directus/Hook/Emitter.php b/src/core/Directus/Hook/Emitter.php
new file mode 100644
index 0000000000..f87dc704aa
--- /dev/null
+++ b/src/core/Directus/Hook/Emitter.php
@@ -0,0 +1,300 @@
+addListener($name, $listener, $priority, self::TYPE_ACTION);
+ }
+
+ /**
+ * Add a filter listener wit the given name
+ *
+ * @param $name
+ * @param $listener
+ * @param int $priority
+ *
+ * @return int Listener's index {@see removeListenerWithIndex}
+ */
+ public function addFilter($name, $listener, $priority = self::P_NORMAL)
+ {
+ return $this->addListener($name, $listener, $priority, self::TYPE_FILTER);
+ }
+
+ /**
+ * Remove listener with a given index
+ *
+ * @param $index
+ */
+ public function removeListenerWithIndex($index)
+ {
+ $this->listenersList[$index] = null;
+ }
+
+ /**
+ * Execute all the the actions listeners registered in the given name
+ *
+ * An Action execute the given listener and do not return any value.
+ *
+ * @param $name
+ * @param null $data
+ */
+ public function run($name, $data = null)
+ {
+ $listeners = $this->getActionListeners($name);
+
+ $this->executeListeners($listeners, $data, self::TYPE_ACTION);
+ }
+
+ /**
+ * @see Emitter->run();
+ *
+ * @param $name
+ * @param null $data
+ */
+ public function execute($name, $data = null)
+ {
+ $this->run($name, $data);
+ }
+
+ /**
+ * Execute all the the filters listeners registered in the given name
+ *
+ * A Filter execute the given listener and return a modified given value
+ *
+ * @param string $name
+ * @param array $data
+ * @param array $attributes
+ *
+ * @return mixed
+ */
+ public function apply($name, array $data = [], array $attributes = [])
+ {
+ $listeners = $this->getFilterListeners($name);
+
+ $payload = new Payload($data, $attributes);
+
+ if ($listeners) {
+ $payload = $this->executeListeners($listeners, $payload, self::TYPE_FILTER);
+ }
+
+ return $payload->getData();
+ }
+
+ /**
+ * Get all the actions listeners
+ *
+ * @param $name
+ *
+ * @return array
+ */
+ public function getActionListeners($name)
+ {
+ return $this->getListeners($this->actionListeners, $name);
+ }
+
+ /**
+ * Whether the hook action name given has listener or not
+ *
+ * @param $name
+ *
+ * @return bool
+ */
+ public function hasActionListeners($name)
+ {
+ return $this->getActionListeners($name) ? true : false;
+ }
+
+ /**
+ * Get all the filters listeners
+ *
+ * @param $name
+ *
+ * @return array
+ */
+ public function getFilterListeners($name)
+ {
+ return $this->getListeners($this->filterListeners, $name);
+ }
+
+ /**
+ * Whether the hook filter name given has listener or not
+ *
+ * @param $name
+ *
+ * @return bool
+ */
+ public function hasFilterListeners($name)
+ {
+ return $this->getFilterListeners($name) ? true : false;
+ }
+
+ /**
+ * Add a listener
+ *
+ * @param $name
+ * @param $listener
+ * @param int $priority
+ * @param int $type
+ *
+ * @return int Listener's index {@see removeListenerWithIndex}
+ */
+ protected function addListener($name, $listener, $priority = null, $type = self::TYPE_ACTION)
+ {
+ if (is_string($listener) && class_exists($listener)) {
+ $listener = new $listener();
+ }
+
+ if ($priority === null) {
+ $priority = self::P_NORMAL;
+ }
+
+ $this->validateListener($listener);
+
+ $index = array_push($this->listenersList, $listener) - 1;
+
+ $arrayName = ($type == self::TYPE_FILTER) ? 'filter' : 'action';
+ $this->{$arrayName.'Listeners'}[$name][$priority][] = $index;
+
+ return $index;
+ }
+
+ /**
+ * Validate a listener
+ *
+ * @param $listener
+ */
+ protected function validateListener($listener)
+ {
+ if (!is_callable($listener) && !($listener instanceof HookInterface)) {
+ throw new \InvalidArgumentException('Listener needs to be a callable or an instance of \Directus\Hook\HookInterface');
+ }
+ }
+
+ /**
+ * Get all listeners registered into a given name
+ *
+ * @param array $items
+ * @param $name
+ *
+ * @return array
+ */
+ protected function getListeners(array $items, $name)
+ {
+ $functions = [];
+ if (array_key_exists($name, $items)) {
+ $listeners = $items[$name];
+ krsort($listeners);
+ $functions = call_user_func_array('array_merge', $listeners);
+ }
+
+ return $functions;
+ }
+
+ /**
+ * Execute a given listeners list
+ *
+ * @param array $listenersIds
+ * @param null $data
+ * @param int $listenerType
+ *
+ * @return array|mixed|null
+ */
+ protected function executeListeners(array $listenersIds, $data = null, $listenerType = self::TYPE_ACTION)
+ {
+ $isFilterType = ($listenerType == self::TYPE_FILTER);
+ foreach ($listenersIds as $index) {
+ $listener = $this->listenersList[$index];
+
+ if ($listener) {
+ if ($listener instanceof HookInterface) {
+ $listener = [$listener, 'handle'];
+ }
+
+ if (!is_array($data)) {
+ $data = [$data];
+ }
+
+ $returnedValue = call_user_func_array($listener, $data);
+ if ($isFilterType) {
+ $data = $returnedValue;
+ }
+ }
+ }
+
+ return ($isFilterType ? $data : null);
+ }
+}
diff --git a/src/core/Directus/Hook/HookInterface.php b/src/core/Directus/Hook/HookInterface.php
new file mode 100644
index 0000000000..214e34544d
--- /dev/null
+++ b/src/core/Directus/Hook/HookInterface.php
@@ -0,0 +1,13 @@
+attributes = new Collection($attributes);
+ }
+
+ /**
+ * Gets an attribute
+ *
+ * @param $key
+ *
+ * @return mixed
+ */
+ public function attribute($key)
+ {
+ return $this->attributes[$key];
+ }
+
+ /**
+ * @return Collection
+ */
+ public function attributes()
+ {
+ return $this->attributes;
+ }
+
+ /**
+ * Gets all the data
+ *
+ * @return array
+ */
+ public function getData()
+ {
+ return $this->items;
+ }
+}
diff --git a/src/core/Directus/Hook/README.md b/src/core/Directus/Hook/README.md
new file mode 100644
index 0000000000..c86359af5d
--- /dev/null
+++ b/src/core/Directus/Hook/README.md
@@ -0,0 +1 @@
+# Directus Hook
diff --git a/src/core/Directus/Hook/composer.json b/src/core/Directus/Hook/composer.json
new file mode 100644
index 0000000000..fadf8070bf
--- /dev/null
+++ b/src/core/Directus/Hook/composer.json
@@ -0,0 +1,23 @@
+{
+ "name": "directus/hook",
+ "description": "Directus Hook Library",
+ "keywords": [
+ "directus",
+ "hook",
+ "event"
+ ],
+ "license": "MIT",
+ "require": {
+ "php": ">=5.4.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "Directus\\Hook\\": ""
+ }
+ },
+ "extra": {
+ "branch-alias": {
+ "dev-d64": "0.9.x-dev"
+ }
+ }
+}
diff --git a/src/core/Directus/Mail/Exception/InvalidTransportException.php b/src/core/Directus/Mail/Exception/InvalidTransportException.php
new file mode 100644
index 0000000000..7b0128fd60
--- /dev/null
+++ b/src/core/Directus/Mail/Exception/InvalidTransportException.php
@@ -0,0 +1,24 @@
+transports = $transportManager;
+ }
+
+ /**
+ * Creates a new message instance
+ *
+ * @return Message
+ */
+ public function createMessage()
+ {
+ return new Message();
+ }
+
+ public function send($view, array $data, \Closure $callback = null)
+ {
+ $transport = $this->transports->getDefault();
+ $message = $this->createMessage();
+
+ // Get global information
+ $config = $transport->getConfig();
+ if ($config->has('from')) {
+ $message->setFrom($config->get('from'));
+ }
+
+ if ($config->has('bcc')) {
+ $message->setBcc($config->get('bcc'));
+ }
+
+ if ($config->has('cc')) {
+ $message->setCc($config->get('cc'));
+ }
+
+ $content = parse_twig($view, array_merge(
+ $data,
+ ['api' => ['env' => get_api_env()]]
+ ));
+
+ $message->setBody($content, 'text/html');
+
+ if ($callback) {
+ call_user_func($callback, $message);
+ }
+
+ if (!array_key_exists($transport->getName(), $this->mailers)) {
+ $this->mailers[$transport->getName()] = new \Swift_Mailer($transport);
+ }
+
+ $swiftMailer = $this->mailers[$transport->getName()];
+ $swiftMailer->send($message);
+ }
+}
diff --git a/src/core/Directus/Mail/Message.php b/src/core/Directus/Mail/Message.php
new file mode 100644
index 0000000000..9a8c4b0f45
--- /dev/null
+++ b/src/core/Directus/Mail/Message.php
@@ -0,0 +1,8 @@
+transports[$name] = $transport;
+ $this->config[$name] = $config;
+ }
+
+ /**
+ * @param $name
+ *
+ * @return AbstractTransport
+ */
+ public function get($name)
+ {
+ if (!isset($this->instances[$name])) {
+ $this->instances[$name] = $this->build($name, ArrayUtils::get($this->config, $name, []));
+ }
+
+ return $this->instances[$name];
+ }
+
+ /**
+ * Gets the first or "default" adapter
+ *
+ * @return AbstractTransport|null
+ */
+ public function getDefault()
+ {
+ $instance = null;
+ if (array_key_exists('default', $this->transports)) {
+ $instance = $this->get('default');
+ } else if (count($this->transports) > 0) {
+ $instance = $this->get($this->transports[0]);
+ }
+
+ return $instance;
+ }
+
+ /**
+ * Creates a instance of a transport registered with the given name
+ *
+ * @param string $name
+ * @param array $config
+ *
+ * @return AbstractTransport
+ */
+ protected function build($name, array $config = [])
+ {
+ if (!array_key_exists($name, $this->transports)) {
+ throw new TransportNotFoundException($name);
+ }
+
+ $transport = $this->transports[$name];
+ if (!is_string($transport) && !is_object($transport) && !is_callable($transport)) {
+ throw new InvalidTransportException($this->transports[$name]);
+ }
+ if (is_string($transport) && !class_exists($transport)) {
+ throw new InvalidTransportException($this->transports[$name]);
+ }
+
+ if (is_callable($transport)) {
+ $instance = call_user_func($transport);
+ } else if (is_string($transport)) {
+ $instance = new $transport($config);
+ } else {
+ $instance = $transport;
+ }
+
+ if (!($instance instanceof AbstractTransport)) {
+ throw new RuntimeException(
+ sprintf('%s is not an instance of %s', $instance, AbstractTransport::class)
+ );
+ }
+
+ return $instance;
+ }
+}
diff --git a/src/core/Directus/Mail/Transports/AbstractTransport.php b/src/core/Directus/Mail/Transports/AbstractTransport.php
new file mode 100644
index 0000000000..ae119f335a
--- /dev/null
+++ b/src/core/Directus/Mail/Transports/AbstractTransport.php
@@ -0,0 +1,80 @@
+name = $name;
+ }
+
+ /**
+ * @return string
+ */
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * @return Collection
+ */
+ public function getConfig()
+ {
+ return $this->config;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function isStarted()
+ {
+ return true;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function start()
+ {
+ return true;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function stop()
+ {
+ return true;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function registerPlugin(Swift_Events_EventListener $plugin)
+ {
+ foreach ($this->listeners as $listener) {
+ if ($listener === $plugin) {
+ return;
+ }
+ }
+
+ $this->listeners[] = $plugin;
+ }
+}
diff --git a/src/core/Directus/Mail/Transports/SendMailTransport.php b/src/core/Directus/Mail/Transports/SendMailTransport.php
new file mode 100644
index 0000000000..ef363e8256
--- /dev/null
+++ b/src/core/Directus/Mail/Transports/SendMailTransport.php
@@ -0,0 +1,28 @@
+config = new Collection($config);
+
+ $this->sendmail = \Swift_SendmailTransport::newInstance($this->config->get('sendmail'));
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function send(\Swift_Mime_Message $message, &$failedRecipients = null)
+ {
+ return $this->sendmail->send($message, &$failedRecipients);
+ }
+}
diff --git a/src/core/Directus/Mail/Transports/SimpleFileTransport.php b/src/core/Directus/Mail/Transports/SimpleFileTransport.php
new file mode 100644
index 0000000000..58269d5e1a
--- /dev/null
+++ b/src/core/Directus/Mail/Transports/SimpleFileTransport.php
@@ -0,0 +1,34 @@
+config = new Collection($config);
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function send(Swift_Mime_Message $message, &$failedRecipients = null)
+ {
+ $path = rtrim($this->config->get('path', ''), '/') . '/' . time() . '.txt';
+
+ $message = [
+ $message->getSubject(),
+ $message->getBody()
+ ];
+
+ file_put_contents($path, implode("\n", $message));
+ }
+}
diff --git a/src/core/Directus/Mail/Transports/SmtpTransport.php b/src/core/Directus/Mail/Transports/SmtpTransport.php
new file mode 100644
index 0000000000..e9efe0f043
--- /dev/null
+++ b/src/core/Directus/Mail/Transports/SmtpTransport.php
@@ -0,0 +1,44 @@
+config = new Collection($config);
+ $transport = \Swift_SmtpTransport::newInstance(
+ $this->config->get('host'),
+ $this->config->get('port')
+ );
+
+ if ($this->config->has('username')) {
+ $transport->setUsername($this->config->get('username'));
+ }
+
+ if ($this->config->has('password')) {
+ $transport->setPassword($this->config->get('password'));
+ }
+
+ if ($this->config->has('encryption')) {
+ $transport->setEncryption($this->config->get('encryption'));
+ }
+
+ $this->smtp = $transport;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function send(\Swift_Mime_Message $message, &$failedRecipients = null)
+ {
+ return $this->smtp->send($message, &$failedRecipients);
+ }
+}
diff --git a/src/core/Directus/Permissions/Acl.php b/src/core/Directus/Permissions/Acl.php
new file mode 100644
index 0000000000..872e3b83a5
--- /dev/null
+++ b/src/core/Directus/Permissions/Acl.php
@@ -0,0 +1,1084 @@
+ self::LEVEL_FULL,
+ self::ACTION_READ => self::LEVEL_FULL,
+ self::ACTION_UPDATE => self::LEVEL_FULL,
+ self::ACTION_DELETE => self::LEVEL_FULL
+ ];
+
+ const PERMISSION_NONE = [
+ self::ACTION_CREATE => self::LEVEL_NONE,
+ self::ACTION_READ => self::LEVEL_NONE,
+ self::ACTION_UPDATE => self::LEVEL_NONE,
+ self::ACTION_DELETE => self::LEVEL_NONE
+ ];
+
+ const PERMISSION_READ = [
+ self::ACTION_CREATE => self::LEVEL_NONE,
+ self::ACTION_READ => self::LEVEL_FULL,
+ self::ACTION_UPDATE => self::LEVEL_NONE,
+ self::ACTION_DELETE => self::LEVEL_NONE
+ ];
+
+ const PERMISSION_WRITE = [
+ self::ACTION_CREATE => self::LEVEL_FULL,
+ self::ACTION_READ => self::LEVEL_NONE,
+ self::ACTION_UPDATE => self::LEVEL_FULL,
+ self::ACTION_DELETE => self::LEVEL_NONE
+ ];
+
+ const PERMISSION_READ_WRITE = [
+ self::ACTION_CREATE => self::LEVEL_FULL,
+ self::ACTION_READ => self::LEVEL_FULL,
+ self::ACTION_UPDATE => self::LEVEL_FULL,
+ self::ACTION_DELETE => 0
+ ];
+
+ protected $permissionLevelsMapping = [
+ 'none' => 0,
+ 'user' => 1,
+ 'group' => 2,
+ 'full' => 3
+ ];
+
+ /**
+ * Permissions by status grouped by collection
+ *
+ * @var array
+ */
+ protected $statusPermissions = [];
+
+ /**
+ * Permissions grouped by collection
+ *
+ * @var array
+ */
+ protected $globalPermissions = [];
+
+ /**
+ * Authenticated user id
+ *
+ * @var int|null
+ */
+ protected $userId = null;
+
+ /**
+ * List of roles id the user beings to
+ *
+ * @var array
+ */
+ protected $roleIds = [];
+
+ /**
+ * List of allowed IPs by role
+ *
+ * @var array
+ */
+ protected $rolesIpWhitelist = [];
+
+ /**
+ * Flag to determine whether the user is public or not
+ *
+ * @var bool
+ */
+ protected $isPublic = null;
+
+ public function __construct(array $permissions = [])
+ {
+ $this->setPermissions($permissions);
+ }
+
+ /**
+ * Sets the authenticated user id
+ *
+ * @param $userId
+ */
+ public function setUserId($userId)
+ {
+ $this->userId = (int)$userId;
+ }
+
+ /**
+ * Sets whether the authenticated user is public
+ *
+ * @param $public
+ */
+ public function setPublic($public)
+ {
+ $this->isPublic = (bool)$public;
+ }
+
+ /**
+ * Gets the authenticated user id
+ *
+ * @return int|null
+ */
+ public function getUserId()
+ {
+ return $this->userId;
+ }
+
+ /**
+ * Gets whether the authenticated user is public
+ *
+ * @return bool
+ */
+ public function isPublic()
+ {
+ return $this->isPublic === true;
+ }
+
+ /**
+ * Gets whether the authenticated user is admin
+ *
+ * @return bool
+ */
+ public function isAdmin()
+ {
+ return $this->hasAdminRole();
+ }
+
+ /**
+ * Checks whether or not the user has admin role
+ *
+ * @return bool
+ */
+ public function hasAdminRole()
+ {
+ return $this->hasRole(1);
+ }
+
+ /**
+ * Checks whether or not the user has the given role id
+ *
+ * @param int $roleId
+ *
+ * @return bool
+ */
+ public function hasRole($roleId)
+ {
+ return in_array($roleId, $this->getRolesId());
+ }
+
+ /**
+ * Get all role IDs
+ *
+ * @return array
+ */
+ public function getRolesId()
+ {
+ return $this->roleIds;
+ }
+
+ /**
+ * Sets the user roles ip whitelist
+ *
+ * @param array $rolesIpWhitelist
+ */
+ public function setRolesIpWhitelist(array $rolesIpWhitelist)
+ {
+ foreach ($rolesIpWhitelist as $role => $ipList) {
+ if (!is_array($ipList)) {
+ $ipList = explode(',', $ipList);
+ }
+
+ $this->rolesIpWhitelist[$role] = $ipList;
+ }
+ }
+
+ /**
+ * Checks whether or not the given ip is allowed in one of the roles
+ *
+ * @param $ip
+ *
+ * @return bool
+ */
+ public function isIpAllowed($ip)
+ {
+ $allowed = true;
+ foreach ($this->rolesIpWhitelist as $list) {
+ if (!empty($list) && !in_array($ip, $list)) {
+ $allowed = false;
+ break;
+ }
+ }
+
+ return $allowed;
+ }
+
+ /**
+ * Sets the group permissions
+ *
+ * @param array $permissions
+ *
+ * @return $this
+ */
+ public function setPermissions(array $permissions)
+ {
+ foreach ($permissions as $collection => $collectionPermissions) {
+ foreach ($collectionPermissions as $permission) {
+ $roleId = ArrayUtils::get($permission, 'role');
+
+ if (!in_array($roleId, $this->roleIds)) {
+ $this->roleIds[] = $roleId;
+ }
+ }
+
+ $this->setCollectionPermissions($collection, $collectionPermissions);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Sets permissions to the given collection
+ *
+ * @param string $collection
+ * @param array $permissions
+ */
+ public function setCollectionPermissions($collection, array $permissions)
+ {
+ foreach ($permissions as $permission) {
+ $this->setCollectionPermission($collection, $permission);
+ }
+ }
+
+ /**
+ * Sets a collection permission
+ *
+ * @param $collection
+ * @param array $permission
+ *
+ * @return $this
+ */
+ public function setCollectionPermission($collection, array $permission)
+ {
+ $status = ArrayUtils::get($permission, 'status');
+
+ if (is_null($status) && !isset($this->globalPermissions[$collection])) {
+ $this->globalPermissions[$collection] = $permission;
+ } else if (!is_null($status) && !isset($this->statusPermissions[$collection][$status])) {
+ $this->statusPermissions[$collection][$status] = $permission;
+ unset($this->globalPermissions[$collection]);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Gets the group permissions
+ *
+ * @return array
+ */
+ public function getPermissions()
+ {
+ return array_merge($this->globalPermissions, $this->statusPermissions);
+ }
+
+ public function getCollectionStatuses($collection)
+ {
+ $statuses = null;
+ $permissions = ArrayUtils::get($this->statusPermissions, $collection);
+ if (!empty($permissions)) {
+ $statuses = array_keys($permissions);
+ }
+
+ return $statuses;
+ }
+
+ /**
+ * Gets a collection permissions
+ *
+ * @param string $collection
+ *
+ * @return array
+ */
+ public function getCollectionPermissions($collection)
+ {
+ if (array_key_exists($collection, $this->statusPermissions)) {
+ return $this->statusPermissions[$collection];
+ } else if (array_key_exists($collection, $this->globalPermissions)) {
+ return $this->globalPermissions[$collection];
+ }
+
+ return [];
+ }
+
+ /**
+ * Gets a collection permission
+ *
+ * @param string $collection
+ * @param null|int|string $status
+ *
+ * @return array
+ */
+ public function getPermission($collection, $status = null)
+ {
+ $permissions = $this->getCollectionPermissions($collection);
+ $hasStatusPermissions = array_key_exists($collection, $this->statusPermissions);
+
+ if (is_null($status) && $hasStatusPermissions) {
+ $permissions = [];
+ } else if ($hasStatusPermissions) {
+ $permissions = ArrayUtils::get($permissions, $status, []);
+ }
+
+ return $permissions;
+ }
+
+ /**
+ * Gets the given type (read/write) field blacklist
+ *
+ * @param string $type
+ * @param string $collection
+ * @param mixed $status
+ *
+ * @return array
+ */
+ public function getFieldBlacklist($type, $collection, $status = null)
+ {
+ $permission = $this->getPermission($collection, $status);
+
+ switch ($type) {
+ case static::FIELD_READ_BLACKLIST:
+ $fields = ArrayUtils::get($permission, static::FIELD_READ_BLACKLIST);
+ break;
+ case static::FIELD_WRITE_BLACKLIST:
+ $fields = ArrayUtils::get($permission, static::FIELD_WRITE_BLACKLIST);
+ break;
+ default:
+ $fields = [];
+ }
+
+ return $fields ?: [];
+ }
+
+ /**
+ * Gets the read field blacklist
+ *
+ * @param string $collection
+ * @param mixed $status
+ *
+ * @return array
+ */
+ public function getReadFieldBlacklist($collection, $status = null)
+ {
+ return $this->getFieldBlacklist(static::FIELD_READ_BLACKLIST, $collection, $status);
+ }
+
+ /**
+ * Gets the write field blacklist
+ *
+ * @param string $collection
+ * @param mixed $status
+ *
+ * @return array|mixed
+ */
+ public function getWriteFieldBlacklist($collection, $status = null)
+ {
+ return $this->getFieldBlacklist(static::FIELD_WRITE_BLACKLIST, $collection, $status);
+ }
+
+ /**
+ * Checks whether the user can add an item in the given collection
+ *
+ * @param string $collection
+ * @param string|int|null $status
+ *
+ * @return bool
+ */
+ public function canCreate($collection, $status = null)
+ {
+ return $this->allowTo(static::ACTION_CREATE, static::LEVEL_USER, $collection, $status);
+ }
+
+ /**
+ * Checks whether the user can view an item in the given collection
+ *
+ * @param int $level
+ * @param $collection
+ * @param string|int|null $status
+ *
+ * @return bool
+ */
+ public function canReadAt($level, $collection, $status = null)
+ {
+ return $this->allowTo(static::ACTION_READ, $level, $collection, $status);
+ }
+
+ /**
+ * Checks whether the user can read at least their own items in the given collection
+ *
+ * @param $collection
+ * @param string|int|null $status
+ *
+ * @return bool
+ */
+ public function canRead($collection, $status = null)
+ {
+ return $this->canReadMine($collection, $status);
+ }
+
+ /**
+ * Checks whether the user can read at least in one permission level no matter the status
+ *
+ * @param string $collection
+ *
+ * @return bool
+ */
+ public function canReadOnce($collection)
+ {
+ return $this->allowToOnce(static::ACTION_READ, $collection);
+ }
+
+ /**
+ * Checks whether the user can read their own items in the given collection
+ *
+ * @param $collection
+ * @param string|int|null $status
+ *
+ * @return bool
+ */
+ public function canReadMine($collection, $status = null)
+ {
+ return $this->canReadAt(static::LEVEL_USER, $collection, $status);
+ }
+
+ /**
+ * Checks whether the user can read same group users items in the given collection
+ *
+ * @param $collection
+ * @param string|int|null $status
+ *
+ * @return bool
+ */
+ public function canReadFromGroup($collection, $status = null)
+ {
+ return $this->canReadAt(static::LEVEL_GROUP, $collection, $status);
+ }
+
+ /**
+ * Checks whether the user can read same group users items in the given collection
+ *
+ * @param $collection
+ * @param string|int|null $status
+ *
+ * @return bool
+ */
+ public function canReadAll($collection, $status = null)
+ {
+ return $this->canReadAt(static::LEVEL_FULL, $collection, $status);
+ }
+
+ /**
+ * Checks whether the user can update an item in the given collection
+ *
+ * @param int $level
+ * @param string $collection
+ * @param mixed $status
+ *
+ * @return bool
+ */
+ public function canUpdateAt($level, $collection, $status = null)
+ {
+ return $this->allowTo(static::ACTION_UPDATE, $level, $collection, $status);
+ }
+
+ /**
+ * Checks whether the user can update at least their own items in the given collection
+ *
+ * @param string $collection
+ * @param mixed $status
+ *
+ * @return bool
+ */
+ public function canUpdate($collection, $status = null)
+ {
+ return $this->canUpdateMine($collection, $status);
+ }
+
+ /**
+ * Checks whether the user can update their own items in the given collection
+ *
+ * @param string $collection
+ * @param mixed $status
+ *
+ * @return bool
+ */
+ public function canUpdateMine($collection, $status = null)
+ {
+ return $this->canUpdateAt(static::LEVEL_USER, $collection, $status);
+ }
+
+ /**
+ * Checks whether the user can update items from the same user groups in the given collection
+ *
+ * @param string $collection
+ * @param mixed $status
+ *
+ * @return bool
+ */
+ public function canUpdateFromGroup($collection, $status = null)
+ {
+ return $this->canUpdateAt(static::LEVEL_GROUP, $collection, $status);
+ }
+
+ /**
+ * Checks whether the user can update all items in the given collection
+ *
+ * @param string $collection
+ * @param mixed $status
+ *
+ * @return bool
+ */
+ public function canUpdateAll($collection, $status = null)
+ {
+ return $this->canUpdateAt(static::LEVEL_FULL, $collection, $status);
+ }
+
+ /**
+ * Checks whether the user can delete an item in the given collection
+ *
+ * @param int $level
+ * @param string $collection
+ * @param string|int|null $status
+ *
+ * @return bool
+ */
+ public function canDeleteAt($level, $collection, $status = null)
+ {
+ return $this->allowTo(static::ACTION_DELETE, $level, $collection, $status);
+ }
+
+ /**
+ * Checks whether the user can delete at least their own items in the given collection
+ *
+ * @param string $collection
+ * @param string|int|null $status
+ *
+ * @return bool
+ */
+ public function canDelete($collection, $status = null)
+ {
+ return $this->canDeleteMine($collection, $status);
+ }
+
+ /**
+ * Checks whether the user can delete its own items in the given collection
+ *
+ * @param string $collection
+ * @param string|int|null $status
+ *
+ * @return bool
+ */
+ public function canDeleteMine($collection, $status = null)
+ {
+ return $this->canDeleteAt(static::LEVEL_USER, $collection, $status);
+ }
+
+ /**
+ * Checks whether the user can delete items that belongs to a user in the same group in the given collection
+ *
+ * @param string $collection
+ * @param string|int|null $status
+ *
+ * @return bool
+ */
+ public function canDeleteFromGroup($collection, $status = null)
+ {
+ return $this->canDeleteAt(static::LEVEL_GROUP, $collection, $status);
+ }
+
+ /**
+ * Checks whether the user can delete any items in the given collection
+ *
+ * @param string $collection
+ * @param string|int|null $status
+ *
+ * @return bool
+ */
+ public function canDeleteAll($collection, $status = null)
+ {
+ return $this->canDeleteAt(static::LEVEL_FULL, $collection, $status);
+ }
+
+ /**
+ * Checks whether the user can alter the given table
+ *
+ * @param $collection
+ *
+ * @return bool
+ */
+ public function canAlter($collection)
+ {
+ return $this->isAdmin();
+ }
+
+ /**
+ * Checks whether a given collection requires explanation message
+ *
+ * @param string $collection
+ * @param string|int|null $status
+ *
+ * @return bool
+ */
+ public function requireExplain($collection, $status = null)
+ {
+ $permission = $this->getPermission($collection, $status);
+ if (!array_key_exists('explain', $permission)) {
+ return false;
+ }
+
+ return $permission['explain'] === 1;
+ }
+
+ /**
+ * Throws an exception if the user cannot read their own items in the given collection
+ *
+ * @param string $collection
+ * @param mixed $status
+ *
+ * @throws Exception\ForbiddenCollectionReadException
+ */
+ public function enforceReadMine($collection, $status = null)
+ {
+ if (!$this->canReadMine($collection, $status)) {
+ throw new Exception\ForbiddenCollectionReadException(
+ $collection
+ );
+ }
+ }
+
+ /**
+ * Throws an exception if the user cannot read the same group items in the given collection
+ *
+ * @param string $collection
+ * @param mixed $status
+ *
+ * @throws Exception\ForbiddenCollectionReadException
+ */
+ public function enforceReadFromGroup($collection, $status = null)
+ {
+ if (!$this->canReadFromGroup($collection, $status)) {
+ throw new Exception\ForbiddenCollectionReadException(
+ $collection
+ );
+ }
+ }
+
+ /**
+ * Throws an exception if the user cannot read all items in the given collection
+ *
+ * @param string $collection
+ * @param mixed $status
+ *
+ * @throws Exception\ForbiddenCollectionReadException
+ */
+ public function enforceReadAll($collection, $status = null)
+ {
+ if (!$this->canReadAll($collection, $status)) {
+ throw new Exception\ForbiddenCollectionReadException(
+ $collection
+ );
+ }
+ }
+
+ /**
+ * Throws an exception if the user cannot create a item in the given collection
+ *
+ * @param string $collection
+ * @param mixed $status
+ *
+ * @throws Exception\ForbiddenCollectionReadException
+ */
+ public function enforceRead($collection, $status = null)
+ {
+ $this->enforceReadMine($collection, $status);
+ }
+
+ /**
+ * Throws an exception if the user cannot read a item in any level or status
+ *
+ * @param string $collection
+ *
+ * @throws Exception\ForbiddenCollectionReadException
+ */
+ public function enforceReadOnce($collection)
+ {
+ if (!$this->canReadOnce($collection)) {
+ throw new Exception\ForbiddenCollectionReadException(
+ $collection
+ );
+ }
+ }
+
+ /**
+ * Throws an exception if the user cannot create a item in the given collection
+ *
+ * @param string $collection
+ * @param mixed $status
+ *
+ * @throws Exception\ForbiddenCollectionCreateException
+ */
+ public function enforceCreate($collection, $status = null)
+ {
+ if (!$this->canCreate($collection, $status)) {
+ throw new Exception\ForbiddenCollectionCreateException(
+ $collection
+ );
+ }
+ }
+
+ /**
+ * Throws an exception if the user cannot alter the given collection
+ *
+ * @param $collection
+ *
+ * @throws Exception\ForbiddenCollectionAlterException
+ */
+ public function enforceAlter($collection)
+ {
+ if (!$this->canAlter($collection)) {
+ throw new Exception\ForbiddenCollectionAlterException(
+ $collection
+ );
+ }
+ }
+
+ /**
+ * Throws an exception if the user cannot update their own items in the given collection
+ *
+ * @param string $collection
+ * @param mixed $status
+ *
+ * @throws Exception\ForbiddenCollectionUpdateException
+ */
+ public function enforceUpdateMine($collection, $status = null)
+ {
+ if (!$this->canUpdateMine($collection, $status)) {
+ throw new Exception\ForbiddenCollectionUpdateException(
+ $collection
+ );
+ }
+ }
+
+ /**
+ * Throws an exception if the user cannot update items from the same group in the given collection
+ *
+ * @param string $collection
+ * @param mixed $status
+ *
+ * @throws Exception\ForbiddenCollectionUpdateException
+ */
+ public function enforceUpdateFromGroup($collection, $status = null)
+ {
+ if (!$this->canUpdateFromGroup($collection, $status)) {
+ throw new Exception\ForbiddenCollectionUpdateException(
+ $collection
+ );
+ }
+ }
+
+ /**
+ * Throws an exception if the user cannot update all items in the given collection
+ *
+ * @param string $collection
+ * @param mixed $status
+ *
+ * @throws Exception\ForbiddenCollectionUpdateException
+ */
+ public function enforceUpdateAll($collection, $status = null)
+ {
+ if (!$this->canUpdateAll($collection, $status)) {
+ throw new Exception\ForbiddenCollectionUpdateException(
+ $collection
+ );
+ }
+ }
+
+ /**
+ * Throws an exception if the user cannot update an item in the given collection
+ *
+ * @param string $collection
+ * @param mixed $status
+ *
+ * @throws Exception\ForbiddenCollectionUpdateException
+ */
+ public function enforceUpdate($collection, $status = null)
+ {
+ $this->enforceUpdateMine($collection, $status);
+ }
+
+ /**
+ * Throws an exception if the user cannot delete their own items in the given collection
+ *
+ * @param string $collection
+ * @param mixed $status
+ *
+ * @throws Exception\ForbiddenCollectionDeleteException
+ */
+ public function enforceDeleteMine($collection, $status = null)
+ {
+ if (!$this->canDeleteMine($collection, $status)) {
+ throw new Exception\ForbiddenCollectionDeleteException(
+ $collection
+ );
+ }
+ }
+
+ /**
+ * Throws an exception if the user cannot delete items from the same group in the given collection
+ *
+ * @param string $collection
+ * @param mixed $status
+ *
+ * @throws Exception\ForbiddenCollectionDeleteException
+ */
+ public function enforceDeleteFromGroup($collection, $status = null)
+ {
+ if (!$this->canDeleteFromGroup($collection, $status)) {
+ throw new Exception\ForbiddenCollectionDeleteException(
+ $collection
+ );
+ }
+ }
+
+ /**
+ * Throws an exception if the user cannot delete all items in the given collection
+ *
+ * @param string $collection
+ * @param mixed $status
+ *
+ * @throws Exception\ForbiddenCollectionDeleteException
+ */
+ public function enforceDeleteAll($collection, $status = null)
+ {
+ if (!$this->canDeleteAll($collection, $status)) {
+ throw new Exception\ForbiddenCollectionDeleteException(
+ $collection
+ );
+ }
+ }
+
+ /**
+ * Throws an exception if the user cannot delete an item in the given collection
+ *
+ * @param string $collection
+ * @param mixed $status
+ *
+ * @throws Exception\ForbiddenCollectionDeleteException
+ */
+ public function enforceDelete($collection, $status = null)
+ {
+ $this->enforceDeleteMine($collection, $status);
+ }
+
+ /**
+ * Checks whether the user can see the given column
+ *
+ * @param string $collection
+ * @param string $field
+ * @param null|string|int $status
+ *
+ * @return bool
+ */
+ public function canReadField($collection, $field, $status = null)
+ {
+ $fields = $this->getReadFieldBlacklist($collection, $status);
+
+ return !in_array($field, $fields);
+ }
+
+ /**
+ * Checks whether the user can see the given column
+ *
+ * @param string $collection
+ * @param string $field
+ * @param null|int|string $status
+ *
+ * @return bool
+ */
+ public function canWriteField($collection, $field, $status = null)
+ {
+ $fields = $this->getWriteFieldBlacklist($collection, $status);
+
+ return !in_array($field, $fields);
+ }
+
+ /**
+ * Throws an exception if the user has not permission to read from the given field
+ *
+ * @param string $collection
+ * @param string|array $fields
+ * @param null|int|string $status
+ *
+ * @throws ForbiddenFieldReadException
+ */
+ public function enforceReadField($collection, $fields, $status = null)
+ {
+ if (!is_array($fields)) {
+ $fields = [$fields];
+ }
+
+ foreach ($fields as $field) {
+ if (!$this->canReadField($collection, $field, $status)) {
+ throw new ForbiddenFieldReadException($collection, $field);
+ }
+ }
+ }
+
+ /**
+ * Throws an exception if the user has not permission to write to the given field
+ *
+ * @param string $collection
+ * @param string|array $fields
+ * @param null|int|string $status
+ *
+ * @throws ForbiddenFieldWriteException
+ */
+ public function enforceWriteField($collection, $fields, $status = null)
+ {
+ if (!is_array($fields)) {
+ $fields = [$fields];
+ }
+
+ foreach ($fields as $field) {
+ if (!$this->canWriteField($collection, $field, $status)) {
+ throw new ForbiddenFieldWriteException($collection, $field);
+ }
+ }
+ }
+
+ /**
+ * Given table name $table and privilege constant $privilege, return boolean
+ * value indicating whether the current user group has permission to perform
+ * the specified table-level action on the specified table.
+ *
+ * @param string $action
+ * @param string $collection
+ * @param int $level
+ * @param mixed $status
+ *
+ * @return boolean
+ */
+ public function allowTo($action, $level, $collection, $status = null)
+ {
+ if ($this->isAdmin()) {
+ return true;
+ }
+
+ $permission = $this->getPermission($collection, $status);
+ $permissionLevel = ArrayUtils::get($permission, $action);
+
+ return $this->can($permissionLevel, $level);
+ }
+
+ public function allowToOnce($action, $collection)
+ {
+ if ($this->isAdmin()) {
+ return true;
+ }
+
+ $permissions = [];
+ if (array_key_exists($collection, $this->statusPermissions)) {
+ $permissions = $this->statusPermissions[$collection];
+ } else if (array_key_exists($collection, $this->globalPermissions)) {
+ $permissions = [$this->globalPermissions[$collection]];
+ }
+
+ $allowed = false;
+ foreach ($permissions as $permission) {
+ $permissionLevel = ArrayUtils::get($permission, $action);
+
+ if ($this->can($permissionLevel, static::LEVEL_USER)) {
+ $allowed = true;
+ break;
+ }
+ }
+
+ return $allowed;
+ }
+
+ /**
+ * Returns a list of status the given collection has permission to read
+ *
+ * @param string $collection
+ *
+ * @return array|mixed
+ */
+ public function getCollectionStatusesReadPermission($collection)
+ {
+ if ($this->isAdmin()) {
+ return null;
+ }
+
+ $statuses = false;
+
+ if (array_key_exists($collection, $this->statusPermissions)) {
+ $statuses = [];
+
+ foreach ($this->statusPermissions[$collection] as $status => $permission) {
+ $permissionLevel = ArrayUtils::get($permission, static::ACTION_READ);
+
+ if ($this->can($permissionLevel, static::LEVEL_USER)) {
+ $statuses[] = $status;
+ }
+ }
+ } else if (array_key_exists($collection, $this->globalPermissions)) {
+ $permission = $this->globalPermissions[$collection];
+ $permissionLevel = ArrayUtils::get($permission, static::ACTION_READ);
+
+ if ($this->can($permissionLevel, static::LEVEL_USER)) {
+ $statuses = null;
+ }
+ }
+
+ return $statuses;
+ }
+
+ /**
+ * Checks whether or not a permission level has equal or higher level
+ *
+ * @param string $permissionLevel
+ * @param string $level
+ *
+ * @return bool
+ */
+ protected function can($permissionLevel, $level)
+ {
+ if (!$permissionLevel) {
+ return false;
+ }
+
+ $levelValue = ArrayUtils::get($this->permissionLevelsMapping, $level);
+ $permissionLevelValue = ArrayUtils::get($this->permissionLevelsMapping, $permissionLevel);
+
+ if ($levelValue && $permissionLevelValue) {
+ return $levelValue <= $permissionLevelValue;
+ }
+
+ return false;
+ }
+}
diff --git a/src/core/Directus/Permissions/Exception/ForbiddenCollectionAlterException.php b/src/core/Directus/Permissions/Exception/ForbiddenCollectionAlterException.php
new file mode 100644
index 0000000000..4f79e9b54c
--- /dev/null
+++ b/src/core/Directus/Permissions/Exception/ForbiddenCollectionAlterException.php
@@ -0,0 +1,18 @@
+=5.4.0",
+ "directus/exception": "~2.0.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "Directus\\Permissions\\": ""
+ }
+ }
+}
diff --git a/src/core/Directus/Services/AbstractExtensionsController.php b/src/core/Directus/Services/AbstractExtensionsController.php
new file mode 100644
index 0000000000..d05b721a61
--- /dev/null
+++ b/src/core/Directus/Services/AbstractExtensionsController.php
@@ -0,0 +1,46 @@
+ $addOns];
+ }
+
+ $directusBasePath = $this->container->get('path_base');
+
+ $filePaths = find_directories($basePath);
+ foreach ($filePaths as $path) {
+ $path .= '/meta.json';
+
+ if (!file_exists($path)) {
+ continue;
+ }
+
+ $addOnsPath = trim(substr($path, strlen($basePath)), '/');
+ $data = [
+ 'id' => basename(dirname($addOnsPath)),
+ // NOTE: This is a temporary solution until we implement core config
+ // In this case /public is the public root path
+ 'path' => trim(substr($path, strlen($directusBasePath) + strlen('/public')), '/')
+ ];
+
+ $meta = @json_decode(file_get_contents($path), true);
+ if ($meta) {
+ unset($meta['id']);
+ $data = array_merge($data, $meta);
+ }
+
+ $addOns[] = $data;
+ }
+
+ return ['data' => $addOns];
+ }
+}
diff --git a/src/core/Directus/Services/AbstractService.php b/src/core/Directus/Services/AbstractService.php
new file mode 100644
index 0000000000..82d71ca8e5
--- /dev/null
+++ b/src/core/Directus/Services/AbstractService.php
@@ -0,0 +1,369 @@
+container = $container;
+ $this->validator = new Validator();
+ }
+
+ /**
+ * Gets application container
+ *
+ * @return Container
+ */
+ protected function getContainer()
+ {
+ return $this->container;
+ }
+
+ /**
+ * Gets application db connection instance
+ *
+ * @return \Zend\Db\Adapter\Adapter
+ */
+ protected function getConnection()
+ {
+ return $this->getContainer()->get('database');
+ }
+
+ /**
+ * Gets schema manager instance
+ *
+ * @return SchemaManager
+ */
+ public function getSchemaManager()
+ {
+ return $this->getContainer()->get('schema_manager');
+ }
+
+ /**
+ * @param $name
+ * @param $acl
+ *
+ * @return RelationalTableGateway
+ */
+ public function createTableGateway($name, $acl = true)
+ {
+ return TableGatewayFactory::create($name, [
+ 'acl' => $acl !== false ? $this->getAcl() : false,
+ 'connection' => $this->getConnection()
+ ]);
+ }
+
+ /**
+ * Gets Acl instance
+ *
+ * @return Acl
+ */
+ protected function getAcl()
+ {
+ return $this->getContainer()->get('acl');
+ }
+
+ /**
+ * Validates a given data against a constraint
+ *
+ * @param array $data
+ * @param array $constraints
+ *
+ * @throws BadRequestException
+ */
+ public function validate(array $data, array $constraints)
+ {
+ $constraintViolations = $this->getViolations($data, $constraints);
+
+ $this->throwErrorIfAny($constraintViolations);
+ }
+
+ /**
+ * @param array $data
+ * @param array $constraints
+ *
+ * @return array
+ */
+ protected function getViolations(array $data, array $constraints)
+ {
+ $violations = [];
+
+ foreach ($constraints as $field => $constraint) {
+ if (is_string($constraint)) {
+ $constraint = explode('|', $constraint);
+ }
+
+ $violations[$field] = $this->validator->validate(ArrayUtils::get($data, $field), $constraint);
+ }
+
+ return $violations;
+ }
+
+ /**
+ * Throws an exception if any violations was made
+ *
+ * @param ConstraintViolationList[] $violations
+ *
+ * @throws BadRequestException
+ */
+ protected function throwErrorIfAny(array $violations)
+ {
+ $results = [];
+
+ /** @var ConstraintViolationList $violation */
+ foreach ($violations as $field => $violation) {
+ $iterator = $violation->getIterator();
+
+ $errors = [];
+ while ($iterator->valid()) {
+ $constraintViolation = $iterator->current();
+ $errors[] = $constraintViolation->getMessage();
+ $iterator->next();
+ }
+
+ if ($errors) {
+ $results[] = sprintf('%s: %s', $field, implode(', ', $errors));
+ }
+ }
+
+ if (count($results) > 0) {
+ throw new InvalidRequestException(implode(' ', $results));
+ }
+ }
+
+ /**
+ * Creates the constraint for a an specific table columns
+ *
+ * @param string $collectionName
+ * @param array $fields List of columns name
+ *
+ * @return array
+ */
+ protected function createConstraintFor($collectionName, array $fields = [])
+ {
+ /** @var SchemaManager $schemaManager */
+ $schemaManager = $this->container->get('schema_manager');
+ $collectionObject = $schemaManager->getCollection($collectionName);
+
+ $constraints = [];
+
+ if ($fields === null) {
+ return $constraints;
+ }
+
+ foreach ($collectionObject->getFields($fields) as $field) {
+ $columnConstraints = [];
+
+ if ($field->hasAutoIncrement()) {
+ continue;
+ }
+
+ $isRequired = $field->isRequired();
+ $isStatusInterface = $field->getInterface() === SystemInterface::INTERFACE_STATUS;
+ if (!$isRequired && $isStatusInterface && $field->getDefaultValue() === null) {
+ $isRequired = true;
+ }
+
+ if ($isRequired || (!$field->isNullable() && $field->getDefaultValue() == null)) {
+ $columnConstraints[] = 'required';
+ }
+
+ if ($field->isArray()) {
+ $columnConstraints[] = 'array';
+ } else if ($field->isJson()) {
+ $columnConstraints[] = 'json';
+ }
+ // TODO: Relational accept its type, null (if allowed) and a object
+ // else if ($schemaManager->isNumericType($field->getType())) {
+ // $columnConstraints[] = 'numeric';
+ // } else if ($schemaManager->isStringType($field->getType())) {
+ // $columnConstraints[] = 'string';
+ // }
+
+ if (!empty($columnConstraints)) {
+ $constraints[$field->getName()] = $columnConstraints;
+ }
+ }
+
+ return $constraints;
+ }
+
+ protected function tagResponseCache($tags)
+ {
+ $this->container->get('response_cache')->tag($tags);
+ }
+
+ protected function invalidateCacheTags($tags)
+ {
+ $this->container->get('cache')->getPool()->invalidateTags($tags);
+ }
+
+ /**
+ * @param RelationalTableGateway $gateway
+ * @param array $params
+ * @param \Closure|null $queryCallback
+ *
+ * @return array|mixed
+ */
+ protected function getItemsAndSetResponseCacheTags(RelationalTableGateway $gateway, array $params, \Closure $queryCallback = null)
+ {
+ return $this->getDataAndSetResponseCacheTags([$gateway, 'getItems'], [$params, $queryCallback]);
+ }
+
+ /**
+ * @param callable $callable
+ * @param array $callableParams
+ * @param null $pkName
+ * @return array|mixed
+ */
+ protected function getDataAndSetResponseCacheTags(Callable $callable, array $callableParams = [], $pkName = null)
+ {
+ $container = $this->container;
+
+ if (is_array($callable) && $callable[0] instanceof RelationalTableGateway) {
+ /** @var $callable[0] RelationalTableGateway */
+ $pkName = $callable[0]->primaryKeyFieldName;
+ }
+
+ $setIdTags = function(Payload $payload) use($pkName, $container) {
+ $collectionName = $payload->attribute('collection_name');
+
+ $this->tagResponseCache('table_'.$collectionName);
+ // Note: See other reference to permissions_collection_<>
+ // to proper set a new tag now that group doesn't exists anymore
+ $this->tagResponseCache('permissions_collection_'.$collectionName);
+
+ foreach ($payload->getData() as $item) {
+ $this->tagResponseCache('entity_'.$collectionName.'_'.$item[$pkName]);
+ }
+
+ return $payload;
+ };
+
+ /** @var Emitter $hookEmitter */
+ $hookEmitter = $container->get('hook_emitter');
+
+ $listenerId = $hookEmitter->addFilter('collection.select', $setIdTags, Emitter::P_LOW);
+ $result = call_user_func_array($callable, $callableParams);
+ $hookEmitter->removeListenerWithIndex($listenerId);
+
+ return $result;
+ }
+
+ protected function getCRUDParams(array $params)
+ {
+ $activityLoggingDisabled = ArrayUtils::get($params, 'activity_skip', 0) == 1;
+ $activityMode = $activityLoggingDisabled
+ ? RelationalTableGateway::ACTIVITY_ENTRY_MODE_DISABLED
+ : RelationalTableGateway::ACTIVITY_ENTRY_MODE_PARENT;
+
+ return [
+ 'activity_mode' => $activityMode,
+ 'activity_message' => ArrayUtils::get($params, 'message')
+ ];
+ }
+
+ /**
+ * Validates the payload against a collection fields
+ *
+ * @param string $collection
+ * @param array|null $fields
+ * @param array $payload
+ * @param array $params
+ *
+ * @throws BadRequestException
+ */
+ protected function validatePayload($collection, $fields, array $payload, array $params)
+ {
+ $collectionObject = $this->getSchemaManager()->getCollection($collection);
+ $payloadCount = count($payload);
+ $hasPrimaryKeyData = ArrayUtils::has($payload, $collectionObject->getPrimaryKeyName());
+
+ if ($payloadCount === 0 || ($hasPrimaryKeyData && count($payload) === 1)) {
+ throw new BadRequestException('Payload cannot be empty');
+ }
+
+ $columnsToValidate = [];
+
+ // TODO: Validate empty request
+ // If the user PATCH, POST or PUT with empty body, must throw an exception to avoid continue the execution
+ // with the exception of POST, that can use the default value instead
+ // TODO: Crate a email interface for the sake of validation
+ if (is_array($fields)) {
+ $columnsToValidate = $fields;
+ }
+
+ $this->validate($payload, $this->createConstraintFor($collection, $columnsToValidate));
+ }
+
+ /**
+ * Verify that the payload has its primary key otherwise an exception will be thrown
+ *
+ * @param $collectionName
+ * @param array $payload
+ *
+ * @throws BadRequestException
+ */
+ protected function validatePayloadHasPrimaryKey($collectionName, array $payload)
+ {
+ $collection = $this->getSchemaManager()->getCollection($collectionName);
+ $primaryKey = $collection->getPrimaryKeyName();
+
+ if (!ArrayUtils::has($payload, $primaryKey) || !$payload[$primaryKey]) {
+ throw new BadRequestException('Payload must include the primary key');
+ }
+ }
+
+ /**
+ * @param string $collection
+ * @param array $payload
+ * @param array $params
+ *
+ * @throws ForbiddenException
+ */
+ protected function enforcePermissions($collection, array $payload, array $params)
+ {
+ $collectionObject = $this->getSchemaManager()->getCollection($collection);
+ $status = null;
+ $statusField = $collectionObject->getStatusField();
+ if ($statusField) {
+ $status = ArrayUtils::get($payload, $statusField->getName(), $statusField->getDefaultValue());
+ }
+
+ $acl = $this->getAcl();
+ $requiredExplain = $acl->requireExplain($collection, $status);
+ if ($requiredExplain && empty($params['message'])) {
+ throw new ForbiddenException('Activity message required for collection: ' . $collection);
+ }
+
+ // Enforce write field blacklist
+ $this->getAcl()->enforceWriteField($collection, array_keys($payload), $status);
+ }
+}
diff --git a/src/core/Directus/Services/AuthService.php b/src/core/Directus/Services/AuthService.php
new file mode 100644
index 0000000000..22547c1cda
--- /dev/null
+++ b/src/core/Directus/Services/AuthService.php
@@ -0,0 +1,400 @@
+validateCredentials($email, $password);
+
+ /** @var Provider $auth */
+ $auth = $this->container->get('auth');
+
+ /** @var UserInterface $user */
+ $user = $auth->login([
+ 'email' => $email,
+ 'password' => $password
+ ]);
+
+ $hookEmitter = $this->container->get('hook_emitter');
+ $hookEmitter->run('directus.authenticated', [$user]);
+
+ // TODO: Move to the hook above
+ /** @var DirectusActivityTableGateway $activityTableGateway */
+ $activityTableGateway = $this->createTableGateway('directus_activity', false);
+ $activityTableGateway->recordLogin($user->get('id'));
+
+ return [
+ 'data' => [
+ 'token' => $this->generateAuthToken($user)
+ ]
+ ];
+ }
+
+ /**
+ * @param string $name
+ *
+ * @return array
+ */
+ public function getAuthenticationRequestInfo($name)
+ {
+ return [
+ 'data' => $this->getSsoAuthorizationInfo($name)
+ ];
+ }
+
+ /**
+ * Gets the basic information of a sso service
+ *
+ * @param string $name
+ *
+ * @return array
+ */
+ public function getSsoBasicInfo($name)
+ {
+ /** @var Social $socialAuth */
+ $socialAuth = $this->container->get('external_auth');
+ /** @var AbstractSocialProvider $service */
+ $service = $socialAuth->get($name);
+ $basePath = $this->container->get('path_base');
+
+ $iconUrl = null;
+ $type = $service->getConfig()->get('custom') === true ? 'custom' : 'core';
+ $iconPath = sprintf('/extensions/%s/auth/%s/icon.svg', $type, $name);
+ if (file_exists($basePath . '/public' . $iconPath)) {
+ $iconUrl = get_url($iconPath);
+ }
+
+ return [
+ 'name' => $name,
+ 'icon' => $iconUrl
+ ];
+ }
+
+ /**
+ * @param string $name
+ *
+ * @return array
+ */
+ public function getSsoAuthorizationInfo($name)
+ {
+ /** @var Social $socialAuth */
+ $socialAuth = $this->container->get('external_auth');
+ /** @var AbstractSocialProvider $service */
+ $service = $socialAuth->get($name);
+
+ $authorizationInfo = [
+ 'authorization_url' => $service->getRequestAuthorizationUrl()
+ ];
+
+ if ($service instanceof TwoSocialProvider) {
+ $authorizationInfo['state'] = $service->getProvider()->getState();
+ }
+
+ return $authorizationInfo;
+ }
+
+ /**
+ * @param string $name
+ *
+ * @return array
+ */
+ public function getSsoCallbackInfo($name)
+ {
+ /** @var Social $socialAuth */
+ $socialAuth = $this->container->get('external_auth');
+ /** @var AbstractSocialProvider $service */
+ $service = $socialAuth->get($name);
+
+ return [
+ 'callback_url' => $service->getRequestAuthorizationUrl()
+ ];
+ }
+
+ /**
+ * Gets the given SSO service information
+ *
+ * @param string $name
+ *
+ * @return array
+ */
+ public function getSsoInfo($name)
+ {
+ return array_merge(
+ $this->getSsoBasicInfo($name),
+ $this->getSsoAuthorizationInfo($name),
+ $this->getSsoCallbackInfo($name)
+ );
+ }
+
+ public function handleAuthenticationRequestCallback($name, $generateRequestToken = false)
+ {
+ /** @var Social $socialAuth */
+ $socialAuth = $this->container->get('external_auth');
+ /** @var AbstractSocialProvider $service */
+ $service = $socialAuth->get($name);
+
+ $serviceUser = $service->handle();
+
+ $user = $this->authenticateWithEmail($serviceUser->getEmail());
+ if ($generateRequestToken) {
+ $token = $this->generateRequestToken($user);
+ } else {
+ $token = $this->generateAuthToken($user);
+ }
+
+ return [
+ 'data' => [
+ 'token' => $token
+ ]
+ ];
+ }
+
+ /**
+ * @param $token
+ *
+ * @return UserInterface
+ */
+ public function authenticateWithToken($token)
+ {
+ if (JWTUtils::isJWT($token)) {
+ $authenticated = $this->getAuthProvider()->authenticateWithToken($token);
+ } else {
+ $authenticated = $this->getAuthProvider()->authenticateWithPrivateToken($token);
+ }
+
+ return $authenticated;
+ }
+
+ /**
+ * Authenticates a user with the given email
+ *
+ * @param $email
+ *
+ * @return \Directus\Authentication\User\User
+ *
+ * @throws UserWithEmailNotFoundException
+ */
+ public function authenticateWithEmail($email)
+ {
+ return $this->getAuthProvider()->authenticateWithEmail($email);
+ }
+
+ /**
+ * Authenticate an user with the SSO authorization code
+ *
+ * @param string $service
+ * @param array $params
+ *
+ * @return array
+ */
+ public function authenticateWithSsoCode($service, array $params)
+ {
+ /** @var Social $socialAuth */
+ $socialAuth = $this->container->get('external_auth');
+ /** @var AbstractSocialProvider $service */
+ $service = $socialAuth->get($service);
+
+ if ($service instanceof OneSocialProvider) {
+ $data = ArrayUtils::pick($params, ['oauth_token', 'oauth_verifier']);
+ } else {
+ $data = ArrayUtils::pick($params, ['code']);
+ }
+
+ $serviceUser = $service->getUserFromCode($data);
+ $user = $this->authenticateWithEmail($serviceUser->getEmail());
+
+ return [
+ 'data' => [
+ 'token' => $this->generateAuthToken($user)
+ ]
+ ];
+ }
+
+ /**
+ * Gets the access token from a request token
+ *
+ * @param string $token
+ *
+ * @return array
+ *
+ * @throws ExpiredRequestTokenException
+ * @throws InvalidRequestTokenException
+ */
+ public function authenticateWithRequestToken($token)
+ {
+ if (!JWTUtils::isJWT($token)) {
+ throw new InvalidRequestTokenException();
+ }
+
+ if (JWTUtils::hasExpired($token)) {
+ throw new ExpiredRequestTokenException();
+ }
+
+ $payload = JWTUtils::getPayload($token);
+ /** @var Provider $auth */
+ $auth = $this->container->get('auth');
+ $user = $auth->getUserProvider()->findWhere([
+ 'id' => $payload->id
+ ]);
+
+ return [
+ 'data' => [
+ 'token' => $this->generateAuthToken($user)
+ ]
+ ];
+ }
+
+ /**
+ * Generates JWT Token
+ *
+ * @param UserInterface $user
+ *
+ * @return string
+ */
+ public function generateAuthToken(UserInterface $user)
+ {
+ /** @var Provider $auth */
+ $auth = $this->container->get('auth');
+
+ return $auth->generateAuthToken($user);
+ }
+
+ /**
+ * Generates a Request JWT Token use for SSO Authentication
+ *
+ * @param UserInterface $user
+ *
+ * @return string
+ */
+ public function generateRequestToken(UserInterface $user)
+ {
+ /** @var Provider $auth */
+ $auth = $this->container->get('auth');
+
+ return $auth->generateRequestToken($user);
+ }
+
+ /**
+ * Sends a email with the reset password token
+ *
+ * @param $email
+ */
+ public function sendResetPasswordToken($email)
+ {
+ $this->validate(['email' => $email], ['email' => 'required|email']);
+
+ /** @var Provider $auth */
+ $auth = $this->container->get('auth');
+ $user = $auth->findUserWithEmail($email);
+
+ $resetToken = $auth->generateResetPasswordToken($user);
+
+ send_forgot_password_email($user->toArray(), $resetToken);
+ }
+
+ public function resetPasswordWithToken($token)
+ {
+ if (!JWTUtils::isJWT($token)) {
+ throw new InvalidResetPasswordTokenException($token);
+ }
+
+ if (JWTUtils::hasExpired($token)) {
+ throw new ExpiredResetPasswordToken($token);
+ }
+
+ $payload = JWTUtils::getPayload($token);
+
+ /** @var Provider $auth */
+ $auth = $this->container->get('auth');
+ $userProvider = $auth->getUserProvider();
+ $user = $userProvider->find($payload->id);
+
+ if (!$user) {
+ throw new UserNotFoundException();
+ }
+
+ $newPassword = StringUtils::randomString(16);
+ $userProvider->update($user, [
+ 'password' => $auth->hashPassword($newPassword)
+ ]);
+
+ send_reset_password_email($user->toArray(), $newPassword);
+ }
+
+ public function refreshToken($token)
+ {
+ $this->validate([
+ 'token' => $token
+ ], [
+ 'token' => 'required'
+ ]);
+
+ /** @var Provider $auth */
+ $auth = $this->container->get('auth');
+
+ return ['data' => ['token' => $auth->refreshToken($token)]];
+ }
+
+ /**
+ * @return Provider
+ */
+ protected function getAuthProvider()
+ {
+ return $this->container->get('auth');
+ }
+
+ /**
+ * Validates email+password credentials
+ *
+ * @param $email
+ * @param $password
+ *
+ * @throws BadRequestException
+ */
+ protected function validateCredentials($email, $password)
+ {
+ $payload = [
+ 'email' => $email,
+ 'password' => $password
+ ];
+ $constraints = [
+ 'email' => 'required|email',
+ 'password' => 'required'
+ ];
+
+ // throws an exception if the constraints are not met
+ $this->validate($payload, $constraints);
+ }
+}
diff --git a/src/core/Directus/Services/CollectionPresetsService.php b/src/core/Directus/Services/CollectionPresetsService.php
new file mode 100644
index 0000000000..53a33faefe
--- /dev/null
+++ b/src/core/Directus/Services/CollectionPresetsService.php
@@ -0,0 +1,53 @@
+collection = SchemaManager::COLLECTION_COLLECTION_PRESETS;
+ $this->itemsService = new ItemsService($this->container);
+ }
+
+ public function findAll(array $params = [])
+ {
+ return $this->itemsService->findAll($this->collection, $params);
+ }
+
+ public function createItem(array $payload, array $params = [])
+ {
+ return $this->itemsService->createItem($this->collection, $payload, $params);
+ }
+
+ public function find($id, array $params = [])
+ {
+ return $this->itemsService->find($this->collection, $id, $params);
+ }
+
+ public function update($id, array $payload, array $params = [])
+ {
+ return $this->itemsService->update($this->collection, $id, $payload, $params);
+ }
+
+ public function delete($id, array $params = [])
+ {
+ return $this->itemsService->delete($this->collection, $id, $params);
+ }
+}
diff --git a/src/core/Directus/Services/FilesServices.php b/src/core/Directus/Services/FilesServices.php
new file mode 100644
index 0000000000..0bc5a16cc0
--- /dev/null
+++ b/src/core/Directus/Services/FilesServices.php
@@ -0,0 +1,150 @@
+collection = SchemaManager::COLLECTION_FILES;
+ }
+
+ public function create(array $data, array $params = [])
+ {
+ $this->enforcePermissions($this->collection, $data, $params);
+ $tableGateway = $this->createTableGateway($this->collection);
+
+ $data['upload_user'] = $this->getAcl()->getUserId();
+ $data['upload_date'] = DateTimeUtils::nowInUTC()->toString();
+
+ $validationConstraints = $this->createConstraintFor($this->collection);
+ $this->validate($data, array_merge(['data' => 'required'], $validationConstraints));
+ $newFile = $tableGateway->updateRecord($data, $this->getCRUDParams($params));
+
+ return $tableGateway->wrapData(
+ append_storage_information($newFile->toArray()),
+ true,
+ ArrayUtils::get($params, 'meta')
+ );
+ }
+
+ public function find($id, array $params = [])
+ {
+ $tableGateway = $this->createTableGateway($this->collection);
+ $params['id'] = $id;
+
+ return $this->getItemsAndSetResponseCacheTags($tableGateway , $params);
+ }
+
+ public function update($id, array $data, array $params = [])
+ {
+ $this->enforcePermissions($this->collection, $data, $params);
+
+ $this->validatePayload($this->collection, array_keys($data), $data, $params);
+ $tableGateway = $this->createTableGateway($this->collection);
+ $data[$tableGateway->primaryKeyFieldName] = $id;
+ $newFile = $tableGateway->updateRecord($data, $this->getCRUDParams($params));
+
+ return $tableGateway->wrapData(
+ append_storage_information($newFile->toArray()),
+ true,
+ ArrayUtils::get($params, 'meta')
+ );
+ }
+
+ public function delete($id, array $params = [])
+ {
+ $this->enforcePermissions($this->collection, [], $params);
+ $tableGateway = $this->createTableGateway($this->collection);
+ $file = $tableGateway->loadItems(['id' => $id]);
+
+ // Force delete files
+ // TODO: Make the hook listen to deletes and catch ALL ids (from conditions)
+ // and deletes every matched files
+ /** @var \Directus\Filesystem\Files $files */
+ $files = $this->container->get('files');
+ $files->delete($file);
+
+ // Delete file record
+ return $tableGateway->deleteRecord($id);
+ }
+
+ public function findAll(array $params = [])
+ {
+ $tableGateway = $this->createTableGateway($this->collection);
+
+ return $this->getItemsAndSetResponseCacheTags($tableGateway, $params);
+ }
+
+ public function createFolder(array $data, array $params = [])
+ {
+ $collection = 'directus_folders';
+ $this->enforcePermissions($collection, $data, $params);
+ $this->validatePayload($collection, null, $data, $params);
+
+ $foldersTableGateway = $this->createTableGateway($collection);
+
+ $newFolder = $foldersTableGateway->updateRecord($data, $this->getCRUDParams($params));
+
+ return $foldersTableGateway->wrapData(
+ $newFolder->toArray(),
+ true,
+ ArrayUtils::get($params, 'meta')
+ );
+ }
+
+ public function findFolder($id, array $params = [])
+ {
+ $foldersTableGateway = $this->createTableGateway('directus_folders');
+ $params['id'] = $id;
+
+ return $this->getItemsAndSetResponseCacheTags($foldersTableGateway, $params);
+ }
+
+ public function updateFolder($id, array $data, array $params = [])
+ {
+ $this->enforcePermissions('directus_folders', $data, $params);
+ $foldersTableGateway = $this->createTableGateway('directus_folders');
+
+ $data['id'] = $id;
+ $group = $foldersTableGateway->updateRecord($data, $this->getCRUDParams($params));
+
+ return $foldersTableGateway->wrapData(
+ $group->toArray(),
+ true,
+ ArrayUtils::get($params, 'meta')
+ );
+ }
+
+ public function findAllFolders(array $params = [])
+ {
+ $foldersTableGateway = $this->createTableGateway('directus_folders');
+
+ return $this->getItemsAndSetResponseCacheTags($foldersTableGateway, $params);
+ }
+
+ public function deleteFolder($id, array $params = [])
+ {
+ $this->enforcePermissions('directus_folders', [], $params);
+
+ $foldersTableGateway = $this->createTableGateway('directus_folders');
+ // NOTE: check if item exists
+ // TODO: As noted in other places make a light function to check for it
+ $this->getItemsAndSetResponseCacheTags($foldersTableGateway, [
+ 'id' => $id
+ ]);
+
+ return $foldersTableGateway->deleteRecord($id, $this->getCRUDParams($params));
+ }
+}
diff --git a/src/core/Directus/Services/InterfacesService.php b/src/core/Directus/Services/InterfacesService.php
new file mode 100644
index 0000000000..2fa1780131
--- /dev/null
+++ b/src/core/Directus/Services/InterfacesService.php
@@ -0,0 +1,21 @@
+container->get('path_base');
+ $this->basePath = $basePath . '/public/extensions/core/interfaces';
+ }
+
+ public function findAll(array $params = [])
+ {
+ return $this->all($this->basePath, $params);
+ }
+}
diff --git a/src/core/Directus/Services/ItemsService.php b/src/core/Directus/Services/ItemsService.php
new file mode 100644
index 0000000000..34a7c57929
--- /dev/null
+++ b/src/core/Directus/Services/ItemsService.php
@@ -0,0 +1,296 @@
+enforcePermissions($collection, $payload, $params);
+ $this->validatePayload($collection, null, $payload, $params);
+
+ $tableGateway = $this->createTableGateway($collection);
+
+ // TODO: Throw an exception if ID exist in payload
+ $newRecord = $tableGateway->updateRecord($payload, $this->getCRUDParams($params));
+
+ try {
+ $item = $this->find($collection, $newRecord->getId());
+ } catch (\Exception $e) {
+ $item = null;
+ }
+
+ return $item;
+ }
+
+ /**
+ * Finds all items in a collection limited by a limit configuration
+ *
+ * @param $collection
+ * @param array $params
+ *
+ * @return array
+ */
+ public function findAll($collection, array $params = [])
+ {
+ // TODO: Use repository instead of TableGateway
+ return $this->getItemsAndSetResponseCacheTags(
+ $this->createTableGateway($collection),
+ $params
+ );
+ }
+
+ /**
+ * Gets a single item in the given collection and id
+ *
+ * @param string $collection
+ * @param mixed $id
+ * @param array $params
+ *
+ * @return array
+ */
+ public function find($collection, $id, array $params = [])
+ {
+ $statusValue = $this->getStatusValue($collection, $id);
+ $tableGateway = $this->createTableGateway($collection);
+
+ $this->getAcl()->enforceRead($collection, $statusValue);
+
+ return $this->getItemsAndSetResponseCacheTags($tableGateway, array_merge($params, [
+ 'id' => $id,
+ 'status' => null
+ ]));
+ }
+
+ /**
+ * Gets a single item in the given collection that matches the conditions
+ *
+ * @param string $collection
+ * @param array $params
+ *
+ * @return array
+ */
+ public function findOne($collection, array $params = [])
+ {
+ $tableGateway = $this->createTableGateway($collection);
+
+ return $this->getItemsAndSetResponseCacheTags($tableGateway, array_merge($params, [
+ 'single' => true
+ ]));
+ }
+
+ /**
+ * Updates a single item in the given collection and id
+ *
+ * @param string $collection
+ * @param mixed $id
+ * @param array $payload
+ * @param array $params
+ *
+ * @return array
+ */
+ public function update($collection, $id, $payload, array $params = [])
+ {
+ $this->enforcePermissions($collection, $payload, $params);
+ $this->validatePayload($collection, array_keys($payload), $payload, $params);
+
+ $tableGateway = $this->createTableGateway($collection);
+
+ // Fetch the entry even if it's not "published"
+ $params['status'] = '*';
+ $payload[$tableGateway->primaryKeyFieldName] = $id;
+ $newRecord = $tableGateway->updateRecord($payload, $this->getCRUDParams($params));
+
+ try {
+ $item = $this->find($collection, $newRecord->getId());
+ } catch (\Exception $e) {
+ $item = null;
+ }
+
+ return $item;
+ }
+
+ public function delete($collection, $id, array $params = [])
+ {
+ $this->enforcePermissions($collection, [], $params);
+
+ // TODO: Better way to check if the item exists
+ // $item = $this->find($collection, $id);
+
+ $tableGateway = $this->createTableGateway($collection);
+ $tableGateway->deleteRecord($id, $this->getCRUDParams($params));
+
+ return true;
+ }
+
+ /**
+ * @param $collection
+ * @param array $items
+ * @param array $params
+ *
+ * @return array
+ *
+ * @throws InvalidRequestException
+ */
+ public function batchCreate($collection, array $items, array $params = [])
+ {
+ if (!isset($items[0]) || !is_array($items[0])) {
+ throw new InvalidRequestException('batch create expect an array of items');
+ }
+
+ foreach ($items as $data) {
+ $this->validatePayload($collection, null, $data, $params);
+ }
+
+ $allItems = [];
+ foreach ($items as $data) {
+ $item = $this->createItem($collection, $data, $params);
+ if (!is_null($item)) {
+ $allItems[] = $item['data'];
+ }
+ }
+
+ if (!empty($allItems)) {
+ $allItems = ['data' => $allItems];
+ }
+
+ return $allItems;
+ }
+
+ /**
+ * @param $collection
+ * @param array $items
+ * @param array $params
+ *
+ * @return array
+ *
+ * @throws InvalidRequestException
+ */
+ public function batchUpdate($collection, array $items, array $params = [])
+ {
+ if (!isset($items[0]) || !is_array($items[0])) {
+ throw new InvalidRequestException('batch create expect an array of items');
+ }
+
+ foreach ($items as $data) {
+ $this->validatePayload($collection, array_keys($data), $data, $params);
+ $this->validatePayloadHasPrimaryKey($collection, $data);
+ }
+
+ $collectionObject = $this->getSchemaManager()->getCollection($collection);
+ $allItems = [];
+ foreach ($items as $data) {
+ $id = $data[$collectionObject->getPrimaryKeyName()];
+ $item = $this->update($collection, $id, $data, $params);
+
+ if (!is_null($item)) {
+ $allItems[] = $item['data'];
+ }
+ }
+
+ if (!empty($allItems)) {
+ $allItems = ['data' => $allItems];
+ }
+
+ return $allItems;
+ }
+
+ /**
+ * @param $collection
+ * @param array $ids
+ * @param array $payload
+ * @param array $params
+ *
+ * @return array
+ */
+ public function batchUpdateWithIds($collection, array $ids, array $payload, array $params = [])
+ {
+ $this->validatePayload($collection, array_keys($payload), $payload, $params);
+
+ $allItems = [];
+ foreach ($ids as $id) {
+ $item = $this->update($collection, $id, $payload, $params);
+ if (!empty($item)) {
+ $allItems[] = $item['data'];
+ }
+ }
+
+ if (!empty($allItems)) {
+ $allItems = ['data' => $allItems];
+ }
+
+ return $allItems;
+ }
+
+ /**
+ * @param $collection
+ * @param array $ids
+ * @param array $params
+ *
+ * @throws ForbiddenException
+ */
+ public function batchDeleteWithIds($collection, array $ids, array $params = [])
+ {
+ // TODO: Implement this into a hook
+ if ($collection === SchemaManager::COLLECTION_ROLES) {
+ $groupService = new RolesService($this->container);
+
+ foreach ($ids as $id) {
+ $group = $groupService->find($id);
+
+ if ($group && !$groupService->canDelete($id)) {
+ throw new ForbiddenException(
+ sprintf('You are not allowed to delete group [%s]', $group->name)
+ );
+ }
+ }
+ }
+
+ foreach ($ids as $id) {
+ $this->delete($collection, $id, $params);
+ }
+ }
+
+ protected function getItem(BaseRowGateway $row)
+ {
+ $collection = $row->getCollection();
+ $item = null;
+ $statusValue = $this->getStatusValue($collection, $row->getId());
+ $tableGateway = $this->createTableGateway($collection);
+
+ if ($this->getAcl()->canRead($collection, $statusValue)) {
+ $params['id'] = $row->getId();
+ $params['status'] = null;
+ $item = $this->getItemsAndSetResponseCacheTags($tableGateway, $params);
+ }
+
+ return $item;
+ }
+
+ protected function getStatusValue($collection, $id)
+ {
+ $collectionObject = $this->getSchemaManager()->getCollection($collection);
+
+ if (!$collectionObject->hasStatusField()) {
+ return null;
+ }
+
+ $primaryFieldName = $collectionObject->getPrimaryKeyName();
+ $tableGateway = new TableGateway($collection, $this->getConnection());
+ $select = $tableGateway->getSql()->select();
+ $select->columns([$collectionObject->getStatusField()->getName()]);
+ $select->where([
+ $primaryFieldName => $id
+ ]);
+
+ $row = $tableGateway->selectWith($select)->current();
+
+ return $row[$collectionObject->getStatusField()->getName()];
+ }
+}
diff --git a/src/core/Directus/Services/ListingsService.php b/src/core/Directus/Services/ListingsService.php
new file mode 100644
index 0000000000..a686bf3630
--- /dev/null
+++ b/src/core/Directus/Services/ListingsService.php
@@ -0,0 +1,21 @@
+container->get('path_base');
+ $this->basePath = $basePath . '/public/extensions/core/listings';
+ }
+
+ public function findAll(array $params = [])
+ {
+ return $this->all($this->basePath, $params);
+ }
+}
diff --git a/src/core/Directus/Services/PagesService.php b/src/core/Directus/Services/PagesService.php
new file mode 100644
index 0000000000..0f55a5c7ba
--- /dev/null
+++ b/src/core/Directus/Services/PagesService.php
@@ -0,0 +1,21 @@
+container->get('path_base');
+ $this->basePath = $basePath . '/public/extensions/core/pages';
+ }
+
+ public function findAll(array $params = [])
+ {
+ return $this->all($this->basePath, $params);
+ }
+}
diff --git a/src/core/Directus/Services/PermissionsService.php b/src/core/Directus/Services/PermissionsService.php
new file mode 100644
index 0000000000..c4ba997cf3
--- /dev/null
+++ b/src/core/Directus/Services/PermissionsService.php
@@ -0,0 +1,93 @@
+collection = SchemaManager::COLLECTION_PERMISSIONS;
+ }
+
+ /**
+ * @param array $data
+ * @param array $params
+ *
+ * @return array
+ */
+ public function create(array $data, array $params = [])
+ {
+ $this->enforcePermissions($this->collection, $data, $params);
+ $this->validatePayload($this->collection, null, $data, $params);
+
+ $tableGateway = $this->getTableGateway();
+ $newGroup = $tableGateway->updateRecord($data, $this->getCRUDParams($params));
+
+ return $tableGateway->wrapData(
+ $newGroup->toArray(),
+ true,
+ ArrayUtils::get($params, 'meta')
+ );
+ }
+
+ public function find($id, array $params = [])
+ {
+ $params['id'] = $id;
+
+ return $this->getItemsAndSetResponseCacheTags($this->getTableGateway(), $params);
+ }
+
+ public function update($id, array $data, array $params = [])
+ {
+ $this->enforcePermissions($this->collection, $data, $params);
+ $this->validatePayload($this->collection, array_keys($data), $data, $params);
+
+ $tableGateway = $this->getTableGateway();
+ $data['id'] = $id;
+ $newGroup = $tableGateway->updateRecord($data, $this->getCRUDParams($params));
+
+ return $tableGateway->wrapData(
+ $newGroup->toArray(),
+ true,
+ ArrayUtils::get($params, 'meta')
+ );
+ }
+
+ public function delete($id, array $params = [])
+ {
+ $this->enforcePermissions($this->collection, [], $params);
+ $this->validate(['id' => $id], $this->createConstraintFor($this->collection, ['id']));
+ $tableGateway = $this->getTableGateway();
+ $this->getItemsAndSetResponseCacheTags($tableGateway, [
+ 'id' => $id
+ ]);
+
+ $tableGateway->deleteRecord($id, $this->getCRUDParams($params));
+
+ return true;
+ }
+
+ public function findAll(array $params = [])
+ {
+ return $this->getItemsAndSetResponseCacheTags($this->getTableGateway(), $params);
+ }
+
+ /**
+ * @return \Directus\Database\TableGateway\RelationalTableGateway
+ */
+ protected function getTableGateway()
+ {
+ return $this->createTableGateway($this->collection);
+ }
+}
diff --git a/src/core/Directus/Services/RelationsService.php b/src/core/Directus/Services/RelationsService.php
new file mode 100644
index 0000000000..79a3b2eb56
--- /dev/null
+++ b/src/core/Directus/Services/RelationsService.php
@@ -0,0 +1,67 @@
+collection = SchemaManager::COLLECTION_RELATIONS;
+ $this->itemsService = new ItemsService($this->container);
+ }
+
+ public function create(array $data, array $params = [])
+ {
+ return $this->itemsService->createItem($this->collection, $data, $params);
+ }
+
+ public function find($id, array $params = [])
+ {
+ return $this->itemsService->find($this->collection, $id, $params);
+ }
+
+ public function update($id, array $data, array $params = [])
+ {
+ return $this->itemsService->update($this->collection, $id, $data, $params);
+ }
+
+ public function delete($id, array $params = [])
+ {
+ return $this->itemsService->delete($this->collection, $id, $params);
+ }
+
+ public function findAll(array $params = [])
+ {
+ return $this->itemsService->findAll($this->collection, $params);
+ }
+
+ public function batchCreate(array $payload, array $params = [])
+ {
+ return $this->itemsService->batchCreate($this->collection, $payload, $params);
+ }
+
+ public function batchUpdateWithIds(array $ids, array $payload, array $params = [])
+ {
+ return $this->itemsService->batchUpdateWithIds($this->collection, $ids, $payload, $params);
+ }
+
+ public function batchDeleteWithIds(array $ids, array $params = [])
+ {
+ return $this->itemsService->batchDeleteWithIds($this->collection, $ids, $params);
+ }
+}
diff --git a/src/core/Directus/Services/RevisionsService.php b/src/core/Directus/Services/RevisionsService.php
new file mode 100644
index 0000000000..6f6f2cfcc4
--- /dev/null
+++ b/src/core/Directus/Services/RevisionsService.php
@@ -0,0 +1,177 @@
+collection = SchemaManager::COLLECTION_REVISIONS;
+ }
+
+ /**
+ * Returns all items from revisions
+ *
+ * Result count will be limited by the rows per page setting
+ *
+ * @param array $params
+ *
+ * @return array|mixed
+ */
+ public function findAll(array $params = [])
+ {
+ $tableGateway = $this->getTableGateway();
+
+ return $this->getDataAndSetResponseCacheTags(
+ [$tableGateway, 'getItems'],
+ [$params]
+ );
+ }
+
+ /**
+ * Returns all revisions for a specific collection
+ *
+ * Result count will be limited by the rows per page setting
+ *
+ * @param $collection
+ * @param array $params
+ *
+ * @return array|mixed
+ */
+ public function findByCollection($collection, array $params = [])
+ {
+ $tableGateway = $this->getTableGateway();
+
+ return $this->getDataAndSetResponseCacheTags(
+ [$tableGateway, 'getItems'],
+ [array_merge_recursive($params, ['filter' => ['collection' => $collection]])]
+ );
+ }
+
+ /**
+ * Returns one revision with the given id
+ *
+ * @param int $id
+ * @param array $params
+ *
+ * @return array
+ */
+ public function findOne($id, array $params = [])
+ {
+ $tableGateway = $this->getTableGateway();
+
+ return $this->getDataAndSetResponseCacheTags(
+ [$tableGateway, 'getItems'],
+ [array_merge_recursive($params, ['filter' => ['id' => $id]])]
+ );
+ }
+
+ /**
+ * Returns all revision from a given item
+ *
+ * @param string $collection
+ * @param mixed $item
+ * @param array $params
+ *
+ * @return array
+ */
+ public function findAllByItem($collection, $item, array $params = [])
+ {
+ $tableGateway = $this->getTableGateway();
+
+ return $this->getDataAndSetResponseCacheTags(
+ [$tableGateway, 'getItems'],
+ [array_merge($params, ['filter' => ['item' => $item, 'collection' => $collection]])]
+ );
+ }
+
+ /**
+ * @param string $collection
+ * @param string $item
+ * @param int $offset
+ * @param array $params
+ *
+ * @return array|mixed
+ */
+ public function findOneByItemOffset($collection, $item, $offset, array $params = [])
+ {
+ $tableGateway = $this->getTableGateway();
+
+ $this->validate(['offset' => $offset], ['offset' => 'required|numeric']);
+
+ return $this->getDataAndSetResponseCacheTags(
+ [$tableGateway, 'getItems'],
+ [array_merge(
+ $params,
+ [
+ // Make sure it's sorted by ID ascending
+ // to proper pick the offset based on creation order
+ 'sort' => 'id',
+ 'filter' => ['item' => $item, 'collection' => $collection],
+ 'single' => true,
+ 'limit' => 1,
+ 'offset' => (int)$offset
+ ]
+ )]
+ );
+ }
+
+ public function revert($collectionName, $item, $revision, array $params = [])
+ {
+ $revisionTableGateway = new TableGateway(SchemaManager::COLLECTION_REVISIONS, $this->getConnection());
+ $select = $revisionTableGateway->getSql()->select();
+ $select->columns(['delta']);
+ $select->where->equalTo('id', $revision);
+ $select->where->equalTo('collection', $collectionName);
+ $select->where->equalTo('item', $item);
+
+ $result = $revisionTableGateway->selectWith($select)->current();
+ if (!$result) {
+ throw new RevisionNotFoundException($revision);
+ }
+
+ $data = json_decode($result->delta, true);
+ if (!$data) {
+ throw new RevisionInvalidDeltaException($revision);
+ }
+
+ $collection = SchemaService::getCollection($collectionName);
+ $tableGateway = $this->createTableGateway($collectionName);
+
+ $data[$collection->getPrimaryKeyName()] = $item;
+ $tableGateway->revertRecord($data);
+
+ return $this->getDataAndSetResponseCacheTags(
+ [$tableGateway, 'getItems'],
+ [array_merge(
+ $params,
+ [
+ 'single' => true,
+ 'id' => $item
+ ]
+ )]
+ );
+ }
+
+ /**
+ * @return \Directus\Database\TableGateway\RelationalTableGateway
+ */
+ protected function getTableGateway()
+ {
+ return $this->createTableGateway($this->collection);
+ }
+}
diff --git a/src/core/Directus/Services/RolesService.php b/src/core/Directus/Services/RolesService.php
new file mode 100644
index 0000000000..5cda7a447d
--- /dev/null
+++ b/src/core/Directus/Services/RolesService.php
@@ -0,0 +1,165 @@
+collection = SchemaManager::COLLECTION_ROLES;
+ $this->itemsService = new ItemsService($this->container);
+ }
+
+ public function create(array $data, array $params = [])
+ {
+ $this->validatePayload($this->collection, null, $data, $params);
+ $this->enforcePermissions($this->collection, $data, $params);
+
+ $groupsTableGateway = $this->createTableGateway($this->collection);
+ // make sure to create new one instead of update
+ unset($data[$groupsTableGateway->primaryKeyFieldName]);
+ $newGroup = $groupsTableGateway->updateRecord($data, $this->getCRUDParams($params));
+
+ return $groupsTableGateway->wrapData(
+ $newGroup->toArray(),
+ true,
+ ArrayUtils::get($params, 'meta')
+ );
+ }
+
+ /**
+ * Finds a group by the given ID in the database
+ *
+ * @param int $id
+ * @param array $params
+ *
+ * @return array
+ */
+ public function find($id, array $params = [])
+ {
+ $tableGateway = $this->getTableGateway();
+ $params['id'] = $id;
+
+ return $this->getItemsAndSetResponseCacheTags($tableGateway, $params);
+ }
+
+ /**
+ * Gets a single item that matches the conditions
+ *
+ * @param array $params
+ *
+ * @return array
+ */
+ public function findOne(array $params = [])
+ {
+ return $this->itemsService->findOne($this->collection, $params);
+ }
+
+ public function update($id, array $data, array $params = [])
+ {
+ $this->validatePayload($this->collection, array_keys($data), $data, $params);
+ $this->enforcePermissions($this->collection, $data, $params);
+
+ $groupsTableGateway = $this->getTableGateway();
+
+ $data['id'] = $id;
+ $group = $groupsTableGateway->updateRecord($data, $this->getCRUDParams($params));
+
+ return $groupsTableGateway->wrapData(
+ $group->toArray(),
+ true,
+ ArrayUtils::get($params, 'meta')
+ );
+ }
+
+ public function findAll(array $params = [])
+ {
+ $groupsTableGateway = $this->getTableGateway();
+
+ return $this->getItemsAndSetResponseCacheTags($groupsTableGateway, $params);
+ }
+
+ public function delete($id, array $params = [])
+ {
+ $this->enforcePermissions($this->collection, [], $params);
+ $this->validate(['id' => $id], $this->createConstraintFor($this->collection, ['id']));
+
+ // TODO: Create exists method
+ // NOTE: throw an exception if item does not exists
+ $group = $this->find($id);
+
+ // TODO: Make the error messages more specific
+ if (!$this->canDelete($id)) {
+ throw new UnauthorizedException(sprintf('You are not allowed to delete group [%s]', $id));
+ }
+
+ $tableGateway = $this->getTableGateway();
+
+ $tableGateway->deleteRecord($id, $this->getCRUDParams($params));
+
+ return true;
+ }
+
+ /**
+ * Checks whether the the group be deleted
+ *
+ * @param $id
+ * @param bool $fetchNew
+ *
+ * @return bool
+ */
+ public function canDelete($id, $fetchNew = false)
+ {
+ if (!$this->lastGroup || $fetchNew === true) {
+ $group = $this->find($id);
+ } else {
+ $group = $this->lastGroup;
+ }
+
+ // TODO: RowGateWay should parse values against their column type
+ return !(!$group || $group->id == 1 || strtolower($group->name) === 'public');
+ }
+
+ /**
+ * @return DirectusRolesTableGateway
+ */
+ public function getTableGateway()
+ {
+ if (!$this->tableGateway) {
+ $acl = $this->container->get('acl');
+ $dbConnection = $this->container->get('database');
+
+ $this->tableGateway = new DirectusRolesTableGateway($dbConnection, $acl);
+ }
+
+ return $this->tableGateway;
+ }
+}
diff --git a/src/core/Directus/Services/ScimService.php b/src/core/Directus/Services/ScimService.php
new file mode 100644
index 0000000000..e1870f4351
--- /dev/null
+++ b/src/core/Directus/Services/ScimService.php
@@ -0,0 +1,663 @@
+usersService = new UsersService($this->container);
+ $this->rolesService = new RolesService($this->container);
+ }
+
+ public function createUser(array $data, array $params = [])
+ {
+ // TODO: Validate the payload schema
+ $user = $this->usersService->create($this->parseScimUserData($data), $params);
+
+ if ($user) {
+ $user = $this->parseUserData(ArrayUtils::get($user, 'data', []));
+ }
+
+ return $user;
+ }
+
+ public function createGroup(array $data, array $params = [])
+ {
+ // TODO: Validate the payload schema
+ $user = $this->rolesService->create($this->parseScimGroupData($data), $params);
+
+ if ($user) {
+ $user = $this->parseGroupData(ArrayUtils::get($user, 'data', []));
+ }
+
+ return $user;
+ }
+
+ public function updateUser($id, array $data, array $params = [])
+ {
+ $user = $this->usersService->findOne([
+ 'fields' => 'id',
+ 'single' => true,
+ 'filter' => [
+ 'external_id' => $id
+ ]
+ ]);
+
+ $parsedData = $this->parseScimUserData($data);
+ ArrayUtils::pull($parsedData, 'external_id');
+
+ $user = $this->usersService->update(ArrayUtils::get($user, 'data.id'), $parsedData, $params);
+
+ if ($user) {
+ $user = $this->parseUserData(ArrayUtils::get($user, 'data', []));
+ }
+
+ return $user;
+ }
+
+ public function updateGroup($id, array $data, array $params = [])
+ {
+ $user = $this->rolesService->findOne([
+ 'fields' => 'id',
+ 'single' => true,
+ 'filter' => [
+ 'external_id' => $id
+ ]
+ ]);
+
+ $parsedData = $this->parseScimGroupData($data);
+ ArrayUtils::pull($parsedData, 'external_id');
+
+ $user = $this->rolesService->update(ArrayUtils::get($user, 'data.id'), $parsedData, $params);
+
+ if ($user) {
+ $user = $this->parseGroupData(ArrayUtils::get($user, 'data', []));
+ }
+
+ return $user;
+ }
+
+ /**
+ * Returns the data of the give user id
+ *
+ * @param mixed $id
+ * @param array $params
+ * @return array
+ *
+ * @throws BadRequestException
+ */
+ public function findUser($id, array $params = [])
+ {
+ if (empty($id)) {
+ throw new BadRequestException('id cannot be empty');
+ }
+
+ $userData = $this->usersService->findOne(
+ [
+ 'single' => true,
+ 'filter' => ['external_id' => $id]
+ ]
+ );
+
+ return $this->parseUserData(ArrayUtils::get($userData, 'data', []));
+ }
+
+ /**
+ * Returns the data of the give group id
+ *
+ * @param mixed $id
+ * @param array $params
+ * @return array
+ *
+ * @throws BadRequestException
+ */
+ public function findGroup($id, array $params = [])
+ {
+ if (empty($id)) {
+ throw new BadRequestException('id cannot be empty');
+ }
+
+ $roleData = $this->rolesService->findOne(
+ [
+ 'single' => true,
+ 'filter' => ['external_id' => $id],
+ 'fields' => [
+ '*',
+ 'users.*.*'
+ ]
+ ]
+ );
+
+ return $this->parseGroupData(ArrayUtils::get($roleData, 'data', []));
+ }
+
+ /**
+ * Returns a list of users
+ *
+ * @param array $scimParams
+ *
+ * @return array
+ */
+ public function findAllUsers(array $scimParams = [])
+ {
+ $parameters = $this->parseListParameters(static::RESOURCE_USER, $scimParams);
+ $items = $this->usersService->findAll($parameters);
+
+ return $this->parseUsersData(
+ ArrayUtils::get($items, 'data', []),
+ ArrayUtils::get($items, 'meta', []),
+ $parameters
+ );
+ }
+
+ /**
+ * Returns a list of groups
+ *
+ * @param array $scimParams
+ *
+ * @return array
+ */
+ public function findAllGroups(array $scimParams = [])
+ {
+ $parameters = $this->parseListParameters(static::RESOURCE_GROUP, $scimParams);
+ $items = $this->rolesService->findAll(array_merge($parameters, [
+ 'fields' => [
+ '*',
+ 'users.*.*'
+ ]
+ ]));
+
+ return $this->parseGroupsData(
+ ArrayUtils::get($items, 'data', []),
+ ArrayUtils::get($items, 'meta', []),
+ $parameters
+ );
+ }
+
+ /**
+ * @param mixed $id
+ * @param array $params
+ *
+ * @return bool
+ */
+ public function deleteGroup($id, array $params = [])
+ {
+ $role = $this->rolesService->findOne([
+ 'fields' => 'id',
+ 'single' => true,
+ 'filter' => [
+ 'external_id' => $id
+ ]
+ ]);
+
+ return $this->rolesService->delete(ArrayUtils::get($role, 'data.id'));
+ }
+
+ /**
+ * Parse Scim parameters into Directus parameters
+ *
+ * @param string $resourceType
+ * @param array $scimParams
+ *
+ * @return array
+ */
+ protected function parseListParameters($resourceType, array $scimParams)
+ {
+ $filter = $this->getFilter($resourceType, ArrayUtils::get($scimParams, 'filter'));
+
+ $parameters = [
+ 'filter' => $filter
+ ];
+
+ if (ArrayUtils::has($scimParams, 'startIndex')) {
+ $offset = (int)ArrayUtils::get($scimParams, 'startIndex', 1);
+ $parameters['offset'] = $offset - 1;
+ }
+
+ if (ArrayUtils::has($scimParams, 'count')) {
+ $limit = (int)ArrayUtils::get($scimParams, 'count', 0);
+ $parameters['limit'] = $limit > 0 ? $limit : 0;
+ }
+
+ $parameters['meta'] = '*';
+
+ return $parameters;
+ }
+
+ /**
+ * @param string $filter
+ *
+ * @param string $resourceType
+ *
+ * @return array
+ *
+ * @throws BadRequestException
+ */
+ protected function getFilter($resourceType, $filter)
+ {
+ if (empty($filter)) {
+ return [];
+ }
+
+ if (!is_string($filter)) {
+ throw new BadRequestException('Filter must be a string');
+ }
+
+ $filterParts = preg_split('/\s+/', $filter);
+
+ if (count($filterParts) !== 3) {
+ throw new BadRequestException('Filter must be: '; + print_r($data); + echo ''; + echo '
+
|
+
+ This email was sent by Directus – {{settings.global.project.name }} +
++ Log in + to manage your email preferences +
diff --git a/src/mail/forgot-password.twig b/src/mail/forgot-password.twig new file mode 100644 index 0000000000..310c972f5c --- /dev/null +++ b/src/mail/forgot-password.twig @@ -0,0 +1,13 @@ +{% extends "base.twig" %} +{% block content %} + +Hey there,
+ +You requested to reset your password, here is your reset password link:
+ +{% set reset_url = settings.global.project.url|trim('/') ~ '/' ~ api.env ~ '/auth/reset_password/' ~ reset_token %} + + + Love,
Directus
Your new Instance of Directus is ready to go! You can access it using the following credentials:
+ +{{ project.url }}
+ +
+ Project Name: {{ project.name }}
+ Admin Email: {{ user.email }}
+ Admin Password: {{ user.password }}
+ Installed Version: {{ project.version }}
+ API Key: {{ user.token }}
+
+ Host Name: {{ database.host }}
+ Username: {{ database.user }}
+ Password: {{ database.password }}
+ Database Name: {{ database.name }}
+
Love,
Directus
Hey there,
+ +Here is a temporary password to access Directus:
+ +{{ new_password }}
+ +Once you log in, you can change your password via the User Settings menu.
+ +Love,
Directus
You have been invited to {{settings.global.project.name }}. Please click the link below to join:
+ +{% set invitation_url = settings.global.project.url|trim('/') ~ '/' ~ api.env ~ '/auth/invitation/' ~ token %} + + +Love,
Directus
+ */
+interface TaggableCacheItemInterface extends CacheItemInterface
+{
+ /**
+ * Get all existing tags. These are the tags the item has when the item is
+ * returned from the pool.
+ *
+ * @return array
+ */
+ public function getPreviousTags();
+
+ /**
+ * Overwrite all tags with a new set of tags.
+ *
+ * @param string[] $tags An array of tags
+ *
+ * @throws InvalidArgumentException When a tag is not valid.
+ *
+ * @return TaggableCacheItemInterface
+ */
+ public function setTags(array $tags);
+}
diff --git a/vendor/cache/cache/src/TagInterop/TaggableCacheItemPoolInterface.php b/vendor/cache/cache/src/TagInterop/TaggableCacheItemPoolInterface.php
new file mode 100644
index 0000000000..055bf4b09d
--- /dev/null
+++ b/vendor/cache/cache/src/TagInterop/TaggableCacheItemPoolInterface.php
@@ -0,0 +1,60 @@
+, Tobias Nyholm
+ */
+interface TaggableCacheItemPoolInterface extends CacheItemPoolInterface
+{
+ /**
+ * Invalidates cached items using a tag.
+ *
+ * @param string $tag The tag to invalidate
+ *
+ * @throws InvalidArgumentException When $tags is not valid
+ *
+ * @return bool True on success
+ */
+ public function invalidateTag($tag);
+
+ /**
+ * Invalidates cached items using tags.
+ *
+ * @param string[] $tags An array of tags to invalidate
+ *
+ * @throws InvalidArgumentException When $tags is not valid
+ *
+ * @return bool True on success
+ */
+ public function invalidateTags(array $tags);
+
+ /**
+ * {@inheritdoc}
+ *
+ * @return TaggableCacheItemInterface
+ */
+ public function getItem($key);
+
+ /**
+ * {@inheritdoc}
+ *
+ * @return array|\Traversable|TaggableCacheItemInterface[]
+ */
+ public function getItems(array $keys = []);
+}
diff --git a/vendor/cache/cache/src/TagInterop/composer.json b/vendor/cache/cache/src/TagInterop/composer.json
new file mode 100644
index 0000000000..03bc1e50eb
--- /dev/null
+++ b/vendor/cache/cache/src/TagInterop/composer.json
@@ -0,0 +1,39 @@
+{
+ "name": "cache/tag-interop",
+ "type": "library",
+ "description": "Framework interoperable interfaces for tags",
+ "keywords": [
+ "cache",
+ "psr6",
+ "tag",
+ "psr"
+ ],
+ "homepage": "http://www.php-cache.com/en/latest/",
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "Tobias Nyholm",
+ "email": "tobias.nyholm@gmail.com",
+ "homepage": "https://github.com/nyholm"
+ },
+ {
+ "name": "Nicolas Grekas ",
+ "email": "p@tchwork.com",
+ "homepage": "https://github.com/nicolas-grekas"
+ }
+ ],
+ "require": {
+ "php": "^5.5 || ^7.0",
+ "psr/cache": "^1.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "Cache\\TagInterop\\": ""
+ }
+ },
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0-dev"
+ }
+ }
+}
diff --git a/vendor/cache/cache/src/Taggable/Changelog.md b/vendor/cache/cache/src/Taggable/Changelog.md
new file mode 100644
index 0000000000..da23421b97
--- /dev/null
+++ b/vendor/cache/cache/src/Taggable/Changelog.md
@@ -0,0 +1,78 @@
+# Changelog
+
+The change log describes what is "Added", "Removed", "Changed" or "Fixed" between each release.
+
+## UNRELEASED
+
+## 1.0.0
+
+### Added
+
+* `Cache\Taggable\Exception\InvalidArgumentException`
+
+### Changed
+
+* We do not throw `Cache\Adapter\Common\Exception\InvalidArgumentException` anymore. Instead we throw
+`Cache\Taggable\Exception\InvalidArgumentException`. Both exceptions do implement `Psr\Cache\InvalidArgumentException`
+* We do not require `cache/adapter-common`
+
+### Removed
+
+* Deprecated interfaces `TaggableItemInterface` and `TaggablePoolInterface`
+
+## 0.5.1
+
+### Fixed
+
+* Bug on `TaggablePSR6ItemAdapter::isItemCreatedHere` where item value was `null`.
+
+## 0.5.0
+
+### Added
+
+* Support for `TaggableCacheItemPoolInterface`
+
+### Changed
+
+* The behavior of `TaggablePSR6ItemAdapter::getTags()` has changed. It will not return the tags stored in the cache storage.
+
+### Removed
+
+* `TaggablePoolTrait`
+* Deprecated `TaggablePoolInterface` in favor of `Cache\TagInterop\TaggableCacheItemPoolInterface`
+* Deprecated `TaggableItemInterface` in favor of `Cache\TagInterop\TaggableCacheItemInterface`
+* Removed support for `TaggablePoolInterface` and `TaggableItemInterface`
+* `TaggablePSR6ItemAdapter::getTags()`. Use `TaggablePSR6ItemAdapter::getPreviousTags()`
+* `TaggablePSR6ItemAdapter::addTag()`. Use `TaggablePSR6ItemAdapter::setTags()`
+
+## 0.4.3
+
+### Fixed
+
+* Do not lose the data when you start using the `TaggablePSR6PoolAdapter`
+
+## 0.4.2
+
+### Changed
+
+* Updated version for integration tests
+* Made `TaggablePSR6PoolAdapter::getTags` protected instead of private
+
+## 0.4.1
+
+### Fixed
+
+* Saving an expired value should be the same as removing that value
+
+## 0.4.0
+
+This is a big BC break. The API is rewritten and how we store tags has changed. Each tag is a key to a list in the
+cache storage. The list contains keys to items that uses that tag.
+
+* The `TaggableItemInterface` is completely rewritten. It extends `CacheItemInterface` and has three methods: `getTags`, `setTags` and `addTag`.
+* The `TaggablePoolInterface` is also rewritten. It has a new `clearTags` function.
+* The `TaggablePoolTrait` has new methods to manipulate the list of tags.
+
+## 0.3.1
+
+No changelog before this version
diff --git a/vendor/cache/cache/src/Taggable/Exception/InvalidArgumentException.php b/vendor/cache/cache/src/Taggable/Exception/InvalidArgumentException.php
new file mode 100644
index 0000000000..bcfa342e41
--- /dev/null
+++ b/vendor/cache/cache/src/Taggable/Exception/InvalidArgumentException.php
@@ -0,0 +1,16 @@
+, Tobias Nyholm