diff --git a/.env.example b/.env.example index fbfba45..d687458 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,13 @@ export POSTGRES_DB= export POSTGRES_USER= export POSTGRES_PASSWORD= +# MySQL +export MYSQL_HOST= +export MYSQL_PORT= +export MYSQL_DB= +export MYSQL_USER= +export MYSQL_PASSWORD= + # Webhook export WEBHOOK_URL= export WEBHOOK_SECRET= diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index 642ca90..bd5e9bf 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -54,5 +54,11 @@ jobs: - name: Produce data to Postgres with multiple tables run: docker exec datagen datagen -s /tests/schema2.sql -f postgres -n 3 -rs 1000 + - name: Produce data to MySQL with Faker.js + run: docker exec datagen datagen -s /tests/mysql-products.sql -f mysql -n 3 + + - name: Produce data to MySQL with multiple tables + run: docker exec datagen datagen -s /tests/mysql-schema.sql -f mysql -n 3 -rs 1000 + - name: Docker Compose Down run: docker compose down -v diff --git a/README.md b/README.md index c01ea1f..efd09ba 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ Fake Data Generator Options: -V, --version output the version number -s, --schema Schema file to use - -f, --format The format of the produced data (choices: "json", "avro", "postgres", "webhook", default: "json") + -f, --format The format of the produced data (choices: "json", "avro", "postgres", "webhook", "mysql", default: "json") -n, --number Number of records to generate. For infinite records, use -1 (default: "10") -c, --clean Clean (delete) Kafka topics and schema subjects previously created -dr, --dry-run Dry run (no data will be produced to Kafka) @@ -279,6 +279,30 @@ datagen \ > :warning: You can only produce to Postgres with a SQL schema. +#### Producing to MySQL + +You can also produce the data to a MySQL database. To do this, you need to specify the `-f mysql` option and provide MySQL connection information in the `.env` file. Here is an example `.env` file: + +``` +# MySQL +export MYSQL_HOST= +export MYSQL_PORT= +export MYSQL_DB= +export MYSQL_USER= +export MYSQL_PASSWORD= +``` + +Then, you can run the following command to produce the data to MySQL: + +```bash +datagen \ + -s tests/products.sql \ + -f mysql \ + -n 1000 +``` + +> :warning: You can only produce to MySQL with a SQL schema. + #### Producing to Webhook You can also produce the data to a Webhook. To do this, you need to specify the `-f webhook` option and provide Webhook connection information in the `.env` file. Here is an example `.env` file: diff --git a/datagen.ts b/datagen.ts index 8770319..7da38c1 100755 --- a/datagen.ts +++ b/datagen.ts @@ -23,7 +23,7 @@ program .requiredOption('-s, --schema ', 'Schema file to use') .addOption( new Option('-f, --format ', 'The format of the produced data') - .choices(['json', 'avro', 'postgres', 'webhook']) + .choices(['json', 'avro', 'postgres', 'webhook', 'mysql']) .default('json') ) .addOption( @@ -58,6 +58,7 @@ global.wait = options.wait; global.clean = options.clean; global.dryRun = options.dryRun; global.prefix = options.prefix; +global.format = options.format; if (global.debug) { console.log(options); @@ -104,7 +105,7 @@ if (!global.wait) { process.exit(1); } - if (global.clean && options.format !== 'postgres' && options.format !== 'webhook') { + if (global.clean && options.format !== 'postgres' && options.format !== 'webhook' && options.format !== 'mysql') { // Only valid for Kafka const topics = [] for (const table of parsedSchema) { diff --git a/docker-compose.yaml b/docker-compose.yaml index f8927dc..e79a440 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -34,6 +34,16 @@ services: ports: - 5432:5432 + mysql: + image: mysql:8.0 + environment: + MYSQL_ROOT_PASSWORD: mysql + MYSQL_DATABASE: mysql + MYSQL_USER: mysql + MYSQL_PASSWORD: mysql + ports: + - 3306:3306 + datagen: build: . container_name: datagen @@ -47,6 +57,12 @@ services: POSTGRES_DB: postgres POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres + MYSQL_HOST: mysql + MYSQL_PORT: 3306 + MYSQL_DB: mysql + MYSQL_USER: root + MYSQL_PASSWORD: mysql + volumes: - ./tests:/tests # Override the entrypoint to run the container and keep it running diff --git a/package-lock.json b/package-lock.json index 1a8a994..e706782 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "crypto-random-string": "^5.0.0", "dotenv": "^16.0.2", "kafkajs": "^2.2.3", + "mysql2": "^3.9.2", "node-sql-parser": "^4.6.1", "pg": "^8.11.0" }, @@ -2311,6 +2312,14 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "engines": { + "node": ">=0.10" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -2641,6 +2650,14 @@ "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", "dev": true }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2757,6 +2774,17 @@ "node": ">=10.17.0" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/import-local": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", @@ -2846,6 +2874,11 @@ "node": ">=0.12.0" } }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -3859,6 +3892,56 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, + "node_modules/mysql2": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.2.tgz", + "integrity": "sha512-3Cwg/UuRkAv/wm6RhtPE5L7JlPB877vwSF6gfLAS68H+zhH+u5oa3AieqEd0D0/kC3W7qIhYbH419f7O9i/5nw==", + "dependencies": { + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.6.3", + "long": "^5.2.1", + "lru-cache": "^8.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/mysql2/node_modules/long": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", + "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==" + }, + "node_modules/mysql2/node_modules/lru-cache": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-8.0.5.tgz", + "integrity": "sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==", + "engines": { + "node": ">=16.14" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", + "integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==", + "dependencies": { + "lru-cache": "^7.14.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/named-placeholders/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "engines": { + "node": ">=12" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -4490,6 +4573,11 @@ ], "peer": true }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, "node_modules/schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", @@ -4552,6 +4640,11 @@ "semver": "bin/semver.js" } }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, "node_modules/serialize-javascript": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz", @@ -4637,6 +4730,14 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "dev": true }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", diff --git a/package.json b/package.json index 5d8bba5..bb08a44 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "crypto-random-string": "^5.0.0", "dotenv": "^16.0.2", "kafkajs": "^2.2.3", + "mysql2": "^3.9.2", "node-sql-parser": "^4.6.1", "pg": "^8.11.0" }, diff --git a/src/dataGenerator.ts b/src/dataGenerator.ts index 8d91edc..7eb8bd7 100644 --- a/src/dataGenerator.ts +++ b/src/dataGenerator.ts @@ -1,6 +1,7 @@ import alert from 'cli-alerts'; -import postgresDataGenerator from './postgresDataGenerator.js'; +import mysqlDataGenerator from './mysqlDataGenerator.js'; import kafkaDataGenerator from './kafkaDataGenerator.js'; +import postgresDataGenerator from './postgresDataGenerator.js'; import webhookDataGenerator from './webhookDataGenerator.js'; interface GeneratorOptions { @@ -30,6 +31,18 @@ export default async function dataGenerator({ await postgresDataGenerator({ schema, iterations, initialSchema }); break; + case 'mysql': + if (!initialSchema.endsWith('.sql')) { + alert({ + type: `error`, + name: `Producing SQL data is only supported with SQL schema files!`, + msg: `` + }); + process.exit(1); + } + + await mysqlDataGenerator({ schema, iterations, initialSchema }); + break; case 'webhook': await webhookDataGenerator({ schema, iterations, initialSchema }); break; diff --git a/src/mysql/createTablesMySQL.ts b/src/mysql/createTablesMySQL.ts new file mode 100644 index 0000000..3575231 --- /dev/null +++ b/src/mysql/createTablesMySQL.ts @@ -0,0 +1,88 @@ +import fs from 'fs'; +import alert from 'cli-alerts'; + +export default async function createTablesMySQL(schema: any, initialSchemaPath: string, connection): Promise { + let allTablesExist = true; + + for (const tableSchema of schema) { + const topicParts = tableSchema._meta.topic.split('.'); + const tableName = topicParts.pop(); + const schemaName = topicParts.join('.'); + + if (schemaName) { + try { + await connection.query(`CREATE DATABASE IF NOT EXISTS ${schemaName};`); + await connection.changeUser({database: schemaName}); + alert({ + type: `success`, + name: `Created or confirmed existence of database ${schemaName}!`, + msg: `` + }); + } catch (error) { + alert({ + type: `error`, + name: `Error creating MySQL database...`, + msg: `\n ${error.message}` + }); + } + } + + try { + const [rows] = await connection.query(`SHOW TABLES LIKE '${tableName}';`); + if (rows.length) { + alert({ + type: `info`, + name: `Table ${tableName} already exists in database ${schemaName}.`, + msg: `Skipping table creation.` + }); + } else { + allTablesExist = false; + } + } catch (error) { + alert({ + type: `error`, + name: `Error checking if table ${tableName} exists...`, + msg: `\n ${error.message}` + }); + } + } + + if (!allTablesExist) { + try { + alert({ + type: `info`, + name: `Running the ${initialSchemaPath} SQL file...`, + msg: `` + }); + const initialSchema = fs.readFileSync(initialSchemaPath, 'utf-8'); + const queries = initialSchema.split(';').map(query => query.trim()).filter(query => query); + + for (const query of queries) { + if (!query) continue; // Skip empty queries + + try { + await connection.query(query); + console.log(`Executed: ${query}`); + } catch (error) { + alert({ + type: `error`, + name: `Error executing MySQL query:`, + msg: `\n ${error.message}\nQuery: ${query}` + }); + } + } + + alert({ + type: `success`, + name: `Created tables in MySQL!`, + msg: `` + }); + } catch (error) { + alert({ + type: `error`, + name: `Error creating MySQL tables...`, + msg: `\n ${error.message}` + }); + } + } +} diff --git a/src/mysql/mysqlConfig.ts b/src/mysql/mysqlConfig.ts new file mode 100644 index 0000000..c5a853e --- /dev/null +++ b/src/mysql/mysqlConfig.ts @@ -0,0 +1,27 @@ +import mysql from 'mysql2/promise'; +import { Env } from '../utils/env.js'; + +export default async function mysqlConfig() { + const mysqlHost = Env.optional("MYSQL_HOST", "localhost"); + const mysqlPort = Env.optional("MYSQL_PORT", "3306"); + const mysqlUser = Env.optional("MYSQL_USER", "root"); + const mysqlPassword = Env.optional("MYSQL_PASSWORD", ""); + const mysqlDatabase = Env.optional("MYSQL_DB", "test"); + + // Create a connection to the MySQL database + try { + const connection = await mysql.createConnection({ + host: mysqlHost, + port: parseInt(mysqlPort), + user: mysqlUser, + password: mysqlPassword, + database: mysqlDatabase, + }); + + // Test the connection + await connection.connect(); + return connection; + } catch (err) { + throw new Error(`Failed to connect to MySQL: ${err.message}`); + } +} diff --git a/src/mysqlDataGenerator.ts b/src/mysqlDataGenerator.ts new file mode 100644 index 0000000..2983589 --- /dev/null +++ b/src/mysqlDataGenerator.ts @@ -0,0 +1,88 @@ +import alert from 'cli-alerts'; +import { generateMegaRecord } from './schemas/generateMegaRecord.js'; +import mysqlConfig from './mysql/mysqlConfig.js'; +import createTablesMySQL from './mysql/createTablesMySQL.js'; +import sleep from './utils/sleep.js'; +import asyncGenerator from './utils/asyncGenerator.js'; + +export default async function mysqlDataGenerator({ + schema, + iterations, + initialSchema +}: { + schema: string; + iterations: number; + initialSchema: string; +}): Promise { + // Database client setup + let connection = null; + if (global.dryRun) { + alert({ + type: `info`, + name: `Debug mode: skipping database connection...`, + msg: `` + }); + } else { + connection = await mysqlConfig(); + } + + for await (const iteration of asyncGenerator(iterations)) { + global.iterationIndex = iteration; + const megaRecord = await generateMegaRecord(schema); + + if (iteration === 0) { + if (global.debug && global.dryRun) { + alert({ + type: `success`, + name: `Dry run: Skipping table creation...`, + msg: `` + }); + } else { + alert({ + type: `info`, + name: `Creating tables...`, + msg: `` + }); + connection && (await createTablesMySQL(schema, initialSchema, connection)); + } + } + + for (const table in megaRecord) { + for await (const record of megaRecord[table].records) { + console.log( + `\n Table: ${table} \n Record: ${JSON.stringify(record)}` + ); + + let key = null; + if (record[megaRecord[table].key]) { + key = record[megaRecord[table].key]; + } + + if (global.dryRun) { + alert({ + type: `success`, + name: `Dry run: Skipping record production...`, + msg: `\n Table: ${table} \n Record key: ${key} \n Payload: ${JSON.stringify( + record + )}` + }); + } + + if (!global.dryRun) { + try { + const values = Object.values(record); + const placeholders = values.map(() => '?').join(', '); + const query = `INSERT INTO ${table} VALUES (${placeholders})`; + connection && (await connection.execute(query, values)); + } catch (err) { + console.error(err); + } + } + } + } + + await sleep(global.wait); + } + + connection && (await connection.end()); +} diff --git a/src/postgresDataGenerator.ts b/src/postgresDataGenerator.ts index 32485cb..ae452f7 100644 --- a/src/postgresDataGenerator.ts +++ b/src/postgresDataGenerator.ts @@ -2,7 +2,6 @@ import alert from 'cli-alerts'; import crypto from 'crypto'; import * as pg from 'pg'; import { generateMegaRecord } from './schemas/generateMegaRecord.js'; -import { OutputFormat } from './formats/outputFormat.js'; import sleep from './utils/sleep.js'; import asyncGenerator from './utils/asyncGenerator.js'; import postgresConfig from './postgres/postgresConfig.js'; diff --git a/src/schemas/parseSqlSchema.ts b/src/schemas/parseSqlSchema.ts index b631b44..c7f47c8 100644 --- a/src/schemas/parseSqlSchema.ts +++ b/src/schemas/parseSqlSchema.ts @@ -77,6 +77,11 @@ export async function convertSqlSchemaToJson(tables: any[]) { schema[column.column.column] = 'faker.datatype.string()'; break; case 'timestamp': + // If MySQL, use the MySQL iso date format + if (global.format === 'mysql') { + schema[column.column.column] = 'faker.date.past().toISOString().slice(0, 19).replace("T", " ")'; + break; + } schema[column.column.column] = 'faker.datatype.datetime()'; break; default: diff --git a/tests/datagen.test.ts b/tests/datagen.test.ts index 7be283d..e989bb3 100644 --- a/tests/datagen.test.ts +++ b/tests/datagen.test.ts @@ -110,6 +110,22 @@ describe('Test sql output', () => { expect(output).toContain('Dry run: Skipping record production...'); expect(output).toContain('Stopping the data generator'); }); + test('should produce sql output with mysql format', () => { + const schema = './tests/products.sql'; + const output = datagen(`-s ${schema} -n 2 -f mysql -dr`); + expect(output).toContain('Parsing schema...'); + expect(output).toContain('Dry run: Skipping record production...'); + expect(output).toContain('Stopping the data generator'); + }); + test('should throw error if mysql output is used with avro schema', () => { + const schema = './tests/schema.avsc'; + try { + const output = datagen(`-s ${schema} -n 2 -f mysql`); + } catch (error) { + expect(error.stdout.toString()).toContain(`Producing SQL data is only supported with SQL schema files!`); + expect(error.status).toBe(1); + } + }); }); describe('Test Webhook output', () => { diff --git a/tests/mysql-products.sql b/tests/mysql-products.sql new file mode 100644 index 0000000..8617b1d --- /dev/null +++ b/tests/mysql-products.sql @@ -0,0 +1,8 @@ +CREATE TABLE `ecommerce`.`products` ( + `id` int PRIMARY KEY, + `name` varchar(255) COMMENT 'faker.internet.userName()', + `merchant_id` int NOT NULL COMMENT 'faker.datatype.number()', + `price` int COMMENT 'faker.datatype.number({ min: 1000, max: 100000 })', + `status` int COMMENT 'faker.datatype.number({ min: 0, max: 1 })', + `created_at` timestamp DEFAULT CURRENT_TIMESTAMP +); diff --git a/tests/mysql-schema.sql b/tests/mysql-schema.sql new file mode 100644 index 0000000..94f4099 --- /dev/null +++ b/tests/mysql-schema.sql @@ -0,0 +1,62 @@ +CREATE TABLE `ecommerce`.`merchants` ( + `id` int, + `country_code` int, + `merchant_name` varchar(255), + `created_at` varchar(255), + `admin_id` int, + PRIMARY KEY (`id`, `country_code`) +); + +CREATE TABLE `ecommerce`.`order_items` ( + `order_id` int, + `product_id` int, + `quantity` int DEFAULT 1 +); + +CREATE TABLE `ecommerce`.`orders` ( + `id` int PRIMARY KEY, + `user_id` int NOT NULL UNIQUE, + `status` varchar(255), + `created_at` varchar(255) +); + +CREATE TABLE `ecommerce`.`products` ( + `id` int PRIMARY KEY, + `name` varchar(255), + `merchant_id` int NOT NULL, + `price` int, + `status` int, + `created_at` timestamp DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE `ecommerce`.`product_tags` ( + `id` int PRIMARY KEY, + `name` varchar(255) +); + +CREATE TABLE `ecommerce`.`merchant_periods` ( + `id` int PRIMARY KEY, + `merchant_id` int, + `country_code` int, + `start_date` timestamp, + `end_date` timestamp +); + +CREATE TABLE `users` ( + `id` INT, + `full_name` varchar(255), + `created_at` timestamp, + `country_code` int +); + +CREATE TABLE `countries` ( + `code` int PRIMARY KEY, + `name` varchar(255), + `continent_name` varchar(255) +); + +CREATE TABLE `ecommerce`.`product_tags_products` ( + `product_tags_id` int, + `products_id` int, + PRIMARY KEY (`product_tags_id`, `products_id`) +);