모델과 DAO
모델
BaseModel을 상속하면 배열/JSON 변환, 암복호화, 자동 타임스탬프를 얻습니다. 프로퍼티는 private + 게터/세터로 선언합니다 — 매핑은 메서드 이름을 기준으로 이뤄집니다.
<?php
namespace ExampleUser;
use _Flexagon\Base\BaseModel;
class UserModel extends BaseModel {
private ?int $id = null;
private string $name;
private ?string $address = null;
public function getId(): ?int { return $this->id; }
public function setId(?int $id): void { $this->id = $id; }
public function getName(): string { return $this->name; }
public function setName(string $name): void { $this->name = $name; }
public function getAddress(): ?string { return $this->address; }
public function setAddress(?string $a): void { $this->address = $a; }
}
BaseModel이 제공하는 메서드:
| 메서드 | 설명 |
|---|---|
getArray(bool $containsObject = true, bool $forceAll = false) |
게터를 훑어 연관 배열로 변환 |
setByArray(array $data) |
배열을 세터로 주입. 키는 snake_case/camelCase 모두 허용 |
getJson(int $flags = JSON_UNESCAPED_UNICODE) |
JSON 직렬화 (중첩 객체는 배열로 평탄화) |
encrypt() / decrypt() |
@encrypt 프로퍼티 일괄 암복호화 |
getCreatedAt() / getUpdatedAt() |
Unix timestamp |
getCreatedAtDatetimeString() |
Y-m-d H:i:s 문자열 |
JsonSerializable을 구현하므로 json_encode($model)이 그대로 동작합니다.
setByArray()는 타입 불일치를 리플렉션으로 보정합니다. 빈 문자열 → nullable 프로퍼티에는 null, 백드 enum 프로퍼티에는 스칼라 값으로 매칭되는 케이스를 넣어 줍니다. 폼 입력을 그대로 넘겨도 되도록 만든 장치입니다.
DAO
BaseMySqlDAO를 상속하고 $tableName만 지정하면 CRUD가 준비됩니다.
프레임워크가 $tableName을 백틱(MySQL)이나 대괄호(SQL Server)로 감싸므로 'order' 같은 예약어도 그대로 쓸 수 있습니다. 이미 감싸 둔 이름은 다시 감싸지 않아서, 'audit.users', '[audit].[users]', '`app`.`users`' 모두 같은 SQL이 됩니다.
<?php
namespace ExampleUser;
use _Flexagon\Base\BaseMySqlDAO;
class UserDAO extends BaseMySqlDAO {
protected string $tableName = 'users';
public function insert(UserModel $user): int|false { return $this->_insert($user); }
public function update(UserModel $user): bool { return $this->_update($user); }
public function delete(UserModel $user): bool { return $this->_delete($user); }
public function findById(int $id): ?UserModel {
return $this->_select(['id' => $id], new UserModel());
}
/** @return UserModel[] */
public function findByCity(string $city, int $page = 1, int $perPage = 20): array {
return $this->_selectList(
new UserModel(), $page, $perPage,
'`address` = :CITY', ['CITY' => $city],
'`created_at` DESC'
);
}
public function countByCity(string $city): int {
return $this->_selectTotalCount('`address` = :CITY', ['CITY' => $city]);
}
}
CRUD 메서드가 protected인 것은 의도된 설계입니다. 어떤 연산을 외부에 노출할지 DAO가 직접 정하므로, 도메인에 맞는 좁은 인터페이스를 유지할 수 있습니다.
| 메서드 | 설명 |
|---|---|
_insert($modelOrArray) |
INSERT. auto_increment 값을 반환 (실패 시 false) |
_update($modelOrArray) |
기본키 기준 UPDATE |
_updateByQuery($modelOrArray, $whereSql, $params) |
조건 지정 UPDATE |
_select($modelOrArray, ?object $vo) |
단건 조회. $vo를 주면 객체로, 없으면 배열로 반환 |
_selectByQuery($whereSql, $params, ?object $vo) |
조건 지정 단건 조회 |
_selectList(?object $vo, $page, $perPage, $whereSql, $params, $orderSql) |
페이지네이션 목록 조회 |
_selectTotalCount($whereSql, $params) |
조건에 맞는 총 건수 |
_delete($modelOrArray) |
기본키 기준 DELETE |
_deleteByQuery($whereSql, $params) |
조건 지정 DELETE |
_insertAll($modelList, $chunkSize = 500) |
청크당 한 문장으로 다건 INSERT. 삽입된 행 수 반환 |
_truncate() |
TRUNCATE |
PostgreSQL은 BasePgSqlDAO, SQLite는 BaseSqliteDAO, SQL Server는 BaseMsSqlDAO를 상속하면 동일한 메서드 표면을 그대로 씁니다. 모든 DAO가 BaseSqlDAO를 상속하며 CRUD·스키마 캐시·트랜잭션은 그 한 곳에 있습니다. 하위 클래스가 갖는 것은 방언이 강제하는 부분 — 식별자 인용(`name` / "name" / [name]), 스키마 조회(DESCRIBE / information_schema / PRAGMA table_info), 페이징(LIMIT / LIMIT·OFFSET / OFFSET·FETCH) — 뿐입니다.
PostgreSQL: 큰따옴표로 감싼 식별자는 대소문자를 구분하고, 프레임워크는
$tableName을 감쌉니다. 테이블이 저장된 이름 그대로, 보통 소문자로 쓰세요('Users'가 아니라'users'). 이름만 쓰면search_path를 따라 찾고, 다른 schema는'audit.users'처럼 씁니다.
SQLite: 데이터 소스는 파일이며
DataSourceModel::file()로 만듭니다._Global::$DATA_SOURCES['default'] = DataSourceModel::file(PROJECT_ROOT . '/var/app.sqlite');디렉터리는 미리 있어야 합니다. 경로가 틀리면 빈 DB를 새로 만드는 대신 접속에 실패합니다.
INTEGER PRIMARY KEY컬럼이 자동 증가 컬럼이고,_truncate()는DELETE로 실행되며, 외래 키 제약이 켜집니다. SQLite는 쓰기 때 파일 전체를 잠그므로, 동시에 쓰는 요청이 많은 사이트라면 WAL을 한 번 켜 두세요(PRAGMA journal_mode = WAL).
SQL Server + 유니코드 — 드라이버는
pdo_sqlsrv를 우선 쓰고 없으면pdo_dblib으로 접속합니다.pdo_dblib은 문자열 파라미터를 서버의 단일바이트 코드페이지로 바인딩하기 때문에, 그 코드페이지에 없는 문자를NVARCHAR컬럼에 쓰면?가 됩니다(읽기는 정상). 오류가 나지 않으므로 나중에 그 컬럼을 읽을 때까지 드러나지 않습니다.준비문 사용 : '?? ?? café' ← 서울·東京 소실, é 는 CP1252 라 생존 준비문 미사용 : '서울 東京 café'이 조합으로 접속하면 프레임워크가 데이터소스당 한 번 경고를 남깁니다.
[FLEXAGON] data source "default" uses pdo_dblib with prepared statements: ...한글·일본어·중국어를 저장한다면
pdo_sqlsrv를 설치하세요. 그게 어렵다면 해당 데이터소스에서 준비문을 끄면 프레임워크가N'...'리터럴로 조립해 유니코드를 보존합니다._Global::$DATA_SOURCES['default']->setUsePrepareStatement(false); _Global::$DB_WARN_DBLIB_UNICODE = false; // 제약을 알고 쓰는 경우 경고만 끄기준비문을 끄면 값이 SQL 문자열로 인라인됩니다. 프레임워크가 따옴표를 이중화하고 제어문자를 제거하지만, 서버가 값과 구문을 분리해 주는 진짜 준비문보다는 방어면이 넓습니다.
pdo_sqlsrv를 쓸 수 있다면 그쪽이 낫습니다.
직접 SQL을 쓰고 싶으면 $this->db(Mysql 인스턴스)를 그대로 사용합니다.
public function findTopSpenders(int $limit): array {
$this->db->executeQuery(
'SELECT u.* FROM users u JOIN orders o ON o.user_id = u.id
GROUP BY u.id ORDER BY SUM(o.amount) DESC LIMIT :LIMIT',
['LIMIT' => $limit]
);
return $this->db->getAllResultAsObject(new UserModel());
}
컬럼 ↔ 프로퍼티 매핑
DB의 snake_case 컬럼과 PHP의 camelCase 프로퍼티가 자동으로 연결됩니다.
| DB 컬럼 | 게터 / 세터 | 프로퍼티 |
|---|---|---|
id |
getId() / setId() |
$id |
user_name |
getUserName() / setUserName() |
$userName |
created_at |
getCreatedAt() / setCreatedAt() |
$createdAt |
파라미터 바인딩
쿼리는 PDO prepared statement로 실행되며, 네임드 플레이스홀더를 씁니다. 프레임워크가 생성하는 쿼리는 컬럼명을 대문자로 바꾼 이름을 사용합니다 (user_name → :USER_NAME).
같은 플레이스홀더를 한 쿼리에서 여러 번 써도 SqlUtil::expandQueryParams()가 자동으로 분리해 주므로 PDO의 중복 바인딩 제약에 걸리지 않습니다.
$this->_selectList(new UserModel(), 1, 20,
'`name` LIKE :KEYWORD OR `address` LIKE :KEYWORD', // 두 번 등장해도 됨
['KEYWORD' => "%{$keyword}%"]
);
디버깅
echo $userDAO->debugQuery(); // 파라미터가 치환된 실제 SQL
echo $userDAO->getError(); // 마지막 오류 메시지
$userDAO->debugDumpParams(); // PDOStatement::debugDumpParams()
_Global::$DEBUG_QUERY_BREAKPOINT를 켜면 모든 쿼리 직후에 Mysql::debugQueryAfter()가 호출됩니다. 빈 메서드이므로 IDE에서 이 지점에 중단점을 걸면 실행된 쿼리를 그대로 관찰할 수 있습니다.