3.3.4 · Service layer + SQL patterns
บทนี้เจาะ service layer ของ centralize-api — รูปแบบการใช้ raw SQL ผ่าน entityManager.query() ทั้งระบบ, CTE ~80 บรรทัดที่ copy-paste ซ้ำ 4 ครั้ง, การใช้ WITH (NOLOCK) dirty read, และ error handling anti-pattern พร้อม business logic จริงเพียงจุดเดียวของ centralize-api (getShiftTimeWithWork)
Service layer pattern
ทุก service inject EntityManager ผ่าน @InjectEntityManager('center') หรือ 'nbc' แล้วเรียก entityManager.query(sql, params) เพื่อรัน raw SQL
centralize-api/src/modules/employee/employee.service.ts:22-31
@Injectable()
export class EmployeeService {
constructor(
@InjectEntityManager('center')
private readonly entityManager: EntityManager,
private readonly technicalPositionRepository: TechnicalPositionRepository,
) {}
private logger = new I3GatewayLogger(EmployeeService.name);
// ...
}จุดสำคัญ
- ไม่มี TypeORM query builder หรือ repository pattern จริง — ใช้แค่
entityManager.query()ส่ง SQL string + params (ส่วนTechnicalPositionRepository/EmployeeGroupRepository/EmployeeLevelRepositoryเป็น class ธรรมดาที่ใช้BaseEntity.find()แบบ active record ไม่ใช่ TypeORM Repository จริง) - ไม่มี transaction — ไม่มี
queryRunner,@Transaction(),entityManager.transaction()ใด ๆ (OK เพราะ read-only) - try/catch ทุก method → throw
BadRequestException(400) สำหรับทุก error รวมถึง DB failure (anti-pattern — ดู §"Error handling") - logger ทุก method — inject ผ่าน
private logger = new I3GatewayLogger(ClassName.name)
Raw SQL pattern — parameterized queries
ทุก query ใช้ @0, @1, ... เป็น positional placeholder แล้วส่ง params เป็น array ที่ตำแหน่งตรงกัน
ตัวอย่างจาก hrpayslip.service.ts
centralize-api/src/modules/hrPaySlip/hrpayslip.service.ts:20-32
async getPaySlipDT(body: EmpCodeListDto) {
try {
return this.entityManager.query(
`SELECT empCode , incomeCode ,incomeName,income,sourceDB from uv_i3_PaySlipDT_reStructure WHERE EmpCode in (${body.empCode.map(
(_, index) => `@${index}`,
)})`,
[...body.empCode],
);
} catch (error) {
this.logger.error(error);
throw new BadRequestException(error);
}
}วิเคราะห์
${body.empCode.map((_, index) => '@' + index)}สร้าง placeholder names แบบ dynamic — ถ้าempCodeมี 3 ตัวจะได้IN (@0, @1, @2)แล้วส่ง[empCode1, empCode2, empCode3]เป็น params- ปลอดภัยจาก SQL injection เพราะ placeholder
@0@1เป็นเพียงชื่อ parameter ไม่ใช่ value interpolation — driver ของ MSSQL (tedious) จะ bind value แยก - pattern เดียวกันใช้ใน
centralize-api/src/modules/hrTimeTemp/hrtimetemp.service.ts:38-70สำหรับworkingDatearray (@${index + 1}เพราะ@0สำหรับempCode) - สังเกต
[...body.empCode](spread) — เทียบเท่าbody.empCode.slice()คือสร้าง array ใหม่ copy จากตัวเดิม เป็นการป้องกัน mutation ของ array ต้นทาง
ข้อยกเว้น — เงื่อนไข dynamic WHERE ที่ปลอดภัย
centralize-api/src/modules/employee/employee.service.ts:272-322 แสดง pattern ที่สร้าง WHERE clause แบบ dynamic
async getEmployees(query: GetEmployeeQueryDto) {
const queryParam = [];
if (query.department) {
queryParam.push('DeptCode = @0');
}
if (query.sourceDB) {
queryParam.push('SourceDB = @1');
}
// ... 9 conditions
queryParam.push('WorkingStatus = @9');
try {
return await this.entityManager.query(
`select ... from vtrcMainProfile
${queryParam.length > 0 ? 'where' : ''}
${queryParam.join(' and ')}`,
[query.department, query.sourceDB, /* ... 9 params */],
);
}
}ลักษณะ
- array
queryParamเก็บเฉพาะ ชื่อเงื่อนไข (string literalDeptCode = @0) ไม่ใช่ value ของ user - params array ส่งค่าจริงเข้าไปในตำแหน่งที่ตรงกับ placeholder
- ปลอดภัยจาก SQL injection เพราะ
${queryParam.join(' and ')}แทรกเฉพาะ string literal ที่เขียนโดย dev ไม่ใช่ user input
⚠️ pattern นี้มี gotcha — params array ส่งค่า undefined สำหรับ field ที่ user ไม่ส่งเข้ามา (เช่น query.department ถ้าไม่มี) แต่ placeholder @0 ยังถูกแทรกใน SQL เฉพาะเมื่อ query.department truthy ดังนั้นตำแหน่งของ value กับ placeholder ยังตรงกัน
~80-line org hierarchy CTE — copy-paste 4 ครั้ง
ปัญหาใหญ่ที่สุดใน service layer คือ SQL block ~80 บรรทัดที่ query org/division/department hierarchy ถูก copy-paste เหมือนกันทุกประการ 4 ครั้ง ในไฟล์ต่อไปนี้
| Method | ไฟล์:บรรทัด |
|---|---|
AppService.getHello | centralize-api/src/app.service.ts:12-143 |
EmployeeService.getOrganizations | centralize-api/src/modules/employee/employee.service.ts:32-108 |
EmployeeService.getDivisions | centralize-api/src/modules/employee/employee.service.ts:110-189 |
EmployeeService.getDepartments | centralize-api/src/modules/employee/employee.service.ts:191-270 |
โครงสร้างคร่าว ๆ (เน้นส่วนสำคัญจาก app.service.ts:12-143)
select
CASE
WHEN orgMain.SourceDB = 'dbHRMI_CU_SC_rep' THEN '02CS'
WHEN orgMain.SourceDB = 'dbHRMI_CU_H_rep' THEN '02'
WHEN orgMain.SourceDB = 'dbHRMI_RC_C_rep' THEN '03'
WHEN orgMain.SourceDB = 'dbHRMI_RC_B_rep' THEN '00'
WHEN orgMain.SourceDB = 'dbHRMI_RC_NBC_rep' THEN '07'
WHEN org.OrgUnitCode is not null THEN LEFT(org.OrgUnitCode, 2)
ELSE LEFT(org2.OrgUnitCode, 2)
END AS OrgCode,
-- ... derive OrgUnitCode, OrgName, DiviCode, DiviName, DeptCode, DeptName, SourceDB
from (
select * from dbHRMI_Center.dbo.emOrgUnit_new_reStructure WITH (NOLOCK)
UNION ALL
select * from dbHRMI_Center_NBC.dbo.emOrgUnit_new_reStructure WITH (NOLOCK)
) as orgMain
LEFT JOIN dbHRMI_Center.dbo.emOrgUnit_new_reStructure AS dept WITH (NOLOCK)
ON dept.OrgUnitID = orgMain.OrgUnitID AND dept.SourceDB = orgMain.SourceDB AND dept.IsDeleted = 0
LEFT JOIN dbHRMI_Center.dbo.emOrgUnit_new_reStructure AS org WITH (NOLOCK)
ON org.OrgUnitCode = LEFT(dept.OrgUnitCode, 2) AND orgMain.SourceDB = org.SourceDB AND org.IsDeleted = 0
LEFT JOIN dbHRMI_Center.dbo.emOrgUnit_new_reStructure AS divi WITH (NOLOCK)
ON divi.OrgUnitCode = LEFT(dept.OrgUnitCode, 4) AND divi.SourceDB = orgMain.SourceDB AND divi.IsDeleted = 0
LEFT JOIN dbHRMI_Center_NBC.dbo.emOrgUnit_new_reStructure AS dept2 WITH (NOLOCK) ...
LEFT JOIN dbHRMI_Center_NBC.dbo.emOrgUnit_new_reStructure AS org2 WITH (NOLOCK) ...
LEFT JOIN dbHRMI_Center_NBC.dbo.emOrgUnit_new_reStructure AS divi2 WITH (NOLOCK) ...
WHERE orgMain.IsDeleted = 0ลักษณะเฉพาะของ CTE นี้
- union 2 databases (
dbHRMI_Center+dbHRMI_Center_NBC) เป็น subqueryorgMain - 6 LEFT JOIN ระหว่าง
emOrgUnit_new_reStructure(aliasdept,org,divi,dept2,org2,divi2) เพื่อ derive org/divi/dept code จากOrgUnitCodeprefix LEFT(OrgUnitCode, 2)= org code,LEFT(OrgUnitCode, 4)= divi code,LEFT(OrgUnitCode, 6+)= dept code- CASE WHEN เพื่อแมป
SourceDBraw value → รหัส 2 หลัก (02CS,02,03,00,07)
ผลกระทบ
- แก้ logic ต้องแก้ 4 ที่ — ถ้า schema HRMI เปลี่ยน (เช่นเพิ่ม field ใหม่ใน
emOrgUnit) ต้องแก้ 4 methods ให้ตรงกัน ถ้าลืมที่ใดที่หนึ่งจะได้ผลต่างกัน - ภาระการดูแลสูง — ไม่มี test ครอบคลุม ดังนั้นถ้า query ที่ 1 คืน row 100 แถว แต่ที่ 2 คืน 99 แถว จะไม่มีใครสังเกตเห็น
- Performance — query plan cache ของ MSSQL จะเก็บแผนแยก 4 ชุด ถึงแม้ SQL จะเหมือนกัน เพราะ connection pool แยก และ SQL hash ต่างกันเพราะ white-space ไม่เหมือนกัน (4 สำเนา format ต่างกัน)
- ตัวแปร
fuckQueryในgetDivisions—centralize-api/src/modules/employee/employee.service.ts:112ใช้ชื่อตัวแปรที่ไม่เหมาะสม ควรแก้เป็นชื่อทางการ เช่นdivisionQuery
การแก้ — สร้าง method private buildOrgHierarchyCTE(): string ใน service ที่ return SQL string แล้วเรียกใช้ในทุก method ที่ต้องการ
WITH (NOLOCK) — dirty read hint
ทุกที่ที่ join HRMI tables ใช้ WITH (NOLOCK) hint เสมอ
-- ตัวอย่างจาก centralize-api/src/app.service.ts:99-104
select * from dbHRMI_Center.dbo.emOrgUnit_new_reStructure WITH (NOLOCK)
UNION ALL
select * from dbHRMI_Center_NBC.dbo.emOrgUnit_new_reStructure WITH (NOLOCK)NOLOCK (หรือ READUNCOMMITTED) เป็น isolation level ที่อนุญาตให้อ่าน row ที่ transaction อื่นกำลังแก้ไขอยู่โดยไม่รอ → เรียกว่า dirty read
| ข้อดี | ข้อเสีย |
|---|---|
| เร็วมาก — ไม่ต้องรอ lock release | อาจได้ข้อมูล inconsistent — row ที่อยู่ระหว่าง UPDATE อาจคืนค่าใหม่บาง column ค่าเก่าบาง column |
| ลด contention บน HRMI ที่มี write traffic สูง | อาจได้ duplicate rows หรือ missing rows ในบางสถานการณ์ (page split) |
| เหมาะสำหรับ read-only report ที่ทนความคลาดเคลื่อนได้ | ไม่เหมาะสำหรับ transactional read ที่ต้องการ accuracy |
เหมาะสมไหม — สำหรับ centralize-api ที่เป็น HR data facade NOLOCK ยอมรับได้เพราะ
- ข้อมูล HR เปลี่ยนไม่บ่อย (org structure, manager) — dirty read ไม่กระทบมาก
- Perf สำคัญกว่า consistency ใน use case ส่วนใหญ่
- เป็น pattern มาตรฐานของ MSSQL read-only report
แต่ต้องเขียนไว้ในคู่มือ — ถ้าได้รับแจ้งว่า "ข้อมูล org ของพนักงาน X ไม่ตรง บางครั้ง" อาจเป็น dirty read ไม่ใช่ bug
Error handling anti-pattern
ทุก service method ใช้ pattern เดียวกัน
try {
return await this.entityManager.query(sql, params);
} catch (error) {
this.logger.error(error);
throw new BadRequestException(error);
}ปัญหา — BadRequestException (HTTP 400) หมายถึง "client ส่งข้อมูลผิด" แต่ error ที่เกิดจาก DB (เช่น connection timeout, SQL syntax error, dead lock) ไม่ใช่ความผิดของ client ควรจะเป็น InternalServerErrorException (500)
ผลกระทบ
- Client สับสน — ได้ 400 ทั้งที่ request ถูกต้อง ทำให้ debug ผิดทาง
- Monitoring พลาด — alert ที่กรองด้วย status code 5xx จะไม่ตรวจจับ error ของ centralize-api
- Retry logic พัง — ถ้า client มี retry เฉพาะ 5xx จะไม่ retry เมื่อเจอ 400 ทั้งที่น่าจะ retry
การแก้ — แยก error type
} catch (error) {
this.logger.error(error);
if (error instanceof QueryFailedError) {
throw new InternalServerErrorException({
message: 'Database operation failed',
detail: error.message,
});
}
throw new InternalServerErrorException(error);
}Business logic จริงเพียงจุดเดียวของ centralize-api — getShiftTimeWithWork
HrTimeTempService.getShiftTimeWithWork (centralize-api/src/modules/hrTimeTemp/hrtimetemp.service.ts:27-128) เป็น business logic แท้จริงเพียงจุดเดียวในระบบ — ทุก service อื่นเป็น thin SQL wrapper
Flow
1. Query hrTimeTempImport บน dbHRMI_Center + dbHRMI_Center_NBC แบบ parallel (Promise.all)
- WHERE EmpCardNo = @0 AND convert(varchar, DateTimeStamp, 102) in (@1, @2, ...)
- ใช้ convert(varchar, ..., 102) = format YYYY.MM.DD เพื่อเทียบเฉพาะ date
│
▼
2. Merge results จาก 2 databases ด้วย [...timeCenter[0], ...timeCenter[1]]
│
▼
3. Sort workingDate ascending (ที่ฝั่ง JS ไม่ใช่ SQL)
│
▼
4. For each day:
│ 4.1 filter check-ins ของวันนั้น (format YYYY-MM-DD เทียบ)
│ 4.2 sort by timestamp ascending
│ 4.3 ถ้ามีหลาย stamp:
│ - แรก = timeIn (format HH:mm)
│ - สุดท้าย = timeOut (format HH:mm)
│ 4.4 ถ้ามี stamp เดียว:
│ - timeIn only
│ - timeOut = null
│ 4.5 ถ้าไม่มี stamp ในวันนั้น: ข้ามวัน (ไม่ push เข้า data)
│
▼
5. Build format array ครบทุก workingDate (รวมวันที่ไม่มีข้อมูล → timeIn='', timeOut='')
│
▼
6. Return shape: { date, timeIn, timeOut }[]จุดสังเกต
- parallel query ใช้
Promise.all— เร็วกว่า query ทีละ database - merge logic ที่ฝั่ง application ไม่ใช่ SQL — เพราะถ้า merge ใน SQL ต้อง
UNION ALLcross-database ที่ MSSQL ทำได้แต่อ่านยาก - timeIn/timeOut derivation คือ business rule ที่ไม่ได้เก็บใน HRMI โดยตรง — HRMI เก็บแค่
DateTimeStamp(timestamp ของการแตะบัตร) ส่วนการตีความว่าคือเข้าหรือออกอยู่ที่ logic นี้ - format สุดท้าย (
hrtimetemp.service.ts:113-122) return ทุก workingDate แม้ไม่มีข้อมูล โดยใส่timeIn=''และtimeOut=''→ เปลี่ยนจากnullเป็น empty string ในขั้นตอนสุดท้าย
Edge cases ที่ logic นี้ไม่ cover
| กรณี | ผลลัพธ์ | ปัญหา |
|---|---|---|
| พนักงานแตะบัตร 1 ครั้ง → timeIn = X, timeOut = null | ทำงานครึ่งวัน? ลืมแตะออก? | ไม่ระบุ — format สุดท้ายใส่ '' ไม่ใช่ null ทำให้ client แยกไม่ออกว่า "ไม่มีข้อมูล" หรือ "มีเข้าไม่มีออก" |
| พนักงานแตะบัตร 5 ครั้งในวันเดียวกัน | timeIn = แรก, timeOut = สุดท้าย | ครั้งที่ 2-4 คืออะไร? (ออกกินข้าว? ธุระ?) |
| พนักงานแตะบัตรข้ามวัน (เช่นเข้า 23:00, ออก 07:00 วันถัดไป) | จัดวันไหน? | ไม่ระบุ — ใช้ workingDate param เป็นตัวกรอง ถ้าไม่ส่งทั้งสองวันจะไม่เห็นครบ |
| Overnight shift | ดูเหมือนจะถูกตั้งแต่กำหนด — workingDate เป็น array | แต่ถ้าพนักงานทำงานข้ามคืน 2 วัน row จะอยู่ในผลลัพธ์ของวันไหน? |
→ โดยทั่วไปจะเริ่ม debug "เวลาออกงานของพนักงาน X ผิด" ที่ method นี้ก่อน เพราะเป็น logic เดียวที่ตีความ timestamp
getEmployeeDisaster — disaster recovery lookup
centralize-api/src/modules/employee/employee.service.ts:434-485
select c.order_row, o.EmpCode as empCode, ...
from vtrcAllProfile as o
left join centralizedProfileOrdering_reStructure_all_cdb_nbc as c
on o.EmpID = c.EmpID and o.EmpCode = c.EmpCode and o.SourceDB = c.SourceDB
left join (
select * from dbHRMI_Center.dbo.hrEmpWorkProfile
union all
select * from dbHRMI_Center_NBC.dbo.hrEmpWorkProfile
) as w on o.EmpID = w.EmpID and o.SourceDB = w.SourceDB and w.IsDeleted = 0
where o.EmpCode = @0
and @1 between convert(date, w.StartDate, 103)
and ISNULL(CONVERT(date, w.EndDate, 103), '9999-12-31')
order by c.order_row asc;query นี้ check ว่า disasterStartDate (เช่นวันเกิดเหตุการณ์ฉุกเฉิน) ตกในช่วง StartDate - EndDate ของงานใดหรือไม่ → ใช้สำหรับ disaster recovery benefit lookup
จุดที่น่าสนใจ
ISNULL(CONVERT(date, w.EndDate, 103), '9999-12-31')— ถ้าEndDateเป็น NULL (พนักงานยังทำงานอยู่) ให้ใช้9999-12-31เป็น upper boundconvert(date, ..., 103)— format 103 คือDD/MM/YYYY(British/French) ซึ่งตรงกับ locale ของ HRMI
getBankAccount — cross-database UNION ALL pattern
centralize-api/src/modules/employee/employee.service.ts:545-586 เป็นตัวอย่างของ query ที่ union ข้อมูลจาก dbHRMI_Center + dbHRMI_Center_NBC ในแต่ละ join ก่อนที่จะเชื่อมกับ vtrcAllProfile
LEFT JOIN (
SELECT * FROM dbHRMI_Center.dbo.hrEmpBankBook_reStructure
UNION ALL
SELECT * FROM dbHRMI_Center_NBC.dbo.hrEmpBankBook_reStructure
) AS hrebb ON tpot.EmpID = hrebb.EmpID AND tpot.SourceDB = hrebb.SourceDB
AND hrebb.IsDeleted = '0' AND hrebb.PercentageSend = '100'pattern นี้ซ้ำใน emBank_reStructure และ emBankBranch_reStructure → ทั้ง 3 join ใช้วิธีเดียวกัน
repository pattern — ใช้บางส่วน
employeeGroup, employeeLevel, technicalPosition modules มี repository class แยก (3 files, 80 LOC รวม)
centralize-api/src/modules/employeeGroup/employeeGroup.repository.ts:1-29
@Injectable()
export class EmployeeGroupRepository {
async findEmployeeGroupBySourceDB(
sourceDB?: string,
): Promise<EmployeeGroup[]> {
const where: any = {
isDeleted: false,
isInactive: false,
};
if (sourceDB) {
where.sourceDB = sourceDB;
}
const [employeeGroups, employeeGroupsNBC] = await Promise.all([
EmployeeGroup.find({ where, order: { emplGrupName: 'ASC' } }),
EmployeeGroupNBC.find({ where, order: { emplGrupName: 'ASC' } }),
]);
return employeeGroups.concat(employeeGroupsNBC);
}
}แต่ repository เหล่านี้ ไม่ได้ใช้ TypeORM Repository pattern จริง — เป็น class ธรรมดาที่ใช้ BaseEntity.find() แบบ active record (เพราะ entity extends BaseEntity) ไม่ได้ inject EntityManager เหมือน service ใช้ชื่อว่า "repository" เฉย ๆ
ผล — pattern ไม่ consistent — EmployeeService inject repository แทนที่จะเรียก SQL เอง แต่ service อื่น (hrPaySlip, hrTimeTemp) เรียก SQL ตรง ๆ ทำให้ codebase อ่านยาก
ทั้งสาม repository ใช้ pattern เดียวกัน — Promise.all query center + NBC แล้ว concat ผล ไม่มี field IsDeleted/IsInactive filter ใน repos บางตัว
| Repository | Filter IsDeleted/IsInactive? | Order by |
|---|---|---|
EmployeeGroupRepository | ใช่ (where.isDeleted = false, isInactive = false) | emplGrupName ASC |
EmployeeLevelRepository | ไม่ใช่ (เฉพาะ sourceDB) | emplLevelName ASC |
TechnicalPositionRepository | ใช่ | posTechnicalName ASC |
ความไม่ consistent ของ filter บ่งบอกว่า EmployeeLevelRepository อาจ return ข้อมูลที่ถูก soft-delete ด้วย → correctness bug เงียบ ๆ
สรุปประเด็นสำคัญ
- Service layer ใช้ raw SQL string ผ่าน
entityManager.query()ทั้งระบบ — TypeORM ใช้แค่ connection management - Parameterized queries (
@0,@1) ใช้ถูกต้อง → ปลอดภัยจาก SQL injection (รวมถึง dynamic WHERE clause pattern) - ~80-line CTE ถูก copy-paste 4 ครั้ง — ภาระการดูแลสูง ควรดึงออกเป็น method ร่วม
WITH (NOLOCK)ทุกที่ — dirty read ยอมรับได้สำหรับ HR facade แต่ต้องเขียนไว้ใน doc- Error handling anti-pattern —
BadRequestExceptionสำหรับทุก error รวม DB failure → ควรแยก type - Business logic จริงเพียงจุดเดียว —
getShiftTimeWithWorktime attendance merge ที่มี edge cases หลายจุด (overnight, แตะบัตรหลายครั้ง) และ format สุดท้ายใส่ empty string ทำให้ client แยกไม่ออกจากข้อมูลจริง - Repository pattern ใช้บางส่วนไม่ consistent — 3 sub-module มี repository ส่วน service อื่นเรียก SQL ตรง ๆ และ filter
IsDeletedไม่เท่ากัน (EmployeeLevelRepositoryไม่ filter)