Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions src/Phaseolies/Database/Database.php
Original file line number Diff line number Diff line change
Expand Up @@ -483,15 +483,29 @@ public function procedure(string $procedureName, array $params = [], array $outp
* @param array $params
* @return array
* @throws PDOException
* @throws \InvalidArgumentException
*/
public static function view(string $viewName, array $where = [], array $params = []): array
{
$sql = "SELECT * FROM {$viewName}";
if (!static::isValidIdentifier($viewName)) {
throw new \InvalidArgumentException("Invalid view name \"{$viewName}\".");
}

$quote = static::getPdoInstance()->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql' ? '"' : '`';
$quoteId = fn(string $id): string => $quote . str_replace('.', "{$quote}.{$quote}", $id) . $quote;

$sql = "SELECT * FROM " . $quoteId($viewName);

if (!empty($where)) {
$conditions = [];
foreach ($where as $column => $value) {
$conditions[] = "{$column} = :{$column}";
$column = (string) $column;

if (!static::isValidIdentifier($column, false)) {
throw new \InvalidArgumentException("Invalid column name \"{$column}\".");
}

$conditions[] = $quoteId($column) . " = :{$column}";
$params[":{$column}"] = $value;
}
$sql .= " WHERE " . implode(' AND ', $conditions);
Expand Down
39 changes: 34 additions & 5 deletions src/Phaseolies/Database/Entity/Query/Grammar.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,29 @@ trait Grammar
*
* @var string
*/
private const VALID_IDENTIFIER_PATTERN = '/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/';
private const VALID_IDENTIFIER_PATTERN = '/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?\z/';

/**
* Matches a plain, unqualified identifier
*
* @var string
*/
private const VALID_SIMPLE_IDENTIFIER_PATTERN = '/^[A-Za-z_][A-Za-z0-9_]*\z/';

/**
* Determine if a value is a safe identifier.
*
* @param string $identifier
* @param bool $allowQualified
* @return bool
*/
protected static function isValidIdentifier(string $identifier, bool $allowQualified = true): bool
{
return (bool) preg_match(
$allowQualified ? self::VALID_IDENTIFIER_PATTERN : self::VALID_SIMPLE_IDENTIFIER_PATTERN,
$identifier
);
}

/**
* Ensure a value used as a column identifier is a safe
Expand All @@ -20,7 +42,7 @@ trait Grammar
*/
protected function assertValidIdentifier(string $identifier): void
{
if (!preg_match(self::VALID_IDENTIFIER_PATTERN, $identifier)) {
if (!static::isValidIdentifier($identifier)) {
throw new \InvalidArgumentException(
"Invalid column identifier \"{$identifier}\". " .
"Use whereRaw()/orderByRaw()/groupByRaw() to build conditions from raw SQL expressions."
Expand Down Expand Up @@ -72,13 +94,20 @@ public function rand(): string
*
* @param string $tableName
* @return string
* @throws \InvalidArgumentException
*/
public function getTableColumnsSql(string $tableName): string
{
$this->assertValidIdentifier($tableName);

// information_schema compares table_name as a string value, so it is
// escaped as a literal rather than quoted as an identifier
$literal = str_replace("'", "''", $tableName);

return match ($this->getDriver()) {
'sqlite' => "PRAGMA table_info({$tableName})",
'pgsql' => "SELECT column_name FROM information_schema.columns WHERE table_name = '{$tableName}'",
default => "DESCRIBE {$tableName}",
'sqlite' => "PRAGMA table_info({$this->quoteIdentifier($tableName)})",
'pgsql' => "SELECT column_name FROM information_schema.columns WHERE table_name = '{$literal}'",
default => "DESCRIBE {$this->quoteIdentifier($tableName)}",
};
}

Expand Down
14 changes: 2 additions & 12 deletions src/Phaseolies/Database/Temporal/InteractsWithTemporal.php
Original file line number Diff line number Diff line change
Expand Up @@ -543,24 +543,14 @@ private static function snapshotScalar(mixed $value): string
}

/**
* Expand a partial datetime to the end of the given day, minute or second.
* Normalize a datetime for comparison with `valid_from`
*
* @param string $datetime
* @return string
*/
private function normalizeTemporalDateTime(string $datetime): string
{
$datetime = trim($datetime);
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $datetime)) {
return $datetime . ' 23:59:59.999999';
}
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $datetime)) {
return $datetime . ':59.999999';
}
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $datetime)) {
return $datetime . '.999999';
}
return $datetime;
return TemporalManager::normalizeDateTime($datetime, $this->getConnection()->getAttribute(PDO::ATTR_DRIVER_NAME));
}

/**
Expand Down
21 changes: 2 additions & 19 deletions src/Phaseolies/Database/Temporal/TemporalBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -441,31 +441,14 @@ private function applyPhpLimit(array $models): array
}

/**
* Normalize various human-friendly datetime strings into a sortable
* Normalize a datetime for comparison with `valid_from`
*
* @param string $datetime
* @return string
*/
private function normalizeDateTime(string $datetime): string
{
$datetime = trim($datetime);

// Date-only: append end-of-day
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $datetime)) {
return $datetime . ' 23:59:59.999999';
}

// Date + HH:MM only
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $datetime)) {
return $datetime . ':59.999999';
}

// Date + HH:MM:SS only
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $datetime)) {
return $datetime . '.999999';
}

return $datetime;
return TemporalManager::normalizeDateTime($datetime, $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME));
}

/**
Expand Down
38 changes: 38 additions & 0 deletions src/Phaseolies/Database/Temporal/TemporalManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,44 @@ private static function insertHistory(PDO $pdo, string $table, array $data): voi
$stmt->execute(array_values($data));
}

/**
* Normalize a user supplied point in time for comparison with `valid_from`.
*
* Partial datetimes are expanded to the end of the given day, minute or
* second. On PostgreSQL `valid_from` is a TIMESTAMPTZ written with an
* explicit offset, so a value without one would be read in the session
* time zone instead of the application's; the application offset is
* appended to keep reads and writes in the same zone.
*
* @param string $datetime
* @param string $driver
* @return string
*/
public static function normalizeDateTime(string $datetime, string $driver): string
{
$datetime = trim($datetime);

if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $datetime)) {
$datetime .= ' 23:59:59.999999';
} elseif (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $datetime)) {
$datetime .= ':59.999999';
} elseif (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $datetime)) {
$datetime .= '.999999';
}

$hasOffset = (bool) preg_match('/(Z|[+-]\d{2}(:?\d{2})?)$/i', $datetime);

if ($driver === 'pgsql' && !$hasOffset) {
try {
return \Carbon\Carbon::parse($datetime)->format('Y-m-d H:i:s.uP');
} catch (\Throwable) {
return $datetime;
}
}

return $datetime;
}

/**
* Generate a high-resolution timestamp string for the current driver.
*
Expand Down
Loading
Loading