├── Composer └── ScriptHandler.php ├── Doctrine ├── DBAL │ ├── Connection.php │ └── Driver │ │ └── PDODblib │ │ ├── Connection.php │ │ └── Driver.php ├── ORM │ └── Persisters │ │ └── Entity │ │ ├── BasicEntityPersister.php │ │ └── JoinedSubclassPersister.php └── Types │ └── NTextType.php ├── Helper ├── ConnectionHelper.php └── PlatformHelper.php ├── LICENSE ├── MediaMonksMssqlBundle.php ├── PDO └── PDO.php ├── Platforms └── DblibPlatform.php ├── README.md ├── Resources └── doc │ ├── 0-requirements-nix.rst │ ├── 0-requirements-windows.rst │ └── 1-setting_up_the_bundle.rst ├── Schema └── DblibSchemaManager.php ├── Session └── Storage │ └── Handler │ └── PdoSessionHandler.php └── composer.json /Composer/ScriptHandler.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class ScriptHandler 12 | { 13 | const DOCTRINE_ORM_PACKAGE_PATH = '/doctrine/orm/lib/'; 14 | 15 | /** 16 | * @param Event $event 17 | */ 18 | public static function ensureDoctrineORMOverrides(Event $event) 19 | { 20 | $doctrineOrmPath = self::getDoctrineOrmPath($event); 21 | if (!file_exists($doctrineOrmPath)) { 22 | return; // Doctrine ORM does not seem be installed 23 | } 24 | 25 | $finder = new Finder(); 26 | $finder->files()->in($doctrineOrmPath) 27 | ->exclude([ 28 | 'Decorator', 29 | 'Event', 30 | 'Id', 31 | 'Internal', 32 | 'Proxy', 33 | 'Query', 34 | 'Tools', 35 | 'Repository', 36 | 'Tools', 37 | 'Utility' 38 | ]); 39 | 40 | $baseFind = 'use Doctrine\ORM\Persisters\Entity\\'; 41 | $baseReplace = 'use MediaMonks\MssqlBundle\Doctrine\ORM\Persisters\Entity\\'; 42 | $replaces = ['BasicEntityPersister']; 43 | 44 | foreach ($finder as $file) { 45 | $data = file_get_contents($file->getRealpath()); 46 | if (strpos($data, $baseFind) === false) { 47 | continue; 48 | } 49 | foreach ($replaces as $replace) { 50 | $data = str_replace($baseFind . $replace, $baseReplace . $replace, $data); 51 | } 52 | file_put_contents($file->getRealpath(), $data); 53 | } 54 | } 55 | 56 | /** 57 | * @param Event $event 58 | * @return string 59 | */ 60 | public static function getDoctrineOrmPath(Event $event) 61 | { 62 | return $event->getComposer()->getConfig()->get('vendor-dir') . self::DOCTRINE_ORM_PACKAGE_PATH; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Doctrine/DBAL/Connection.php: -------------------------------------------------------------------------------- 1 | exec('ROLLBACK TRANSACTION'); 16 | } 17 | 18 | /** 19 | * {@inheritdoc} 20 | */ 21 | public function commit() 22 | { 23 | $this->exec('COMMIT TRANSACTION'); 24 | } 25 | 26 | /** 27 | * {@inheritdoc} 28 | */ 29 | public function beginTransaction() 30 | { 31 | $this->exec('BEGIN TRANSACTION'); 32 | } 33 | 34 | /** 35 | * {@inheritdoc} 36 | */ 37 | public function lastInsertId($name = null) 38 | { 39 | $stmt = $this->query('SELECT SCOPE_IDENTITY()'); 40 | $id = $stmt->fetchColumn(); 41 | $stmt->closeCursor(); 42 | return $id; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Doctrine/DBAL/Driver/PDODblib/Driver.php: -------------------------------------------------------------------------------- 1 | 14 | * @author Robert Slootjes 15 | */ 16 | class Driver implements \Doctrine\DBAL\Driver 17 | { 18 | const NAME = 'pdo_dblib'; 19 | 20 | /** 21 | * Attempts to establish a connection with the underlying driver. 22 | * 23 | * @param array $params 24 | * @param string $username 25 | * @param string $password 26 | * @param array $driverOptions 27 | * @return \Doctrine\DBAL\Driver\Connection 28 | */ 29 | public function connect(array $params, $username = null, $password = null, array $driverOptions = []) 30 | { 31 | if (PlatformHelper::isWindows()) { 32 | return $this->connectWindows($params, $username, $password, $driverOptions); 33 | } 34 | return $this->connectUnix($params, $username, $password, $driverOptions); 35 | } 36 | 37 | /** 38 | * @param array $params 39 | * @param null $username 40 | * @param null $password 41 | * @param array $driverOptions 42 | * @return PDOConnection 43 | */ 44 | protected function connectWindows(array $params, $username = null, $password = null, array $driverOptions = []) 45 | { 46 | return new PDOConnection( 47 | $this->constructPdoDsnWindows($params), 48 | $username, 49 | $password, 50 | $driverOptions 51 | ); 52 | } 53 | 54 | /** 55 | * @param array $params 56 | * @param null $username 57 | * @param null $password 58 | * @param array $driverOptions 59 | * @return Connection 60 | */ 61 | protected function connectUnix(array $params, $username = null, $password = null, array $driverOptions = []) 62 | { 63 | $connection = new Connection( 64 | $this->constructPdoDsnUnix($params), 65 | $username, 66 | $password, 67 | $driverOptions 68 | ); 69 | 70 | ConnectionHelper::setConnectionOptions($connection); 71 | 72 | return $connection; 73 | } 74 | 75 | /** 76 | * Constructs the Dblib PDO DSN. 77 | * 78 | * @return string The DSN. 79 | */ 80 | public function constructPdoDsn(array $params) 81 | { 82 | if (PlatformHelper::isWindows()) { 83 | return $this->constructPdoDsnWindows($params); 84 | } 85 | return $this->constructPdoDsnUnix($params); 86 | } 87 | 88 | /** 89 | * @param array $params 90 | * @return string 91 | */ 92 | protected function constructPdoDsnWindows(array $params) 93 | { 94 | $dsn = 'sqlsrv:server='; 95 | 96 | if (isset($params['host'])) { 97 | $dsn .= $params['host']; 98 | } 99 | 100 | if (isset($params['port']) && !empty($params['port'])) { 101 | $dsn .= ',' . $params['port']; 102 | } 103 | 104 | if (isset($params['dbname'])) { 105 | $dsn .= ';Database=' . $params['dbname']; 106 | } 107 | return $dsn; 108 | } 109 | 110 | /** 111 | * @param array $params 112 | * @return string 113 | */ 114 | public function constructPdoDsnUnix(array $params) 115 | { 116 | $dsn = 'dblib:'; 117 | if (isset($params['host'])) { 118 | $dsn .= 'host=' . $params['host'] . ';'; 119 | } 120 | if (isset($params['port'])) { 121 | $dsn .= 'port=' . $params['port'] . ';'; 122 | } 123 | if (isset($params['dbname'])) { 124 | $dsn .= 'dbname=' . $params['dbname'] . ';'; 125 | } 126 | if (isset($params['charset'])) { 127 | $dsn .= 'charset=' . $params['charset'] . ';'; 128 | } 129 | 130 | return $dsn; 131 | } 132 | 133 | /** 134 | * @return DblibPlatform 135 | */ 136 | public function getDatabasePlatform() 137 | { 138 | return new DblibPlatform(); 139 | } 140 | 141 | /** 142 | * @param DoctrineConnection $connection 143 | * @return DblibSchemaManager 144 | */ 145 | public function getSchemaManager(DoctrineConnection $connection) 146 | { 147 | return new DblibSchemaManager($connection); 148 | } 149 | 150 | /** 151 | * @return string 152 | */ 153 | public function getName() 154 | { 155 | return self::NAME; 156 | } 157 | 158 | /** 159 | * @param DoctrineConnection $connection 160 | * @return mixed 161 | */ 162 | public function getDatabase(DoctrineConnection $connection) 163 | { 164 | $params = $connection->getParams(); 165 | return $params['dbname']; 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php: -------------------------------------------------------------------------------- 1 | queuedInserts) { 15 | return []; 16 | } 17 | 18 | $postInsertIds = []; 19 | $idGenerator = $this->class->idGenerator; 20 | $isPostInsertId = $idGenerator->isPostInsertGenerator(); 21 | $tableName = $this->class->getTableName(); 22 | 23 | foreach ($this->queuedInserts as $entity) { 24 | $insertData = $this->prepareInsertData($entity); 25 | 26 | $types = []; 27 | $params = []; 28 | 29 | if (isset($insertData[$tableName])) { 30 | foreach ($insertData[$tableName] as $column => $value) { 31 | $types[] = $this->columnTypes[$column]; 32 | $params[] = $value; 33 | } 34 | } 35 | 36 | $this->conn->executeUpdate($this->getInsertSQL(), $params, $types); 37 | 38 | if ($isPostInsertId) { 39 | $generatedId = $idGenerator->generate($this->em, $entity); 40 | $id = [ 41 | $this->class->identifier[0] => $generatedId 42 | ]; 43 | $postInsertIds[] = [ 44 | 'generatedId' => $generatedId, 45 | 'entity' => $entity, 46 | ]; 47 | } else { 48 | $id = $this->class->getIdentifierValues($entity); 49 | } 50 | 51 | if ($this->class->isVersioned) { 52 | $this->assignDefaultVersionValue($entity, $id); 53 | } 54 | } 55 | 56 | $this->queuedInserts = []; 57 | 58 | return $postInsertIds; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Doctrine/ORM/Persisters/Entity/JoinedSubclassPersister.php: -------------------------------------------------------------------------------- 1 | exec('SET ANSI_WARNINGS ON'); 17 | $connection->exec('SET ANSI_PADDING ON'); 18 | $connection->exec('SET ANSI_NULLS ON'); 19 | $connection->exec('SET QUOTED_IDENTIFIER ON'); 20 | $connection->exec('SET CONCAT_NULL_YIELDS_NULL ON'); 21 | } 22 | 23 | /** 24 | * @param $query 25 | * @param array $values 26 | * @return void|mixed 27 | */ 28 | public static function updateQuery($query, array $values = []) 29 | { 30 | if (PlatformHelper::isWindows()) { 31 | return $query; 32 | } 33 | 34 | for ($i = 0, $offset = 0; $pos = strpos($query, '?', $offset); $i++) { 35 | $offset = $pos + 1; 36 | if (isset($values[$i]) && is_string($values[$i])) { 37 | $query = substr_replace($query, 'N?', $pos, 1); 38 | $offset++; 39 | } 40 | } 41 | return $query; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Helper/PlatformHelper.php: -------------------------------------------------------------------------------- 1 | constructPdoDsn([ 23 | 'host' => $host, 24 | 'port' => $port, 25 | 'dbname' => $dbname 26 | ]); 27 | 28 | parent::__construct($dsn, $username, $passwd, $options); 29 | 30 | ConnectionHelper::setConnectionOptions($this); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Platforms/DblibPlatform.php: -------------------------------------------------------------------------------- 1 | 79 | tds version = 8.0 80 | client charset = UTF-8 81 | 82 | Then when you specifiy the host in your Symfony parameters.yml you will use "example" 83 | instead of the host/ip and this block with it's configuration will be used instead 84 | 85 | .. _Homebrew: http://brew.sh/ 86 | .. _PHP installed with Homebrew: https://github.com/Homebrew/homebrew-php#installation -------------------------------------------------------------------------------- /Resources/doc/0-requirements-windows.rst: -------------------------------------------------------------------------------- 1 | Step 0: Requirements Windows 2 | ============================ 3 | 4 | PHP Driver 5 | ---------- 6 | 7 | 1. Download the latest version of `Drivers for PHP for SQL Server`_ from the Microsoft website. 8 | 2. Extract the drivers to a folder where you can find them again 9 | 3. Copy both dlls (``php_pdo_sqlsrv_*_*.dll`` & and ``php_sqlsrv_*_*.dll``) for your php version and thread safety setting to the extension dir of your php installation 10 | 4. Enable the drivers in your php.ini: 11 | - extension=php_pdo_sqlsrv_*_*.dll 12 | - extension=php_pdo_sqlsrv_*_*.dll 13 | 5. Restart your webserver 14 | 15 | .. _Drivers for PHP for SQL Server: https://www.microsoft.com/en-us/download/details.aspx?id=20098 16 | -------------------------------------------------------------------------------- /Resources/doc/1-setting_up_the_bundle.rst: -------------------------------------------------------------------------------- 1 | Step 1: Setting up the bundle 2 | ============================= 3 | 4 | A) Download the Bundle 5 | ---------------------- 6 | 7 | Open a command console, enter your project directory and execute the 8 | following command to download the latest stable version of this bundle: 9 | 10 | .. code-block:: bash 11 | 12 | $ composer require mediamonks/mssql-bundle ~1.0 13 | 14 | This command requires you to have Composer installed globally, as explained 15 | in the `installation chapter`_ of the Composer documentation. 16 | 17 | B) Enable the Bundle 18 | -------------------- 19 | 20 | Then, enable the bundle by adding it to the list of registered bundles 21 | in the ``app/AppKernel.php`` file of your project: 22 | 23 | .. code-block:: php 24 | 25 | _conn->options['database_device']) { 18 | $query .= ' ON ' . $this->_conn->options['database_device']; 19 | $query .= $this->_conn->options['database_size'] ? '=' . 20 | $this->_conn->options['database_size'] : ''; 21 | } 22 | return $this->_conn->standaloneQuery($query, null, true); 23 | } 24 | 25 | /** 26 | * lists all database sequences 27 | * 28 | * @param string|null $database 29 | * @return array 30 | */ 31 | public function listSequences($database = null) 32 | { 33 | $query = "SELECT name FROM sysobjects WHERE xtype = 'U'"; 34 | $tableNames = $this->_conn->fetchAll($query); 35 | 36 | return array_map([$this->_conn->formatter, 'fixSequenceName'], $tableNames); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Session/Storage/Handler/PdoSessionHandler.php: -------------------------------------------------------------------------------- 1 | 39 | * @author Michael Williams 40 | * @author Tobias Schultze 41 | */ 42 | class PdoSessionHandler implements \SessionHandlerInterface 43 | { 44 | /** 45 | * No locking is done. This means sessions are prone to loss of data due to 46 | * race conditions of concurrent requests to the same session. The last session 47 | * write will win in this case. It might be useful when you implement your own 48 | * logic to deal with this like an optimistic approach. 49 | */ 50 | const LOCK_NONE = 0; 51 | 52 | /** 53 | * Creates an application-level lock on a session. The disadvantage is that the 54 | * lock is not enforced by the database and thus other, unaware parts of the 55 | * application could still concurrently modify the session. The advantage is it 56 | * does not require a transaction. 57 | * This mode is not available for SQLite and not yet implemented for oci and sqlsrv. 58 | */ 59 | const LOCK_ADVISORY = 1; 60 | 61 | /** 62 | * Issues a real row lock. Since it uses a transaction between opening and 63 | * closing a session, you have to be careful when you use same database connection 64 | * that you also use for your application logic. This mode is the default because 65 | * it's the only reliable solution across DBMSs. 66 | */ 67 | const LOCK_TRANSACTIONAL = 2; 68 | 69 | /** 70 | * @var \PDO|null PDO instance or null when not connected yet 71 | */ 72 | private $pdo; 73 | 74 | /** 75 | * @var string|null|false DSN string or null for session.save_path or false when lazy connection disabled 76 | */ 77 | private $dsn = false; 78 | 79 | /** 80 | * @var string Database driver 81 | */ 82 | private $driver; 83 | 84 | /** 85 | * @var string Table name 86 | */ 87 | private $table = 'sessions'; 88 | 89 | /** 90 | * @var string Column for session id 91 | */ 92 | private $idCol = 'sess_id'; 93 | 94 | /** 95 | * @var string Column for session data 96 | */ 97 | private $dataCol = 'sess_data'; 98 | 99 | /** 100 | * @var string Column for lifetime 101 | */ 102 | private $lifetimeCol = 'sess_lifetime'; 103 | 104 | /** 105 | * @var string Column for timestamp 106 | */ 107 | private $timeCol = 'sess_time'; 108 | 109 | /** 110 | * @var string Username when lazy-connect 111 | */ 112 | private $username = ''; 113 | 114 | /** 115 | * @var string Password when lazy-connect 116 | */ 117 | private $password = ''; 118 | 119 | /** 120 | * @var array Connection options when lazy-connect 121 | */ 122 | private $connectionOptions = array(); 123 | 124 | /** 125 | * @var int The strategy for locking, see constants 126 | */ 127 | private $lockMode = self::LOCK_TRANSACTIONAL; 128 | 129 | /** 130 | * It's an array to support multiple reads before closing which is manual, non-standard usage. 131 | * 132 | * @var \PDOStatement[] An array of statements to release advisory locks 133 | */ 134 | private $unlockStatements = array(); 135 | 136 | /** 137 | * @var bool True when the current session exists but expired according to session.gc_maxlifetime 138 | */ 139 | private $sessionExpired = false; 140 | 141 | /** 142 | * @var bool Whether a transaction is active 143 | */ 144 | private $inTransaction = false; 145 | 146 | /** 147 | * @var bool Whether gc() has been called 148 | */ 149 | private $gcCalled = false; 150 | 151 | /** 152 | * Constructor. 153 | * 154 | * You can either pass an existing database connection as PDO instance or 155 | * pass a DSN string that will be used to lazy-connect to the database 156 | * when the session is actually used. Furthermore it's possible to pass null 157 | * which will then use the session.save_path ini setting as PDO DSN parameter. 158 | * 159 | * List of available options: 160 | * * db_table: The name of the table [default: sessions] 161 | * * db_id_col: The column where to store the session id [default: sess_id] 162 | * * db_data_col: The column where to store the session data [default: sess_data] 163 | * * db_lifetime_col: The column where to store the lifetime [default: sess_lifetime] 164 | * * db_time_col: The column where to store the timestamp [default: sess_time] 165 | * * db_username: The username when lazy-connect [default: ''] 166 | * * db_password: The password when lazy-connect [default: ''] 167 | * * db_connection_options: An array of driver-specific connection options [default: array()] 168 | * * lock_mode: The strategy for locking, see constants [default: LOCK_TRANSACTIONAL] 169 | * 170 | * @param \PDO|string|null $pdoOrDsn A \PDO instance or DSN string or null 171 | * @param array $options An associative array of options 172 | * 173 | * @throws \InvalidArgumentException When PDO error mode is not PDO::ERRMODE_EXCEPTION 174 | */ 175 | public function __construct($pdoOrDsn = null, array $options = array()) 176 | { 177 | if ($pdoOrDsn instanceof \PDO) { 178 | if (\PDO::ERRMODE_EXCEPTION !== $pdoOrDsn->getAttribute(\PDO::ATTR_ERRMODE)) { 179 | throw new \InvalidArgumentException(sprintf('"%s" requires PDO error mode attribute be set to throw Exceptions (i.e. $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION))', __CLASS__)); 180 | } 181 | 182 | $this->pdo = $pdoOrDsn; 183 | $this->driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME); 184 | } else { 185 | $this->dsn = $pdoOrDsn; 186 | } 187 | 188 | $this->table = isset($options['db_table']) ? $options['db_table'] : $this->table; 189 | $this->idCol = isset($options['db_id_col']) ? $options['db_id_col'] : $this->idCol; 190 | $this->dataCol = isset($options['db_data_col']) ? $options['db_data_col'] : $this->dataCol; 191 | $this->lifetimeCol = isset($options['db_lifetime_col']) ? $options['db_lifetime_col'] : $this->lifetimeCol; 192 | $this->timeCol = isset($options['db_time_col']) ? $options['db_time_col'] : $this->timeCol; 193 | $this->username = isset($options['db_username']) ? $options['db_username'] : $this->username; 194 | $this->password = isset($options['db_password']) ? $options['db_password'] : $this->password; 195 | $this->connectionOptions = isset($options['db_connection_options']) ? $options['db_connection_options'] : $this->connectionOptions; 196 | $this->lockMode = isset($options['lock_mode']) ? $options['lock_mode'] : $this->lockMode; 197 | 198 | if(!PlatformHelper::isWindows()) { 199 | $this->lockMode = self::LOCK_NONE; 200 | } 201 | } 202 | 203 | /** 204 | * Creates the table to store sessions which can be called once for setup. 205 | * 206 | * Session ID is saved in a column of maximum length 128 because that is enough even 207 | * for a 512 bit configured session.hash_function like Whirlpool. Session data is 208 | * saved in a BLOB. One could also use a shorter inlined varbinary column 209 | * if one was sure the data fits into it. 210 | * 211 | * @throws \PDOException When the table already exists 212 | * @throws \DomainException When an unsupported PDO driver is used 213 | */ 214 | public function createTable() 215 | { 216 | // connect if we are not yet 217 | $this->getConnection(); 218 | 219 | switch ($this->driver) { 220 | case 'mysql': 221 | // We use varbinary for the ID column because it prevents unwanted conversions: 222 | // - character set conversions between server and client 223 | // - trailing space removal 224 | // - case-insensitivity 225 | // - language processing like é == e 226 | $sql = "CREATE TABLE $this->table ($this->idCol VARBINARY(128) NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol MEDIUMINT NOT NULL, $this->timeCol INTEGER UNSIGNED NOT NULL) COLLATE utf8_bin, ENGINE = InnoDB"; 227 | break; 228 | case 'sqlite': 229 | $sql = "CREATE TABLE $this->table ($this->idCol TEXT NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol INTEGER NOT NULL, $this->timeCol INTEGER NOT NULL)"; 230 | break; 231 | case 'pgsql': 232 | $sql = "CREATE TABLE $this->table ($this->idCol VARCHAR(128) NOT NULL PRIMARY KEY, $this->dataCol BYTEA NOT NULL, $this->lifetimeCol INTEGER NOT NULL, $this->timeCol INTEGER NOT NULL)"; 233 | break; 234 | case 'oci': 235 | $sql = "CREATE TABLE $this->table ($this->idCol VARCHAR2(128) NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol INTEGER NOT NULL, $this->timeCol INTEGER NOT NULL)"; 236 | break; 237 | case 'sqlsrv': 238 | $sql = "CREATE TABLE $this->table ($this->idCol VARCHAR(128) NOT NULL PRIMARY KEY, $this->dataCol VARBINARY(MAX) NOT NULL, $this->lifetimeCol INTEGER NOT NULL, $this->timeCol INTEGER NOT NULL)"; 239 | break; 240 | default: 241 | throw new \DomainException(sprintf('Creating the session table is currently not implemented for PDO driver "%s".', $this->driver)); 242 | } 243 | 244 | try { 245 | $this->pdo->exec($sql); 246 | } catch (\PDOException $e) { 247 | $this->rollback(); 248 | 249 | throw $e; 250 | } 251 | } 252 | 253 | /** 254 | * Returns true when the current session exists but expired according to session.gc_maxlifetime. 255 | * 256 | * Can be used to distinguish between a new session and one that expired due to inactivity. 257 | * 258 | * @return bool Whether current session expired 259 | */ 260 | public function isSessionExpired() 261 | { 262 | return $this->sessionExpired; 263 | } 264 | 265 | /** 266 | * {@inheritdoc} 267 | */ 268 | public function open($savePath, $sessionName) 269 | { 270 | if (null === $this->pdo) { 271 | $this->connect($this->dsn ?: $savePath); 272 | } 273 | 274 | return true; 275 | } 276 | 277 | /** 278 | * {@inheritdoc} 279 | */ 280 | public function read($sessionId) 281 | { 282 | try { 283 | return base64_decode($this->doRead($sessionId)); 284 | } catch (\PDOException $e) { 285 | $this->rollback(); 286 | 287 | throw $e; 288 | } 289 | } 290 | 291 | /** 292 | * {@inheritdoc} 293 | */ 294 | public function gc($maxlifetime) 295 | { 296 | // We delay gc() to close() so that it is executed outside the transactional and blocking read-write process. 297 | // This way, pruning expired sessions does not block them from being started while the current session is used. 298 | $this->gcCalled = true; 299 | 300 | return true; 301 | } 302 | 303 | /** 304 | * {@inheritdoc} 305 | */ 306 | public function destroy($sessionId) 307 | { 308 | // delete the record associated with this id 309 | $sql = "DELETE FROM $this->table WHERE $this->idCol = :id"; 310 | 311 | try { 312 | $stmt = $this->pdo->prepare($sql); 313 | $stmt->bindParam(':id', $sessionId, \PDO::PARAM_STR); 314 | $stmt->execute(); 315 | } catch (\PDOException $e) { 316 | $this->rollback(); 317 | 318 | throw $e; 319 | } 320 | 321 | return true; 322 | } 323 | 324 | /** 325 | * {@inheritdoc} 326 | */ 327 | public function write($sessionId, $data) 328 | { 329 | $data = base64_encode($data); 330 | 331 | $maxlifetime = (int) ini_get('session.gc_maxlifetime'); 332 | 333 | try { 334 | // We use a single MERGE SQL query when supported by the database. 335 | $mergeSql = $this->getMergeSql(); 336 | 337 | if (null !== $mergeSql) { 338 | $mergeStmt = $this->pdo->prepare($mergeSql); 339 | if ('sqlsrv' === $this->driver || 'oci' === $this->driver) { 340 | $mergeStmt->bindParam(1, $sessionId, \PDO::PARAM_STR); 341 | $mergeStmt->bindParam(2, $sessionId, \PDO::PARAM_STR); 342 | $mergeStmt->bindParam(3, $data, \PDO::PARAM_LOB); 343 | $mergeStmt->bindParam(4, $maxlifetime, \PDO::PARAM_INT); 344 | $mergeStmt->bindValue(5, time(), \PDO::PARAM_INT); 345 | $mergeStmt->bindParam(6, $data, \PDO::PARAM_LOB); 346 | $mergeStmt->bindParam(7, $maxlifetime, \PDO::PARAM_INT); 347 | $mergeStmt->bindValue(8, time(), \PDO::PARAM_INT); 348 | } 349 | else { 350 | $mergeStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR); 351 | $mergeStmt->bindParam(':data', $data, \PDO::PARAM_LOB); 352 | $mergeStmt->bindParam(':lifetime', $maxlifetime, \PDO::PARAM_INT); 353 | $mergeStmt->bindValue(':time', time(), \PDO::PARAM_INT); 354 | } 355 | $mergeStmt->execute(); 356 | return true; 357 | } 358 | 359 | $updateStmt = $this->pdo->prepare( 360 | "UPDATE $this->table SET $this->dataCol = :data, $this->lifetimeCol = :lifetime, $this->timeCol = :time WHERE $this->idCol = :id" 361 | ); 362 | $updateStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR); 363 | $updateStmt->bindParam(':data', $data, \PDO::PARAM_LOB); 364 | $updateStmt->bindParam(':lifetime', $maxlifetime, \PDO::PARAM_INT); 365 | $updateStmt->bindValue(':time', time(), \PDO::PARAM_INT); 366 | $updateStmt->execute(); 367 | 368 | // When MERGE is not supported, like in Postgres, we have to use this approach that can result in 369 | // duplicate key errors when the same session is written simultaneously (given the LOCK_NONE behavior). 370 | // We can just catch such an error and re-execute the update. This is similar to a serializable 371 | // transaction with retry logic on serialization failures but without the overhead and without possible 372 | // false positives due to longer gap locking. 373 | if (!$updateStmt->rowCount()) { 374 | try { 375 | $insertStmt = $this->pdo->prepare( 376 | "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time)" 377 | ); 378 | $insertStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR); 379 | $insertStmt->bindParam(':data', $data, \PDO::PARAM_LOB); 380 | $insertStmt->bindParam(':lifetime', $maxlifetime, \PDO::PARAM_INT); 381 | $insertStmt->bindValue(':time', time(), \PDO::PARAM_INT); 382 | $insertStmt->execute(); 383 | } catch (\PDOException $e) { 384 | // Handle integrity violation SQLSTATE 23000 (or a subclass like 23505 in Postgres) for duplicate keys 385 | if (0 === strpos($e->getCode(), '23')) { 386 | $updateStmt->execute(); 387 | } else { 388 | throw $e; 389 | } 390 | } 391 | } 392 | } catch (\PDOException $e) { 393 | $this->rollback(); 394 | 395 | throw $e; 396 | } 397 | 398 | return true; 399 | } 400 | 401 | /** 402 | * {@inheritdoc} 403 | */ 404 | public function close() 405 | { 406 | $this->commit(); 407 | 408 | while ($unlockStmt = array_shift($this->unlockStatements)) { 409 | $unlockStmt->execute(); 410 | } 411 | 412 | if ($this->gcCalled) { 413 | $this->gcCalled = false; 414 | 415 | // delete the session records that have expired 416 | $sql = "DELETE FROM $this->table WHERE $this->lifetimeCol + $this->timeCol < :time"; 417 | 418 | $stmt = $this->pdo->prepare($sql); 419 | $stmt->bindValue(':time', time(), \PDO::PARAM_INT); 420 | $stmt->execute(); 421 | } 422 | 423 | if (false !== $this->dsn) { 424 | $this->pdo = null; // only close lazy-connection 425 | } 426 | 427 | return true; 428 | } 429 | 430 | /** 431 | * Lazy-connects to the database. 432 | * 433 | * @param string $dsn DSN string 434 | */ 435 | private function connect($dsn) 436 | { 437 | $this->pdo = new \PDO($dsn, $this->username, $this->password, $this->connectionOptions); 438 | $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); 439 | $this->driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME); 440 | } 441 | 442 | /** 443 | * Helper method to begin a transaction. 444 | * 445 | * Since SQLite does not support row level locks, we have to acquire a reserved lock 446 | * on the database immediately. Because of https://bugs.php.net/42766 we have to create 447 | * such a transaction manually which also means we cannot use PDO::commit or 448 | * PDO::rollback or PDO::inTransaction for SQLite. 449 | * 450 | * Also MySQLs default isolation, REPEATABLE READ, causes deadlock for different sessions 451 | * due to http://www.mysqlperformanceblog.com/2013/12/12/one-more-innodb-gap-lock-to-avoid/ . 452 | * So we change it to READ COMMITTED. 453 | */ 454 | private function beginTransaction() 455 | { 456 | if (!$this->inTransaction) { 457 | if ('sqlite' === $this->driver) { 458 | $this->pdo->exec('BEGIN IMMEDIATE TRANSACTION'); 459 | } else { 460 | if ('mysql' === $this->driver) { 461 | $this->pdo->exec('SET TRANSACTION ISOLATION LEVEL READ COMMITTED'); 462 | } 463 | $this->pdo->beginTransaction(); 464 | } 465 | $this->inTransaction = true; 466 | } 467 | } 468 | 469 | /** 470 | * Helper method to commit a transaction. 471 | */ 472 | private function commit() 473 | { 474 | if ($this->inTransaction) { 475 | try { 476 | // commit read-write transaction which also releases the lock 477 | if ('sqlite' === $this->driver) { 478 | $this->pdo->exec('COMMIT'); 479 | } else { 480 | $this->pdo->commit(); 481 | } 482 | $this->inTransaction = false; 483 | } catch (\PDOException $e) { 484 | $this->rollback(); 485 | 486 | throw $e; 487 | } 488 | } 489 | } 490 | 491 | /** 492 | * Helper method to rollback a transaction. 493 | */ 494 | private function rollback() 495 | { 496 | // We only need to rollback if we are in a transaction. Otherwise the resulting 497 | // error would hide the real problem why rollback was called. We might not be 498 | // in a transaction when not using the transactional locking behavior or when 499 | // two callbacks (e.g. destroy and write) are invoked that both fail. 500 | if ($this->inTransaction) { 501 | if ('sqlite' === $this->driver) { 502 | $this->pdo->exec('ROLLBACK'); 503 | } else { 504 | $this->pdo->rollBack(); 505 | } 506 | $this->inTransaction = false; 507 | } 508 | } 509 | 510 | /** 511 | * Reads the session data in respect to the different locking strategies. 512 | * 513 | * We need to make sure we do not return session data that is already considered garbage according 514 | * to the session.gc_maxlifetime setting because gc() is called after read() and only sometimes. 515 | * 516 | * @param string $sessionId Session ID 517 | * 518 | * @return string The session data 519 | */ 520 | private function doRead($sessionId) 521 | { 522 | $this->sessionExpired = false; 523 | 524 | if (self::LOCK_ADVISORY === $this->lockMode) { 525 | $this->unlockStatements[] = $this->doAdvisoryLock($sessionId); 526 | } 527 | 528 | $selectSql = $this->getSelectSql(); 529 | $selectStmt = $this->pdo->prepare($selectSql); 530 | $selectStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR); 531 | $selectStmt->execute(); 532 | 533 | $sessionRows = $selectStmt->fetchAll(\PDO::FETCH_NUM); 534 | 535 | if ($sessionRows) { 536 | if ($sessionRows[0][1] + $sessionRows[0][2] < time()) { 537 | $this->sessionExpired = true; 538 | 539 | return ''; 540 | } 541 | 542 | return is_resource($sessionRows[0][0]) ? stream_get_contents($sessionRows[0][0]) : $sessionRows[0][0]; 543 | } 544 | 545 | if (self::LOCK_TRANSACTIONAL === $this->lockMode && 'sqlite' !== $this->driver) { 546 | // Exclusive-reading of non-existent rows does not block, so we need to do an insert to block 547 | // until other connections to the session are committed. 548 | try { 549 | $insertStmt = $this->pdo->prepare( 550 | "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time)" 551 | ); 552 | $insertStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR); 553 | $insertStmt->bindValue(':data', '', \PDO::PARAM_LOB); 554 | $insertStmt->bindValue(':lifetime', 0, \PDO::PARAM_INT); 555 | $insertStmt->bindValue(':time', time(), \PDO::PARAM_INT); 556 | $insertStmt->execute(); 557 | } catch (\PDOException $e) { 558 | // Catch duplicate key error because other connection created the session already. 559 | // It would only not be the case when the other connection destroyed the session. 560 | if (0 === strpos($e->getCode(), '23')) { 561 | // Retrieve finished session data written by concurrent connection. SELECT 562 | // FOR UPDATE is necessary to avoid deadlock of connection that starts reading 563 | // before we write (transform intention to real lock). 564 | $selectStmt->execute(); 565 | $sessionRows = $selectStmt->fetchAll(\PDO::FETCH_NUM); 566 | 567 | if ($sessionRows) { 568 | return is_resource($sessionRows[0][0]) ? stream_get_contents($sessionRows[0][0]) : $sessionRows[0][0]; 569 | } 570 | 571 | return ''; 572 | } 573 | 574 | throw $e; 575 | } 576 | } 577 | 578 | return ''; 579 | } 580 | 581 | /** 582 | * Executes an application-level lock on the database. 583 | * 584 | * @param string $sessionId Session ID 585 | * 586 | * @return \PDOStatement The statement that needs to be executed later to release the lock 587 | * 588 | * @throws \DomainException When an unsupported PDO driver is used 589 | * 590 | * @todo implement missing advisory locks 591 | * - for oci using DBMS_LOCK.REQUEST 592 | * - for sqlsrv using sp_getapplock with LockOwner = Session 593 | */ 594 | private function doAdvisoryLock($sessionId) 595 | { 596 | switch ($this->driver) { 597 | case 'mysql': 598 | // should we handle the return value? 0 on timeout, null on error 599 | // we use a timeout of 50 seconds which is also the default for innodb_lock_wait_timeout 600 | $stmt = $this->pdo->prepare('SELECT GET_LOCK(:key, 50)'); 601 | $stmt->bindValue(':key', $sessionId, \PDO::PARAM_STR); 602 | $stmt->execute(); 603 | 604 | $releaseStmt = $this->pdo->prepare('DO RELEASE_LOCK(:key)'); 605 | $releaseStmt->bindValue(':key', $sessionId, \PDO::PARAM_STR); 606 | 607 | return $releaseStmt; 608 | case 'pgsql': 609 | // Obtaining an exclusive session level advisory lock requires an integer key. 610 | // So we convert the HEX representation of the session id to an integer. 611 | // Since integers are signed, we have to skip one hex char to fit in the range. 612 | if (4 === PHP_INT_SIZE) { 613 | $sessionInt1 = hexdec(substr($sessionId, 0, 7)); 614 | $sessionInt2 = hexdec(substr($sessionId, 7, 7)); 615 | 616 | $stmt = $this->pdo->prepare('SELECT pg_advisory_lock(:key1, :key2)'); 617 | $stmt->bindValue(':key1', $sessionInt1, \PDO::PARAM_INT); 618 | $stmt->bindValue(':key2', $sessionInt2, \PDO::PARAM_INT); 619 | $stmt->execute(); 620 | 621 | $releaseStmt = $this->pdo->prepare('SELECT pg_advisory_unlock(:key1, :key2)'); 622 | $releaseStmt->bindValue(':key1', $sessionInt1, \PDO::PARAM_INT); 623 | $releaseStmt->bindValue(':key2', $sessionInt2, \PDO::PARAM_INT); 624 | } else { 625 | $sessionBigInt = hexdec(substr($sessionId, 0, 15)); 626 | 627 | $stmt = $this->pdo->prepare('SELECT pg_advisory_lock(:key)'); 628 | $stmt->bindValue(':key', $sessionBigInt, \PDO::PARAM_INT); 629 | $stmt->execute(); 630 | 631 | $releaseStmt = $this->pdo->prepare('SELECT pg_advisory_unlock(:key)'); 632 | $releaseStmt->bindValue(':key', $sessionBigInt, \PDO::PARAM_INT); 633 | } 634 | 635 | return $releaseStmt; 636 | case 'sqlite': 637 | throw new \DomainException('SQLite does not support advisory locks.'); 638 | default: 639 | throw new \DomainException(sprintf('Advisory locks are currently not implemented for PDO driver "%s".', $this->driver)); 640 | } 641 | } 642 | 643 | /** 644 | * Return a locking or nonlocking SQL query to read session information. 645 | * 646 | * @return string The SQL string 647 | * 648 | * @throws \DomainException When an unsupported PDO driver is used 649 | */ 650 | private function getSelectSql() 651 | { 652 | if (self::LOCK_TRANSACTIONAL === $this->lockMode) { 653 | $this->beginTransaction(); 654 | 655 | switch ($this->driver) { 656 | case 'mysql': 657 | case 'oci': 658 | case 'pgsql': 659 | return "SELECT $this->dataCol, $this->lifetimeCol, $this->timeCol FROM $this->table WHERE $this->idCol = :id FOR UPDATE"; 660 | case 'sqlsrv': 661 | return "SELECT $this->dataCol, $this->lifetimeCol, $this->timeCol FROM $this->table WITH (UPDLOCK, ROWLOCK) WHERE $this->idCol = :id"; 662 | case 'sqlite': 663 | // we already locked when starting transaction 664 | break; 665 | default: 666 | throw new \DomainException(sprintf('Transactional locks are currently not implemented for PDO driver "%s".', $this->driver)); 667 | } 668 | } 669 | 670 | return "SELECT $this->dataCol, $this->lifetimeCol, $this->timeCol FROM $this->table WHERE $this->idCol = :id"; 671 | } 672 | 673 | /** 674 | * Returns a merge/upsert (i.e. insert or update) SQL query when supported by the database for writing session data. 675 | * 676 | * @return string|null The SQL string or null when not supported 677 | */ 678 | private function getMergeSql() 679 | { 680 | switch ($this->driver) { 681 | case 'mysql': 682 | return "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time) ". 683 | "ON DUPLICATE KEY UPDATE $this->dataCol = VALUES($this->dataCol), $this->lifetimeCol = VALUES($this->lifetimeCol), $this->timeCol = VALUES($this->timeCol)"; 684 | case 'oci': 685 | // DUAL is Oracle specific dummy table 686 | return "MERGE INTO $this->table USING DUAL ON ($this->idCol = ?) ". 687 | "WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (?, ?, ?, ?) ". 688 | "WHEN MATCHED THEN UPDATE SET $this->dataCol = ?, $this->lifetimeCol = ?, $this->timeCol = ?"; 689 | case 'sqlsrv' === $this->driver && version_compare($this->pdo->getAttribute(\PDO::ATTR_SERVER_VERSION), '10', '>='): 690 | // MERGE is only available since SQL Server 2008 and must be terminated by semicolon 691 | // It also requires HOLDLOCK according to http://weblogs.sqlteam.com/dang/archive/2009/01/31/UPSERT-Race-Condition-With-MERGE.aspx 692 | return "MERGE INTO $this->table WITH (HOLDLOCK) USING (SELECT 1 AS dummy) AS src ON ($this->idCol = ?) ". 693 | "WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (?, ?, ?, ?) ". 694 | "WHEN MATCHED THEN UPDATE SET $this->dataCol = ?, $this->lifetimeCol = ?, $this->timeCol = ?;"; 695 | case 'sqlite': 696 | return "INSERT OR REPLACE INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time)"; 697 | } 698 | } 699 | 700 | /** 701 | * Return a PDO instance. 702 | * 703 | * @return \PDO 704 | */ 705 | protected function getConnection() 706 | { 707 | if (null === $this->pdo) { 708 | $this->connect($this->dsn ?: ini_get('session.save_path')); 709 | } 710 | 711 | return $this->pdo; 712 | } 713 | } 714 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mediamonks/mssql-bundle", 3 | "type": "symfony-bundle", 4 | "description": "Symfony Bundle for working with Microsoft SQL Server on *nix (dblib) and Windows (sqlsrv) with UTF-8 and transactions support", 5 | "keywords": [ 6 | "mssql", 7 | "dblib", 8 | "sqlsrv", 9 | "symfony", 10 | "bundle", 11 | "freetds", 12 | "pdo_dblib", 13 | "pdo_sqlsrv", 14 | "sqlserver", 15 | "utf8", 16 | "utf-8", 17 | "unicode" 18 | ], 19 | "homepage": "https://www.mediamonks.com/", 20 | "license": "MIT", 21 | "authors": [ 22 | { 23 | "name": "Robert Slootjes", 24 | "email": "robert@mediamonks.com", 25 | "homepage": "https://github.com/slootjes" 26 | }, 27 | { 28 | "name": "Arjen Warendorff", 29 | "email": "arjen@mediamonks.com" 30 | }, 31 | { 32 | "name": "Michal Kristin", 33 | "email": "michal@mediamonks.com" 34 | }, 35 | { 36 | "name": "Realestate.co.nz", 37 | "homepage": "http://www.realestate.co.nz" 38 | }, 39 | { 40 | "name": "Ken Golovin", 41 | "homepage": "https://github.com/pasinter" 42 | } 43 | ], 44 | "require": { 45 | "php": "^5.4|~7.0" 46 | }, 47 | "suggest": { 48 | "symfony/framework-bundle": "For when using this in Symfony" 49 | }, 50 | "autoload": { 51 | "psr-4": { 52 | "MediaMonks\\MssqlBundle\\": "" 53 | } 54 | }, 55 | "extra": { 56 | "branch-alias": { 57 | "dev-master": "1.0-dev" 58 | } 59 | } 60 | } 61 | --------------------------------------------------------------------------------