Skip to content

Commit

Permalink
Merge branch 'feature/dynamic'
Browse files Browse the repository at this point in the history
  • Loading branch information
cambell-prince committed Dec 16, 2018
2 parents cf3547b + f33724b commit 33d0d29
Show file tree
Hide file tree
Showing 7 changed files with 430 additions and 6 deletions.
2 changes: 1 addition & 1 deletion bin/anorm.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<?php
namespace Anorm\Tools;

define('VERSION', '0.2.3');
define('VERSION', '0.9.0');

// Try 3rd party install relative to bin folder
if (\file_exists(__DIR__ . '/../../../autoload.php')) {
Expand Down
56 changes: 56 additions & 0 deletions src/Anorm.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php
namespace Anorm;

class Anorm
{

private static $connections = array();

/**
* @var callable Function that returns a SQL type definition for creating a column
* function($fieldName, $sampleData) {
* return TableMaker::columnDefinition($fieldName, $sampleData);
* };
*/
public static $columnFn = '\Anorm\TableMaker::columnDefinition';

/**
* Creates a new Anorm connection named $name connected to $dsn.
* @param string $name Name of this connection for later use.
* @param string $dsn PDO DSN string to establish the connection.
* @return Anorm
* @see connect
*/
public static function connect($name, $dsn, $user, $password)
{
if (!\array_key_exists($name, self::$connections)) {
self::$connections[$name] = new Anorm($dsn, $user, $password);
}
return self::$connections[$name];
}

/**
* Returns the Anorm connection of the given $name.
* Note that the connection must have been previously opened
* with a call to connect.
* @param string $name Name of the connection to use.
* @return Anorm
* @see connect
*/
public static function use($name)
{
if (!\array_key_exists($name, self::$connections)) {
throw new \Exception("Anorm: Connection '$name' doesn't exist. Call Anorm::connection first.");
}
return self::$connections[$name];
}

/** @var PDO The connection */
public $pdo;

private function __construct($dsn, $user, $password)
{
$this->pdo = new \PDO($dsn, $user, $password);
$this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
}
}
45 changes: 40 additions & 5 deletions src/DataMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@

class DataMapper
{

const MODE_DYNAMIC = 'dynamic';
const MODE_STATIC = 'static';

public $mode = self::MODE_STATIC;

/** @var \PDO */
public $pdo;

Expand Down Expand Up @@ -111,13 +117,17 @@ public function write(&$c)
}
if ($c->$key === null) {
$sql = 'INSERT INTO `' . $this->table . '` SET ' . $set;
$result = $this->pdo->query($sql);
$c->$key = $this->pdo->lastInsertId();
$this->dynamicWrapper(function () use ($sql, $c, $key) {
$result = $this->pdo->query($sql);
$c->$key = $this->pdo->lastInsertId();
}, $c);
} else {
$keyField = $this->map[$key];
$id = $c->$key;
$sql = 'UPDATE `' . $this->table . '` SET ' . $set . ' WHERE ' . $keyField . "='" . $id . "'";
$this->pdo->query($sql);
$this->dynamicWrapper(function () use ($sql) {
$this->pdo->query($sql);
}, $c);
}
return $c->$key;
}
Expand All @@ -127,17 +137,42 @@ public function read(&$c, $id)
$databasePrimaryKey = $this->map[$this->modelPrimaryKey];
// TODO Could make the '*' explicit from the map
$sql = 'SELECT * FROM `' . $this->table . '` WHERE ' . $databasePrimaryKey . "='" . $id . "'";
$result = $this->query($sql);
$result = $this->dynamicWrapper(function () use ($sql) {
return $this->pdo->query($sql);
}, $c);
return $this->readRow($c, $result);
}

private function dynamicWrapper(callable $fn, $model = null)
{
$lastException = '';
for ($strike = 0; $strike < 10; ++$strike) {
try {
return $fn();
} catch (\PDOException $e) {
if ($this->mode !== self::MODE_DYNAMIC) {
throw $e;
}
TableMaker::fix($e, $this, $model);
if ($e->getMessage() == $lastException) {
// Same exception twice in a row so throw.
throw $e; // @codeCoverageIgnore
}
$lastException = $e->getMessage();
}
}
throw new \Exception("$strike strikes in DataMapper."); // @codeCoverageIgnore
}

/**
* @var $sql string SQL query
* @return \PDOStatement
*/
public function query($sql)
{
return $this->pdo->query($sql);
return $this->dynamicWrapper(function () use ($sql) {
return $this->pdo->query($sql);
});
}

/**
Expand Down
109 changes: 109 additions & 0 deletions src/TableMaker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<?php
namespace Anorm;

class TableMaker
{

public static function fix(\Exception $exception, DataMapper $mapper, $model = null)
{
// TODO We could create this from an Anorm factory / container
$maker = new TableMaker($exception, $mapper, $model);
return $maker->_fix();
}

/** @var \PDOException The exception that requires the database schema to be fixed. */
public $exception;

/** @var DataMapper The DataMapper */
private $mapper;

/** @var Model An optional model instance */
private $model;

public function __construct(\Exception $exception, DataMapper $mapper, $model)
{
$this->exception = $exception;
$this->mapper = $mapper;
$this->model = $model;
}

private function _fix()
{
switch ($this->exception->getCode()) {
case '42S02': // table not found
$this->createTable();
break;
case '42S22': // column not found
$this->createColumn();
break;
}
}

private function createTable()
{
// Regex the message to get the name of the table
$matches = array();
if (!\preg_match("/'([^\.']*)\.([^\.']*)'/", $this->exception->getMessage(), $matches)) {
throw new \Exception('Anorm: Could not parse PDOException', 0, $this->exception);
}
$tableName = $matches[2];
// Create the table with an auto increment id as primary key.
// Review: Should we also try and create all the columns we can now,
// or wait until possibly later when we might have better data
// to hint the type?
// Current design choice is to wait until later even if it means
// a highly iterative, multiple exception approach on the common
// first write case.
$sql = "CREATE TABLE $tableName(
id INT(11) AUTO_INCREMENT PRIMARY KEY
)";
$this->mapper->pdo->query($sql);
}

private function createColumn()
{
// Regex the message to get the name of the table
$matches = array();
if (!\preg_match("/column '([^\.']*)'/", $this->exception->getMessage(), $matches)) {
throw new \Exception('Anorm: Could not parse PDOException', 0, $this->exception);
}
$columnName = $matches[1];
// Add the column.
// TODO Have a go at figuring out the type if the model is available.
$sampleData = null;
if ($this->model) {
// See if we can reverse map the
$invertMap = array_flip($this->mapper->map);
$property = $invertMap[$columnName];
$sampleData = $this->model->$property;
}
$columnFn = Anorm::$columnFn; // Redundant, but can't do this Anorm::$columnFn(...)
$columnDefinition = $columnFn($columnName, $sampleData);
$sql = "ALTER TABLE " . $this->mapper->table . " ADD $columnName $columnDefinition";
$this->mapper->pdo->query($sql);
}

public static function columnDefinition($columnName, $sampleData)
{
if ($sampleData) {
if (\is_numeric($sampleData)) {
if (\is_integer($sampleData)) {
return "INT(11) NULL";
}
if (\is_float($sampleData)) {
return "DOUBLE NULL";
}
}
if (preg_match('/(\d{4})-(\d{2})-(\d{2})/', $sampleData) === 1) {
return "DATETIME NULL";
}
if (strlen($sampleData) > 256) {
return "TEXT";
}
if (strlen($sampleData) > 128) {
return "VARCHAR(256)";
}
}
return 'VARCHAR(128)';
}
}
39 changes: 39 additions & 0 deletions test/anorm/Anorm_Test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

require_once(__DIR__ . '/../../vendor/autoload.php');

use PHPUnit\Framework\TestCase;

use Anorm\Anorm;

class AnormTest extends TestCase
{
public function testConnctionAndUse_OK()
{
$anorm1 = Anorm::connect('testname', 'mysql:host=localhost;dbname=anorm_test', 'travis', '');
$this->assertInstanceOf('Anorm\Anorm', $anorm1);

$anorm2 = Anorm::use('testname');
$this->assertInstanceOf('Anorm\Anorm', $anorm2);
$this->assertEquals($anorm1, $anorm2);
}

/**
* @expectedException \Exception
* @expectedExceptionMessage Anorm: Connection 'bogusname' doesn't exist. Call Anorm::connection first.
*/
public function testUse_NotConnected_Fails()
{
$result = Anorm::use('bogusname');
}

/**
* @expectedException \PDOException
* @expectedExceptionMessage SQLSTATE[HY000] [1045] Access denied for user 'bogus'@'localhost' (using password: NO)
*/
public function testConnction_Bogus_Fails()
{
$result = Anorm::connect('bogusname', 'mysql:host=localhost;dbname=bogus', 'bogus', '');
}

}
99 changes: 99 additions & 0 deletions test/anorm/DataMapper_Dynamic_Test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

require_once(__DIR__ . '/../../vendor/autoload.php');

use PHPUnit\Framework\TestCase;

use Anorm\DataMapper;
use Anorm\Model;

class NotYetModel extends Model {
public function __construct(\PDO $pdo)
{
parent::__construct($pdo, DataMapper::createByClass($pdo, $this));
$this->_mapper->mode = DataMapper::MODE_DYNAMIC;
}

public function countRows()
{
$result = $this->_mapper->query('SELECT * FROM `not_yet`');
return $result->rowCount();
}

public $id;
public $name;
public $dtc;
}

class DataMapperDynamicTest extends TestCase
{
/** @var \PDO */
private $pdo;

public function __construct()
{
parent::__construct();
$this->pdo = new \PDO('mysql:host=localhost;dbname=anorm_test', 'travis', '');
}

public function setUp()
{
$this->pdo->query('DROP TABLE IF EXISTS `not_yet`');
}

public function testFindOne_OK()
{
/** @var NotYetModel */
$model = DataMapper::find('NotYetModel', $this->pdo)
->where("`name`='Name 1'")
->one();
$this->assertTrue(true); // Just testing that we haven't yet thrown.
}

public function testCrud_OK()
{
$model0 = new NotYetModel($this->pdo);
$this->assertEquals($this->pdo, $model0->_mapper->pdo);
// Count current rows
$n0 = $model0->countRows();
$this->assertEquals(0, $n0);

// Create
$model0->name = 'bob';
$model0->dtc = '2018-11-25 00:00:00';
$this->assertNull($model0->id);
$model0->write();
$this->assertNotNull($model0->id);

// Count current rows (n+1)
$n1 = $model0->countRows();
$this->assertEquals($n0 + 1, $n1);

// Read (data present)
$model1 = new NotYetModel($this->pdo);
$model1->read($model0->id);
$this->assertEquals($model0->name, $model1->name);
$this->assertEquals($model0->dtc, $model1->dtc);

// Update
$model1->name = 'fred';
$model1->write();

// Read (data changed)
$model2 = new NotYetModel($this->pdo);
$model2->read($model1->id);
$this->assertEquals($model1->name, $model2->name);

// Delete
$model0->_mapper->delete($model0->id);

// Count current rows (n)
$n2 = $model0->countRows();
$this->assertEquals($n0, $n2);

}




}
Loading

0 comments on commit 33d0d29

Please sign in to comment.