모델과 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에서 이 지점에 중단점을 걸면 실행된 쿼리를 그대로 관찰할 수 있습니다.