join()} to allow Editor to * obtain joined information from the database. * * For an overview of how Join tables work, please refer to the * {@link https://editor.datatables.net/manual/php/ Editor manual} as it is * useful to understand how this class represents the links between tables, * before fully getting to grips with it's API. * * @example * Join the parent table (the one specified in the {@see Editor->table()} * method) with the table *access*, with a link table *user__access*, which * allows multiple properties to be found for each row in the parent table. * ```php * Join::inst( 'access', 'array' ) * ->link( 'users.id', 'user_access.user_id' ) * ->link( 'access.id', 'user_access.access_id' ) * ->field( * Field::inst( 'id' )->validator( 'Validate::required' ), * Field::inst( 'name' ) * ) * ``` * * @example * Single row join - here we join the parent table with a single row in * the child table, without an intermediate link table. In this case the * child table is called *extra* and the two fields give the columns that * Editor will read from that table. * ```php * Join::inst( 'extra', 'object' ) * ->link( 'user.id', 'extra.user_id' ) * ->field( * Field::inst( 'comments' ), * Field::inst( 'review' ) * ) * ``` */ class Join extends DataTables\Ext { /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * Constructor */ /** * Join instance constructor. * @param string $table Table name to get the joined data from. * @param string $type Work with a single result ('object') or an array of * results ('array') for the join. */ function __construct( $table=null, $type='object' ) { $this->table( $table ); $this->type( $type ); } /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * Private properties */ /** @var DataTables\Editor\Field[] */ private $_fields = array(); /** @var array */ private $_join = array( "parent" => null, "child" => null, "table" => null ); /** @var array */ private $_leftJoin = array(); /** @var string */ private $_table = null; /** @var string */ private $_type = null; /** @var string */ private $_name = null; /** @var boolean */ private $_get = true; /** @var boolean */ private $_set = true; /** @var string */ private $_aliasParentTable = null; /** @var array */ private $_where = array(); /** @var boolean */ private $_whereSet = false; /** @var array */ private $_links = array(); /** @var string */ private $_customOrder = null; /** @var callable[] */ private $_validators = array(); /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * Public methods */ /** * Get / set parent table alias. * * When working with a self referencing table (i.e. a column in the table contains * a primary key value from its own table) it can be useful to set an alias on the * parent table's name, allowing a self referencing Join. For example: * ``` * SELECT p2.publisher * FROM publisher as p2 * JOIN publisher on (publisher.idPublisher = p2.idPublisher) * ``` * Where, in this case, `publisher` is the table that is used by the Editor instance, * and you want to use `Join` to link back to the table (resolving a name for example). * This method allows that alias to be set. Fields can then use standard SQL notation * to select a field, for example `p2.publisher` or `publisher.publisher`. * @param string $_ Table alias to use * @return string|self Table alias set (which is null by default), or self if used as * a setter. */ public function aliasParentTable ( $_=null ) { return $this->_getSet( $this->_aliasParentTable, $_ ); } /** * Get / set field instances. * * The list of fields designates which columns in the table that will be read * from the joined table. * @param Field $_... Instances of the {@see Field} class, given as a single * instance of {@see Field}, an array of {@see Field} instances, or multiple * {@see Field} instance parameters for the function. * @return Field[]|self Array of fields, or self if used as a setter. * @see {@see Field} for field documentation. */ public function field ( $_=null ) { $args = func_get_args(); if ( $_ !== null && !is_array($_) ) { $_ = $args; } return $this->_getSet( $this->_fields, $_, true ); } /** * Get / set field instances. * * An alias of {@see field}, for convenience. * @param Field $_... Instances of the {@see Field} class, given as a single * instance of {@see Field}, an array of {@see Field} instances, or multiple * {@see Field} instance parameters for the function. * @return Field[]|self Array of fields, or self if used as a setter. * @see {@see Field} for field documentation. */ public function fields ( $_=null ) { $args = func_get_args(); if ( $_ !== null && !is_array($_) ) { $_ = $args; } return $this->_getSet( $this->_fields, $_, true ); } /** * Get / set get attribute. * * When set to false no read operations will occur on the join tables. * @param boolean $_ Value * @return boolean|self Name */ public function get ( $_=null ) { return $this->_getSet( $this->_get, $_ ); } /** * Get / set join properties. * * Define how the SQL will be performed, on what columns. There are * basically two types of join that are supported by Editor here, a direct * foreign key reference in the join table to the parent table's primary * key, or a link table that contains just primary keys for the parent and * child tables (this approach is usually used with a {@see Join->type()} of * 'array' since you can often have multiple links between the two tables, * while a direct foreign key reference will typically use a type of * 'object' (i.e. a single entry). * * @param string|string[] $parent Parent table's primary key names. If used * with a link table (i.e. third parameter to this method is given, then * an array should be used, with the first element being the pkey's name * in the parent table, and the second element being the key's name in * the link table. * @param string|string[] $child Child table's primary key names. If used * with a link table (i.e. third parameter to this method is given, then * an array should be used, with the first element being the pkey's name * in the child table, and the second element being the key's name in the * link table. * @param string $table Join table name, if using a link table * @returns Join This for chaining * @deprecated 1.5 Please use the {@see Join->link()} method rather than this * method now. */ public function join ( $parent=null, $child=null, $table=null ) { if ( $parent === null && $child === null ) { return $this->_join; } $this->_join['parent'] = $parent; $this->_join['child'] = $child; $this->_join['table'] = $table; return $this; } /** * Set up a left join operation for the Mjoined data * * @param string $table to get the information from * @param string $field1 the first field to get the information from * @param string $operator the operation to perform on the two fields * @param string $field2 the second field to get the information from * @return self */ public function leftJoin ( $table, $field1, $operator, $field2 ) { $this->_leftJoin[] = array( "table" => $table, "field1" => $field1, "field2" => $field2, "operator" => $operator ); return $this; } /** * Create a join link between two tables. The order of the fields does not * matter, but each field must contain the table name as well as the field * name. * * This method can be called a maximum of two times for an Mjoin instance: * * * First time, creates a link between the Editor host table and a join * table * * Second time creates the links required for a link table. * * Please refer to the Editor Mjoin documentation for further details: * https://editor.datatables.net/manual/php * * @param string $field1 Table and field name * @param string $field2 Table and field name * @return Join Self for chaining * @throws \Exception Link limitations */ public function link ( $field1, $field2 ) { if ( strpos($field1, '.') === false || strpos($field2, '.') === false ) { throw new \Exception("Link fields must contain both the table name and the column name"); } if ( count( $this->_links ) >= 4 ) { throw new \Exception("Link method cannot be called more than twice for a single instance"); } $this->_links[] = $field1; $this->_links[] = $field2; return $this; } /** * Specify the property that the data will be sorted by. * * @param string $order SQL column name to order the data by * @return Join Self for chaining */ public function order ( $_=null ) { // How this works is by setting the SQL order by clause, and since the // join that is done in PHP is always done sequentially, the order is // retained. return $this->_getSet( $this->_customOrder, $_ ); } /** * Get / set name. * * The `name` of the Join is the JSON property key that is used when * 'getting' the data, and the HTTP property key (in a JSON style) when * 'setting' data. Typically the name of the db table will be used here, * but this method allows that to be overridden. * @param string $_ Field name * @return String|self Name */ public function name ( $_=null ) { return $this->_getSet( $this->_name, $_ ); } /** * Get / set set attribute. * * When set to false no write operations will occur on the join tables. * This can be useful when you want to display information which is joined, * but want to only perform write operations on the parent table. * @param boolean $_ Value * @return boolean|self Name */ public function set ( $_=null ) { return $this->_getSet( $this->_set, $_ ); } /** * Get / set join table name. * * Please note that this will also set the {@see Join->name()} used by the Join * as well. This is for convenience as the JSON output / HTTP input will * typically use the same name as the database name. If you want to set a * custom name, the {@see Join->name()} method must be called ***after*** this one. * @param string $_ Name of the table to read the join data from * @return String|self Name of the join table, or self if used as a setter. */ public function table ( $_=null ) { if ( $_ !== null ) { $this->_name = $_; } return $this->_getSet( $this->_table, $_ ); } /** * Get / set the join type. * * The join type allows the data that is returned from the join to be given * as an array (i.e. working with multiple possibly results for each record from * the parent table), or as an object (i.e. working which one and only one result * for each record form the parent table). * @param string $_ Join type ('object') or an array of * results ('array') for the join. * @return String|self Join type, or self if used as a setter. */ public function type ( $_=null ) { return $this->_getSet( $this->_type, $_ ); } /** * Set a validator for the array of data (not on a field basis) * * @param string $fieldName Name of the field that any error should be shown * against on the client-side * @param callable $fn Callback function for validation * @return self Chainable */ public function validator ( $fieldName, $fn ) { $this->_validators[] = array( 'fieldName' => $fieldName, 'fn' => $fn ); return $this; } /** * Where condition to add to the query used to get data from the database. * Note that this is applied to the child table. * * Can be used in two different ways: * * * Simple case: `where( field, value, operator )` * * Complex: `where( fn )` * * @param string|callable $key Single field name or a closure function * @param string|string[] $value Single field value, or an array of values. * @param string $op Condition operator: <, >, = etc * @return string[]|self Where condition array, or self if used as a setter. */ public function where ( $key=null, $value=null, $op='=' ) { if ( $key === null ) { return $this->_where; } if ( is_callable($key) && is_object($key) ) { $this->_where[] = $key; } else { $this->_where[] = array( "key" => $key, "value" => $value, "op" => $op ); } return $this; } /** * Get / set if the WHERE conditions should be included in the create and * edit actions. * * This means that the fields which have been used as part of the 'get' * WHERE condition (using the `where()` method) will be set as the values * given. * * This is default false (i.e. they are not included). * * @param boolean $_ Include (`true`), or not (`false`) * @return boolean Current value */ public function whereSet ( $_=null ) { return $this->_getSet( $this->_whereSet, $_ ); } /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * Internal methods */ /** * Get data * @param Editor $editor Host Editor instance * @param string[] $data Data from the parent table's get and were we need * to add out output. * @param array $options options array for fields * @internal */ public function data( $editor, &$data, &$options ) { if ( ! $this->_get ) { return; } $this->_prep( $editor ); $db = $editor->db(); $dteTable = $editor->table(); $pkey = $editor->pkey(); $idPrefix = $editor->idPrefix(); $dteTable = $dteTable[0]; if ( strpos($dteTable, ' as ') !== false ) { $arr = explode(' as ', $dteTable); $dteTable = $arr[0]; $this->_aliasParentTable = $arr[1]; } $dteTableLocal = $this->_aliasParentTable ? // Can be aliased to allow a self join $this->_aliasParentTable : $dteTable; $joinField = isset($this->_join['table']) ? $this->_join['parent'][0] : $this->_join['parent']; // This is something that will likely come in a future version, but it // is a relatively low use feature. Please get in touch if this is // something you require. if ( count( $pkey ) > 1 ) { throw new \Exception("MJoin is not currently supported with a compound primary key for the main table", 1); } if ( count($data) > 0 ) { $pkey = $pkey[0]; $pkeyIsJoin = $pkey === $joinField || $pkey === $dteTableLocal.'.'.$joinField; // Set up the JOIN query $stmt = $db ->query( 'select' ) ->distinct( true ) ->get( $dteTableLocal.'.'.$joinField.' as dteditor_pkey' ) ->get( $this->_fields('get') ) ->table( $dteTable .' as '. $dteTableLocal ); if ( $this->order() ) { $stmt->order( $this->order() ); } $stmt->left_join($this->_leftJoin); $this->_apply_where( $stmt ); if ( isset($this->_join['table']) ) { // Working with a link table $stmt ->join( $this->_join['table'], $dteTableLocal.'.'.$this->_join['parent'][0] .' = '. $this->_join['table'].'.'.$this->_join['parent'][1] ) ->join( $this->_table, $this->_table.'.'.$this->_join['child'][0] .' = '. $this->_join['table'].'.'.$this->_join['child'][1] ); } else { // No link table in the middle $stmt ->join( $this->_table, $this->_table.'.'.$this->_join['child'] .' = '. $dteTableLocal.'.'.$this->_join['parent'] ); } // Check that the joining field is available. The joining key can // come from the Editor instance's primary key, or any other field, // including a nested value (from a left join). If the instance's // pkey, then we've got that in the DT_RowId parameter, so we can // use that. Otherwise, the key must be in the field list. if ( $this->_propExists( $dteTable.'.'.$joinField, $data[0] ) ) { $readField = $dteTable.'.'.$joinField; } else if ( $this->_propExists( $joinField, $data[0] ) ) { $readField = $joinField; } else if ( ! $pkeyIsJoin ) { echo json_encode( array( "sError" => "Join was performed on the field '{$joinField}' which was not " ."included in the Editor field list. The join field must be included " ."as a regular field in the Editor instance." ) ); exit(0); } // Get list of pkey values and apply as a WHERE IN condition // This is primarily useful in server-side processing mode and when filtering // the table as it means only a sub-set will be selected // This is only applied for "sensible" data sets. It will just complicate // matters for really large data sets: // https://stackoverflow.com/questions/21178390/in-clause-limitation-in-sql-server if ( count($data) < 1000 ) { $whereIn = array(); for ( $i=0 ; $i_readProp( $readField, $data[$i] ); } $stmt->where_in( $dteTableLocal.'.'.$joinField, $whereIn ); } // Go! $res = $stmt->exec(); if ( ! $res ) { return; } // Map to primary key for fast lookup $join = array(); while ( $row=$res->fetch() ) { $inner = array(); for ( $j=0 ; $j_fields) ; $j++ ) { $field = $this->_fields[$j]; if ( $field->apply('get') ) { $field->write($inner, $row); } } if ( $this->_type === 'object' ) { $join[ $row['dteditor_pkey'] ] = $inner; } else { if ( !isset( $join[ $row['dteditor_pkey'] ] ) ) { $join[ $row['dteditor_pkey'] ] = array(); } $join[ $row['dteditor_pkey'] ][] = $inner; } } // Loop over the data and do a join based on the data available for ( $i=0 ; $i_readProp( $readField, $data[$i] ); if ( isset( $join[$rowPKey] ) ) { $data[$i][ $this->_name ] = $join[$rowPKey]; } else { $data[$i][ $this->_name ] = ($this->_type === 'object') ? (object)array() : array(); } } } // Field options foreach ($this->_fields as $field) { $opts = $field->optionsExec( $db ); if ( $opts !== false ) { $name = $this->name(). ($this->_type === 'object' ? '.' : '[].'). $field->name(); $options[ $name ] = $opts; } } } /** * Create a row. * @param Editor $editor Host Editor instance * @param int $parentId Parent row's primary key value * @param string[] $data Data to be set for the join * @internal */ public function create ( $editor, $parentId, $data ) { // If not settable, or the many count for the join was not submitted // there we do nothing if ( ! $this->_set || ! isset($data[$this->_name]) || ! isset($data[$this->_name.'-many-count']) ) { return; } $this->_prep( $editor ); $db = $editor->db(); if ( $this->_type === 'object' ) { $this->_insert( $db, $parentId, $data[$this->_name] ); } else { for ( $i=0 ; $i_name]) ; $i++ ) { $this->_insert( $db, $parentId, $data[$this->_name][$i] ); } } } /** * Update a row. * @param Editor $editor Host Editor instance * @param int $parentId Parent row's primary key value * @param string[] $data Data to be set for the join * @internal */ public function update ( $editor, $parentId, $data ) { // If not settable, or the many count for the join was not submitted // there we do nothing if ( ! $this->_set || ! isset($data[$this->_name.'-many-count']) ) { return; } $this->_prep( $editor ); $db = $editor->db(); if ( $this->_type === 'object' ) { // update or insert $this->_update_row( $db, $parentId, $data[$this->_name] ); } else { // WARNING - this will remove rows and then readd them. Any // data not in the field list WILL BE LOST // todo - is there a better way of doing this? $this->remove( $editor, array($parentId) ); $this->create( $editor, $parentId, $data ); } } /** * Delete rows * @param Editor $editor Host Editor instance * @param int[] $ids Parent row IDs to delete * @internal */ public function remove ( $editor, $ids ) { if ( ! $this->_set ) { return; } $that = $this; $this->_prep( $editor ); $db = $editor->db(); if ( isset($this->_join['table']) ) { $stmt = $db ->query( 'delete' ) ->table( $this->_join['table'] ) ->or_where( $this->_join['parent'][1], $ids ) ->exec(); } else { $stmt = $db ->query( 'delete' ) ->table( $this->_table ) ->where_group( function ( $q ) use ( $that, $ids ) { $q->or_where( $that->_join['child'], $ids ); } ); $this->_apply_where( $stmt ); $stmt->exec(); } } /** * Validate input data * * @param array $errors Errors array * @param Editor $editor Editor instance * @param string[] $data Data to validate * @param string $action `create` or `edit` * @internal */ public function validate ( &$errors, $editor, $data, $action ) { if ( ! $this->_set && ! isset($data[$this->_name.'-many-count']) ) { return; } $this->_prep( $editor ); $joinData = isset($data[$this->_name]) ? $data[$this->_name] : array(); for ( $i=0 ; $i_validators) ; $i++ ) { $validator = $this->_validators[$i]; $fn = $validator['fn']; $res = $fn( $editor, $action, $joinData ); if ( is_string($res) ) { $errors[] = array( "name" => $validator['fieldName'], "status" => $res ); } } if ( $this->_type === 'object' ) { $this->_validateFields( $errors, $editor, $joinData, $this->_name.'.' ); } else { for ( $i=0 ; $i_validateFields( $errors, $editor, $joinData[$i], $this->_name.'[].' ); } } } /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * Private methods */ /** * Add local WHERE condition to query * @param \DataTables\Database\Query $query Query instance to apply the WHERE conditions to * @private */ private function _apply_where ( $query ) { for ( $i=0 ; $i_where) ; $i++ ) { if ( is_callable( $this->_where[$i] ) ) { $this->_where[$i]( $query ); } else { $query->where( $this->_where[$i]['key'], $this->_where[$i]['value'], $this->_where[$i]['op'] ); } } } /** * Create a row. * @param \DataTables\Database $db Database reference to use * @param int $parentId Parent row's primary key value * @param string[] $data Data to be set for the join * @private */ private function _insert( $db, $parentId, $data ) { if ( isset($this->_join['table']) ) { // Insert keys into the join table $stmt = $db ->query('insert') ->table( $this->_join['table'] ) ->set( $this->_join['parent'][1], $parentId ) ->set( $this->_join['child'][1], $data[$this->_join['child'][0]] ) ->exec(); } else { // Insert values into the target table $stmt = $db ->query('insert') ->table( $this->_table ) ->set( $this->_join['child'], $parentId ); for ( $i=0 ; $i_fields) ; $i++ ) { $field = $this->_fields[$i]; if ( $field->apply( 'set', $data ) ) { // TODO should be create or edit $stmt->set( $field->dbField(), $field->val('set', $data) ); } } // If the where condition variables should also be added to the database // Note that `whereSet` is now deprecated if ( $this->_whereSet ) { for ( $i=0, $ien=count($this->_where) ; $i<$ien ; $i++ ) { if ( ! is_callable( $this->_where[$i] ) ) { $stmt->set( $this->_where[$i]['key'], $this->_where[$i]['value'] ); } } } $stmt->exec(); } } /** * Prepare the instance to be run. * * @param Editor $editor Editor instance * @private */ private function _prep ( $editor ) { $links = $this->_links; // Were links used to configure this instance - if so, we need to // back them onto the join array if ( $this->_join['parent'] === null && count($links) ) { $editorTable = $editor->table(); $editorTable = $editorTable[0]; $joinTable = $this->table(); if ( strpos($editorTable, ' as ') !== false ) { $arr = explode(' as ', $editorTable); $editorTable = $arr[0]; $this->_aliasParentTable = $arr[1]; } if ( $this->_aliasParentTable ) { $editorTable = $this->_aliasParentTable; } if ( count( $links ) === 2 ) { // No link table $f1 = explode( '.', $links[0] ); $f2 = explode( '.', $links[1] ); if ( $f1[0] === $editorTable ) { $this->_join['parent'] = $f1[1]; $this->_join['child'] = $f2[1]; } else { $this->_join['parent'] = $f2[1]; $this->_join['child'] = $f1[1]; } } else { // Link table $f1 = explode( '.', $links[0] ); $f2 = explode( '.', $links[1] ); $f3 = explode( '.', $links[2] ); $f4 = explode( '.', $links[3] ); // Discover the name of the link table if ( $f1[0] !== $editorTable && $f1[0] !== $joinTable ) { $this->_join['table'] = $f1[0]; } else if ( $f2[0] !== $editorTable && $f2[0] !== $joinTable ) { $this->_join['table'] = $f2[0]; } else if ( $f3[0] !== $editorTable && $f3[0] !== $joinTable ) { $this->_join['table'] = $f3[0]; } else { $this->_join['table'] = $f4[0]; } $this->_join['parent'] = array( $f1[1], $f2[1] ); $this->_join['child'] = array( $f3[1], $f4[1] ); } } } /** * Update a row. * @param \DataTables\Database $db Database reference to use * @param int $parentId Parent row's primary key value * @param string[] $data Data to be set for the join * @private */ private function _update_row ( $db, $parentId, $data ) { if ( isset($this->_join['table']) ) { // Got a link table, just insert the pkey references $db->push( $this->_join['table'], array( $this->_join['parent'][1] => $parentId, $this->_join['child'][1] => $data[$this->_join['child'][0]] ), array( $this->_join['parent'][1] => $parentId ) ); } else { // No link table, just a direct reference $set = array( $this->_join['child'] => $parentId ); for ( $i=0 ; $i_fields) ; $i++ ) { $field = $this->_fields[$i]; if ( $field->apply( 'set', $data ) ) { $set[ $field->dbField() ] = $field->val('set', $data); } } // Add WHERE conditions $where = array($this->_join['child'] => $parentId); for ( $i=0, $ien=count($this->_where) ; $i<$ien ; $i++ ) { $where[ $this->_where[$i]['key'] ] = $this->_where[$i]['value']; // Is there any point in this? Is there any harm? // Note that `whereSet` is now deprecated if ( $this->_whereSet ) { if ( ! is_callable( $this->_where[$i] ) ) { $set[ $this->_where[$i]['key'] ] = $this->_where[$i]['value']; } } } $db->push( $this->_table, $set, $where ); } } /** * Create an SQL string from the fields that this instance knows about for * using in queries * @param string $direction Direction: 'get' or 'set'. * @returns array Fields to include * @private */ private function _fields ( $direction ) { $fields = array(); for ( $i=0 ; $i_fields) ; $i++ ) { $field = $this->_fields[$i]; if ( $field->apply( $direction, null ) ) { if ( strpos( $field->dbField() , "." ) === false ) { $fields[] = $this->_table.'.'.$field->dbField() ." as ".$field->dbField();; } else { $fields[] = $field->dbField();// ." as ".$field->dbField(); } } } return $fields; } /** * Validate input data * * @param array $errors Errors array * @param Editor $editor Editor instance * @param string[] $data Data to validate * @param string $prefix Field error prefix for client-side to show the * error message on the appropriate field * @internal */ private function _validateFields ( &$errors, $editor, $data, $prefix ) { for ( $i=0 ; $i_fields) ; $i++ ) { $field = $this->_fields[$i]; $validation = $field->validate( $data, $editor ); if ( $validation !== true ) { $errors[] = array( "name" => $prefix.$field->name(), "status" => $validation ); } } } }