Skip to content

Commit 0a51c79

Browse files
committed
feat: add join
1 parent 3883072 commit 0a51c79

File tree

2 files changed

+196
-5
lines changed

2 files changed

+196
-5
lines changed

src/index.ts

Lines changed: 116 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,22 @@ export class MicroSQL {
3838
}
3939

4040
private select(sql: string) {
41+
const joinMatch = sql.match(
42+
/SELECT (.+?) FROM (\w+)\s+(?:INNER\s+)?JOIN\s+(\w+)\s+ON\s+(\w+)\.(\w+)\s*=\s*(\w+)\.(\w+)(?: WHERE (.*?))?(?: ORDER BY (\w+)(?:\.(\w+))?(?: (ASC|DESC))?)?(?: LIMIT (\d+))?$/i
43+
);
44+
45+
const leftJoinMatch = sql.match(
46+
/SELECT (.+?) FROM (\w+)\s+LEFT\s+JOIN\s+(\w+)\s+ON\s+(\w+)\.(\w+)\s*=\s*(\w+)\.(\w+)(?: WHERE (.*?))?(?: ORDER BY (\w+)(?:\.(\w+))?(?: (ASC|DESC))?)?(?: LIMIT (\d+))?$/i
47+
);
48+
49+
if (joinMatch) {
50+
return this.selectWithJoin(sql, joinMatch, "INNER");
51+
}
52+
53+
if (leftJoinMatch) {
54+
return this.selectWithJoin(sql, leftJoinMatch, "LEFT");
55+
}
56+
4157
const match = sql.match(
4258
/SELECT (.+?) FROM (\w+)(?: WHERE (.*?))?(?: ORDER BY (\w+)(?: (ASC|DESC))?)?(?: LIMIT (\d+))?$/i
4359
);
@@ -78,7 +94,104 @@ export class MicroSQL {
7894
return rows;
7995
}
8096

81-
private coerceValue(val: any): any {
97+
private selectWithJoin(
98+
sql: string,
99+
match: RegExpMatchArray,
100+
joinType: "INNER" | "LEFT"
101+
) {
102+
const [
103+
,
104+
columns,
105+
leftTable,
106+
rightTable,
107+
leftJoinTable,
108+
leftJoinKey,
109+
rightJoinTable,
110+
rightJoinKey,
111+
whereClause,
112+
orderTable,
113+
orderField,
114+
orderDir,
115+
limitStr
116+
] = match;
117+
118+
const leftRows = this.load(leftTable);
119+
const rightRows = this.load(rightTable);
120+
121+
let joinedRows: Row[] = [];
122+
123+
for (const leftRow of leftRows) {
124+
const matchingRightRows = rightRows.filter(
125+
rightRow => leftRow[leftJoinKey] === rightRow[rightJoinKey]
126+
);
127+
128+
if (matchingRightRows.length > 0) {
129+
for (const rightRow of matchingRightRows) {
130+
const joined: Row = {};
131+
132+
for (const key in leftRow) {
133+
joined[`${leftTable}.${key}`] = leftRow[key];
134+
joined[key] = leftRow[key];
135+
}
136+
137+
for (const key in rightRow) {
138+
joined[`${rightTable}.${key}`] = rightRow[key];
139+
if (!joined[key]) {
140+
joined[key] = rightRow[key];
141+
}
142+
}
143+
144+
joinedRows.push(joined);
145+
}
146+
} else if (joinType === "LEFT") {
147+
const joined: Row = {};
148+
149+
for (const key in leftRow) {
150+
joined[`${leftTable}.${key}`] = leftRow[key];
151+
joined[key] = leftRow[key];
152+
}
153+
154+
joinedRows.push(joined);
155+
}
156+
}
157+
158+
if (whereClause) {
159+
joinedRows = joinedRows.filter(row => this.evalWhere(whereClause, row));
160+
}
161+
162+
if (orderField) {
163+
const actualOrderField = orderTable ? `${orderTable}.${orderField}` : orderField;
164+
joinedRows.sort((a, b) => {
165+
const aVal = this.coerceValue(a[actualOrderField]);
166+
const bVal = this.coerceValue(b[actualOrderField]);
167+
168+
if (aVal < bVal) return orderDir?.toUpperCase() === "DESC" ? 1 : -1;
169+
if (aVal > bVal) return orderDir?.toUpperCase() === "DESC" ? -1 : 1;
170+
return 0;
171+
});
172+
}
173+
174+
if (limitStr) {
175+
const limit = parseInt(limitStr, 10);
176+
joinedRows = joinedRows.slice(0, limit);
177+
}
178+
179+
if (columns.trim() !== "*") {
180+
const fields = columns.split(",").map(f => f.trim());
181+
joinedRows = joinedRows.map(r => {
182+
const obj: Row = {};
183+
for (const f of fields) {
184+
const fieldName = f.includes(".") ? f : f;
185+
obj[f] = r[fieldName] !== undefined ? r[fieldName] : r[f];
186+
}
187+
return obj;
188+
});
189+
}
190+
191+
return joinedRows;
192+
}
193+
194+
private coerceValue(val): string | number | null | undefined {
82195
if (val === null || val === undefined) return val;
83196
const num = Number(val);
84197
return isNaN(num) ? val : num;
@@ -129,7 +242,7 @@ export class MicroSQL {
129242
const [, table, setClause, whereClause] = match;
130243
let data = this.load(table);
131244

132-
const updates: Record<string, any> = {};
245+
const updates: Record<string, string> = {};
133246
const pairs = this.splitRespectingQuotes(setClause);
134247

135248
pairs.forEach(pair => {
@@ -255,7 +368,7 @@ export class MicroSQL {
255368

256369
private evalCondition(expr: string, row: Row): boolean {
257370
const match = expr.match(
258-
/(\w+)\s*(=|>=|<=|>|<|LIKE|IN)\s*(\([^\)]+\)|["'][^"']*["']|\S+)/i
371+
/(\w+)\s*(=|>=|<=|>|<|LIKE|IN)\s*(\([^)]+\)|["'][^"']*["']|\S+)/i
259372
);
260373
if (!match) return false;
261374

tests/index.test.ts

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ afterAll(() => {
1414
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true });
1515
});
1616

17-
describe("MicroDB - Edge Cases", () => {
17+
describe("MicroSQL - Edge Cases", () => {
1818
let db: MicroSQL;
1919

2020
beforeEach(() => {
@@ -166,4 +166,82 @@ describe("MicroDB - Edge Cases", () => {
166166
const result = db.query(`SELECT name FROM users WHERE name LIKE "alice"`);
167167
expect(result).toHaveLength(3);
168168
});
169-
});
169+
it("handles INNER JOIN", () => {
170+
db.query(`INSERT INTO users (id, name) VALUES (1, "Alice")`);
171+
db.query(`INSERT INTO users (id, name) VALUES (2, "Bob")`);
172+
db.query(`INSERT INTO orders (id, user_id, product) VALUES (1, 1, "Laptop")`);
173+
db.query(`INSERT INTO orders (id, user_id, product) VALUES (2, 1, "Mouse")`);
174+
db.query(`INSERT INTO orders (id, user_id, product) VALUES (3, 2, "Keyboard")`);
175+
176+
const result = db.query(
177+
`SELECT users.name, orders.product FROM users JOIN orders ON users.id = orders.user_id`
178+
);
179+
180+
expect(result).toHaveLength(3);
181+
expect(result).toEqual([
182+
{ "users.name": "Alice", "orders.product": "Laptop" },
183+
{ "users.name": "Alice", "orders.product": "Mouse" },
184+
{ "users.name": "Bob", "orders.product": "Keyboard" }
185+
]);
186+
});
187+
188+
it("handles INNER JOIN with INNER keyword", () => {
189+
db.query(`INSERT INTO users (id, name) VALUES (1, "Alice")`);
190+
db.query(`INSERT INTO orders (id, user_id, product) VALUES (1, 1, "Laptop")`);
191+
192+
const result = db.query(
193+
`SELECT users.name, orders.product FROM users INNER JOIN orders ON users.id = orders.user_id`
194+
);
195+
196+
expect(result).toHaveLength(1);
197+
expect(result[0]).toEqual({ "users.name": "Alice", "orders.product": "Laptop" });
198+
});
199+
200+
it("handles LEFT JOIN", () => {
201+
db.query(`INSERT INTO users (id, name) VALUES (1, "Alice")`);
202+
db.query(`INSERT INTO users (id, name) VALUES (2, "Bob")`);
203+
db.query(`INSERT INTO users (id, name) VALUES (3, "Carol")`);
204+
db.query(`INSERT INTO orders (id, user_id, product) VALUES (1, 1, "Laptop")`);
205+
db.query(`INSERT INTO orders (id, user_id, product) VALUES (2, 1, "Mouse")`);
206+
207+
const result = db.query(
208+
`SELECT users.name, orders.product FROM users LEFT JOIN orders ON users.id = orders.user_id`
209+
);
210+
211+
expect(result).toHaveLength(4);
212+
expect(result.map(r => r["users.name"]).sort()).toEqual(["Alice", "Alice", "Bob", "Carol"]);
213+
214+
const bobOrder = result.find(r => r["users.name"] === "Bob");
215+
expect(bobOrder["orders.product"]).toBeUndefined();
216+
});
217+
218+
it("handles JOIN with WHERE clause", () => {
219+
db.query(`INSERT INTO users (id, name, age) VALUES (1, "Alice", 25)`);
220+
db.query(`INSERT INTO users (id, name, age) VALUES (2, "Bob", 30)`);
221+
db.query(`INSERT INTO orders (id, user_id, product) VALUES (1, 1, "Laptop")`);
222+
db.query(`INSERT INTO orders (id, user_id, product) VALUES (2, 2, "Mouse")`);
223+
224+
const result = db.query(
225+
`SELECT users.name, orders.product FROM users JOIN orders ON users.id = orders.user_id WHERE users.age > 25`
226+
);
227+
228+
expect(result).toHaveLength(1);
229+
expect(result[0]).toEqual({ "users.name": "Bob", "orders.product": "Mouse" });
230+
});
231+
232+
it("handles JOIN with ORDER BY and LIMIT", () => {
233+
db.query(`INSERT INTO users (id, name) VALUES (1, "Alice")`);
234+
db.query(`INSERT INTO users (id, name) VALUES (2, "Bob")`);
235+
db.query(`INSERT INTO orders (id, user_id, product, price) VALUES (1, 1, "Laptop", 1000)`);
236+
db.query(`INSERT INTO orders (id, user_id, product, price) VALUES (2, 1, "Mouse", 20)`);
237+
db.query(`INSERT INTO orders (id, user_id, product, price) VALUES (3, 2, "Keyboard", 50)`);
238+
239+
const result = db.query(
240+
`SELECT users.name, orders.product, orders.price FROM users JOIN orders ON users.id = orders.user_id ORDER BY orders.price DESC LIMIT 2`
241+
);
242+
243+
expect(result).toHaveLength(2);
244+
expect(result[0]["orders.product"]).toBe("Laptop");
245+
expect(result[1]["orders.product"]).toBe("Keyboard");
246+
});
247+
})

0 commit comments

Comments
 (0)