diff --git a/.changeset/sharp-donuts-push.md b/.changeset/sharp-donuts-push.md new file mode 100644 index 000000000..fc740cb24 --- /dev/null +++ b/.changeset/sharp-donuts-push.md @@ -0,0 +1,5 @@ +--- +'@powersync/common': minor +--- + +Merge `Table` and `TableV2` but kept `TableV2` to avoid making this a breaking change. diff --git a/.prettierignore b/.prettierignore index b992e6cb7..325716636 100644 --- a/.prettierignore +++ b/.prettierignore @@ -11,4 +11,6 @@ **/android/** **/assets/** **/bin/** -**/ios/** \ No newline at end of file +**/ios/** + +pnpm-lock.yaml diff --git a/demos/example-vite/src/index.js b/demos/example-vite/src/index.js index 3bb7fddc2..b1aa743de 100644 --- a/demos/example-vite/src/index.js +++ b/demos/example-vite/src/index.js @@ -19,9 +19,9 @@ class DummyConnector { async uploadData(database) {} } -export const AppSchema = new Schema([ - new Table({ name: 'customers', columns: [new Column({ name: 'name', type: ColumnType.TEXT })] }) -]); +const customers = new Table({ name: column.text }) + +export const AppSchema = new Schema({ customers }); let PowerSync; diff --git a/demos/example-webpack/src/index.js b/demos/example-webpack/src/index.js index 3bb7fddc2..56a673d40 100644 --- a/demos/example-webpack/src/index.js +++ b/demos/example-webpack/src/index.js @@ -1,4 +1,4 @@ -import { Column, ColumnType, Schema, Table, PowerSyncDatabase } from '@powersync/web'; +import { Column, ColumnType, Schema, Table, PowerSyncDatabase, column } from '@powersync/web'; import Logger from 'js-logger'; Logger.useDefaults(); @@ -19,9 +19,9 @@ class DummyConnector { async uploadData(database) {} } -export const AppSchema = new Schema([ - new Table({ name: 'customers', columns: [new Column({ name: 'name', type: ColumnType.TEXT })] }) -]); +const customers = new Table({ name: column.text }) + +export const AppSchema = new Schema({ customers }); let PowerSync; diff --git a/demos/react-multi-client/.gitignore b/demos/react-multi-client/.gitignore index d857f4f02..7d8c0c88a 100644 --- a/demos/react-multi-client/.gitignore +++ b/demos/react-multi-client/.gitignore @@ -35,4 +35,4 @@ yarn-error.log* .vercel # typescript -*.tsbuildinfo \ No newline at end of file +*.tsbuildinfo diff --git a/demos/react-supabase-todolist/src/library/powersync/AppSchema.ts b/demos/react-supabase-todolist/src/library/powersync/AppSchema.ts index f84b60595..b2d12ebae 100644 --- a/demos/react-supabase-todolist/src/library/powersync/AppSchema.ts +++ b/demos/react-supabase-todolist/src/library/powersync/AppSchema.ts @@ -1,9 +1,9 @@ -import { column, Schema, TableV2 } from '@powersync/web'; +import { column, Schema, Table } from '@powersync/web'; export const LISTS_TABLE = 'lists'; export const TODOS_TABLE = 'todos'; -const todos = new TableV2( +const todos = new Table( { list_id: column.text, created_at: column.text, @@ -16,7 +16,7 @@ const todos = new TableV2( { indexes: { list: ['list_id'] } } ); -const lists = new TableV2({ +const lists = new Table({ created_at: column.text, name: column.text, owner_id: column.text diff --git a/packages/common/src/db/Column.ts b/packages/common/src/db/Column.ts deleted file mode 100644 index 0cd49bc68..000000000 --- a/packages/common/src/db/Column.ts +++ /dev/null @@ -1,30 +0,0 @@ -// https://www.sqlite.org/lang_expr.html#castexpr -export enum ColumnType { - TEXT = 'TEXT', - INTEGER = 'INTEGER', - REAL = 'REAL' -} - -export interface ColumnOptions { - name: string; - type?: ColumnType; -} - -export class Column { - constructor(protected options: ColumnOptions) {} - - get name() { - return this.options.name; - } - - get type() { - return this.options.type; - } - - toJSON() { - return { - name: this.name, - type: this.type - }; - } -} diff --git a/packages/common/src/db/schema/Column.ts b/packages/common/src/db/schema/Column.ts new file mode 100644 index 000000000..b3ce98609 --- /dev/null +++ b/packages/common/src/db/schema/Column.ts @@ -0,0 +1,60 @@ +// https://www.sqlite.org/lang_expr.html#castexpr +export enum ColumnType { + TEXT = 'TEXT', + INTEGER = 'INTEGER', + REAL = 'REAL' +} + +export interface ColumnOptions { + name: string; + type?: ColumnType; +} + +export type BaseColumnType = { + type: ColumnType; +}; + +export type ColumnsType = Record>; + +export type ExtractColumnValueType> = T extends BaseColumnType ? R : unknown; + +const text: BaseColumnType = { + type: ColumnType.TEXT +}; + +const integer: BaseColumnType = { + type: ColumnType.INTEGER +}; + +const real: BaseColumnType = { + type: ColumnType.REAL +}; + +// There is maximum of 127 arguments for any function in SQLite. Currently we use json_object which uses 1 arg per key (column name) +// and one per value, which limits it to 63 arguments. +export const MAX_AMOUNT_OF_COLUMNS = 63; + +export const column = { + text, + integer, + real +}; + +export class Column { + constructor(protected options: ColumnOptions) {} + + get name() { + return this.options.name; + } + + get type() { + return this.options.type; + } + + toJSON() { + return { + name: this.name, + type: this.type + }; + } +} diff --git a/packages/common/src/db/schema/IndexedColumn.ts b/packages/common/src/db/schema/IndexedColumn.ts index 32c1fb408..8b92ee43d 100644 --- a/packages/common/src/db/schema/IndexedColumn.ts +++ b/packages/common/src/db/schema/IndexedColumn.ts @@ -1,4 +1,4 @@ -import { ColumnType } from '../Column'; +import { ColumnType } from './Column'; import { Table } from './Table'; export interface IndexColumnOptions { diff --git a/packages/common/src/db/schema/Schema.ts b/packages/common/src/db/schema/Schema.ts index eed148554..42fc4f930 100644 --- a/packages/common/src/db/schema/Schema.ts +++ b/packages/common/src/db/schema/Schema.ts @@ -1,7 +1,6 @@ -import { Table as ClassicTable } from './Table'; -import { RowType, TableV2 } from './TableV2'; +import { RowType, Table } from './Table'; -type SchemaType = Record>; +type SchemaType = Record>; type SchemaTableType = { [K in keyof S]: RowType; @@ -16,9 +15,9 @@ export class Schema { */ readonly types: SchemaTableType; readonly props: S; - readonly tables: ClassicTable[]; + readonly tables: Table[]; - constructor(tables: ClassicTable[] | S) { + constructor(tables: Table[] | S) { if (Array.isArray(tables)) { this.tables = tables; } else { @@ -28,20 +27,29 @@ export class Schema { } validate() { - for (const table of this.tables as ClassicTable[]) { + for (const table of this.tables) { table.validate(); } } toJSON() { return { - tables: (this.tables as ClassicTable[]).map((t) => t.toJSON()) + // This is required because "name" field is not present in TableV2 + tables: this.tables.map((t) => t.toJSON()) }; } private convertToClassicTables(props: S) { return Object.entries(props).map(([name, table]) => { - return ClassicTable.createTable(name, table); + const convertedTable = new Table({ + name, + columns: table.columns, + indexes: table.indexes, + localOnly: table.localOnly, + insertOnly: table.insertOnly, + viewName: table.viewNameOverride || name + }); + return convertedTable; }); } } diff --git a/packages/common/src/db/schema/Table.ts b/packages/common/src/db/schema/Table.ts index 7059bb4c9..191f17c42 100644 --- a/packages/common/src/db/schema/Table.ts +++ b/packages/common/src/db/schema/Table.ts @@ -1,5 +1,13 @@ -import { Column } from '../Column'; -import type { Index } from './Index'; +import { + BaseColumnType, + Column, + ColumnsType, + ColumnType, + ExtractColumnValueType, + MAX_AMOUNT_OF_COLUMNS +} from './Column'; +import { Index } from './Index'; +import { IndexedColumn } from './IndexedColumn'; import { TableV2 } from './TableV2'; export interface TableOptions { @@ -14,19 +22,34 @@ export interface TableOptions { viewName?: string; } -export const DEFAULT_TABLE_OPTIONS: Partial = { +export type RowType> = { + [K in keyof T['columnMap']]: ExtractColumnValueType; +} & { + id: string; +}; + +export type IndexShorthand = Record; + +export interface TableV2Options { + indexes?: IndexShorthand; + localOnly?: boolean; + insertOnly?: boolean; + viewName?: string; +} + +export const DEFAULT_TABLE_OPTIONS = { indexes: [], insertOnly: false, localOnly: false }; -const MAX_AMOUNT_OF_COLUMNS = 63 - export const InvalidSQLCharacters = /["'%,.#\s[\]]/; -export class Table { +export class Table { protected options: TableOptions; + protected _mappedColumns: Columns; + static createLocalOnly(options: TableOptions) { return new Table({ ...options, localOnly: true, insertOnly: false }); } @@ -35,10 +58,17 @@ export class Table { return new Table({ ...options, localOnly: false, insertOnly: true }); } - static createTable(name: string, table: TableV2) { + /** + * Create a table. + * @deprecated This was only only included for TableV2 and is no longer necessary. + * Prefer to use new Table() directly. + * + * TODO remove in the next major release. + */ + static createTable(name: string, table: Table) { return new Table({ name, - columns: Object.entries(table.columns).map(([name, col]) => new Column({ name, type: col.type })), + columns: table.columns, indexes: table.indexes, localOnly: table.options.localOnly, insertOnly: table.options.insertOnly, @@ -46,8 +76,109 @@ export class Table { }); } - constructor(options: TableOptions) { - this.options = { ...DEFAULT_TABLE_OPTIONS, ...options }; + /** + * Creates a new Table instance. + * + * This constructor supports two different versions: + * 1. New constructor: Using a Columns object and an optional TableV2Options object + * 2. Deprecated constructor: Using a TableOptions object (will be removed in the next major release) + * + * @constructor + * @param {Columns | TableOptions} optionsOrColumns - Either a Columns object (for V2 syntax) or a TableOptions object (for V1 syntax) + * @param {TableV2Options} [v2Options] - Optional configuration options for V2 syntax + * + * @example + * New constructor example + * ```javascript + * const table = new Table( + * { + * name: column.text, + * age: column.integer + * }, + * { indexes: { nameIndex: ['name'] } } + * ); + *``` + * + * + * @example + * Deprecated constructor example + * ```javascript + * const table = new Table({ + * name: 'users', + * columns: [ + * new Column({ name: 'name', type: ColumnType.TEXT }), + * new Column({ name: 'age', type: ColumnType.INTEGER }) + * ] + * }); + *``` + */ + constructor(columns: Columns, options?: TableV2Options); + /** + * @deprecated This constructor will be removed in the next major release. + * Use the new constructor shown below instead as this does not show types. + * @example + * Use this instead + * ```javascript + * const table = new Table( + * { + * name: column.text, + * age: column.integer + * }, + * { indexes: { nameIndex: ['name'] } } + * ); + *``` + */ + constructor(options: TableOptions); + constructor(optionsOrColumns: Columns | TableOptions, v2Options?: TableV2Options) { + if (this.isTableV1(optionsOrColumns)) { + this.initTableV1(optionsOrColumns); + } else { + this.initTableV2(optionsOrColumns, v2Options); + } + } + + private isTableV1(arg: TableOptions | Columns): arg is TableOptions { + return 'columns' in arg && Array.isArray(arg.columns); + } + + private initTableV1(options: TableOptions) { + this.options = { + ...options, + indexes: options.indexes || [], + insertOnly: options.insertOnly ?? DEFAULT_TABLE_OPTIONS.insertOnly, + localOnly: options.localOnly ?? DEFAULT_TABLE_OPTIONS.localOnly + }; + } + + private initTableV2(columns: Columns, options?: TableV2Options) { + const convertedColumns = Object.entries(columns).map( + ([name, columnInfo]) => new Column({ name, type: columnInfo.type }) + ); + + const convertedIndexes = Object.entries(options?.indexes ?? {}).map( + ([name, columnNames]) => + new Index({ + name, + columns: columnNames.map( + (name) => + new IndexedColumn({ + name: name.replace(/^-/, ''), + ascending: !name.startsWith('-') + }) + ) + }) + ); + + this.options = { + name: '', + columns: convertedColumns, + indexes: convertedIndexes, + insertOnly: options?.insertOnly ?? DEFAULT_TABLE_OPTIONS.insertOnly, + localOnly: options?.localOnly ?? DEFAULT_TABLE_OPTIONS.localOnly, + viewName: options?.viewName + }; + + this._mappedColumns = columns; } get name() { @@ -66,6 +197,16 @@ export class Table { return this.options.columns; } + get columnMap(): Columns { + return ( + this._mappedColumns ?? + this.columns.reduce((hash: Record>, column) => { + hash[column.name] = { type: column.type ?? ColumnType.TEXT }; + return hash; + }, {} as Columns) + ); + } + get indexes() { return this.options.indexes ?? []; } @@ -105,8 +246,10 @@ export class Table { throw new Error(`Invalid characters in view name: ${this.viewNameOverride}`); } - if(this.columns.length > MAX_AMOUNT_OF_COLUMNS) { - throw new Error(`Table ${this.name} has too many columns. The maximum number of columns is ${MAX_AMOUNT_OF_COLUMNS}.`); + if (this.columns.length > MAX_AMOUNT_OF_COLUMNS) { + throw new Error( + `Table has too many columns. The maximum number of columns is ${MAX_AMOUNT_OF_COLUMNS}.` + ); } const columnNames = new Set(); @@ -114,25 +257,24 @@ export class Table { for (const column of this.columns) { const { name: columnName } = column; if (column.name === 'id') { - throw new Error(`${this.name}: id column is automatically added, custom id columns are not supported`); + throw new Error(`An id column is automatically added, custom id columns are not supported`); } if (columnNames.has(columnName)) { throw new Error(`Duplicate column ${columnName}`); } if (InvalidSQLCharacters.test(columnName)) { - throw new Error(`Invalid characters in column name: $name.${column}`); + throw new Error(`Invalid characters in column name: ${column.name}`); } columnNames.add(columnName); } const indexNames = new Set(); - for (const index of this.indexes) { if (indexNames.has(index.name)) { - throw new Error(`Duplicate index $name.${index}`); + throw new Error(`Duplicate index ${index.name}`); } if (InvalidSQLCharacters.test(index.name)) { - throw new Error(`Invalid characters in index name: $name.${index}`); + throw new Error(`Invalid characters in index name: ${index.name}`); } for (const column of index.columns) { diff --git a/packages/common/src/db/schema/TableV2.ts b/packages/common/src/db/schema/TableV2.ts index 7c61672dd..47e81c0f1 100644 --- a/packages/common/src/db/schema/TableV2.ts +++ b/packages/common/src/db/schema/TableV2.ts @@ -1,99 +1,9 @@ -import { ColumnType } from '../Column'; -import { Index } from './Index'; -import { IndexedColumn } from './IndexedColumn'; -import { InvalidSQLCharacters } from './Table'; +import { ColumnsType } from './Column'; +import { Table } from './Table'; -export type BaseColumnType = { - type: ColumnType; -}; - -const text: BaseColumnType = { - type: ColumnType.TEXT -}; - -const integer: BaseColumnType = { - type: ColumnType.INTEGER -}; - -const real: BaseColumnType = { - type: ColumnType.REAL -}; - -// There is maximum of 127 arguments for any function in SQLite. Currently we use json_object which uses 1 arg per key (column name) -// and one per value, which limits it to 63 arguments. -const MAX_AMOUNT_OF_COLUMNS = 63; - -export const column = { - text, - integer, - real -}; - -export type ColumnsType = Record>; - -export type ExtractColumnValueType> = T extends BaseColumnType ? R : unknown; - -export type RowType> = { - [K in keyof T['columns']]: ExtractColumnValueType; -} & { - id: string; -}; - -export type IndexShorthand = Record; - -export interface TableV2Options { - indexes?: IndexShorthand; - localOnly?: boolean; - insertOnly?: boolean; - viewName?: string; -} - -/* +/** Generate a new table from the columns and indexes + @deprecated You should use {@link Table} instead as it now allows TableV2 syntax. + This will be removed in the next major release. */ -export class TableV2 { - public indexes: Index[]; - - constructor( - public columns: Columns, - public options: TableV2Options = {} - ) { - this.validateTable(columns); - - if (options?.indexes) { - this.indexes = Object.entries(options.indexes).map(([name, columns]) => { - if (name.startsWith('-')) { - return new Index({ - name: name.substring(1), - columns: columns.map((c) => new IndexedColumn({ name: c, ascending: false })) - }); - } - - return new Index({ - name: name, - columns: columns.map((c) => new IndexedColumn({ name: c, ascending: true })) - }); - }); - } - } - - private validateTable(columns: Columns) { - const columnNames = Object.keys(columns); - const columnLength = columnNames.length; - - if (columnNames.includes('id')) { - throw new Error(`An id column is automatically added, custom id columns are not supported`); - } - - if (columnLength > MAX_AMOUNT_OF_COLUMNS) { - throw new Error(`TableV2 cannot have more than ${MAX_AMOUNT_OF_COLUMNS} columns`); - } - - columnNames - .map((column) => { - if (InvalidSQLCharacters.test(column)) { - throw new Error(`Invalid characters in column name: ${column}`); - } - }) - } -} +export class TableV2 extends Table {} diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 4e55be2f5..b2bdb8ee5 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -23,11 +23,11 @@ export * from './db/schema/Schema'; export * from './db/schema/Table'; export * from './db/schema/Index'; export * from './db/schema/IndexedColumn'; +export * from './db/schema/Column'; +export * from './db/schema/TableV2'; export * from './db/crud/SyncStatus'; export * from './db/crud/UploadQueueStatus'; export * from './db/DBAdapter'; -export * from './db/Column'; -export * from './db/schema/TableV2'; export * from './utils/AbortOperation'; export * from './utils/BaseObserver'; diff --git a/packages/common/tests/db/schema/Schema.test.ts b/packages/common/tests/db/schema/Schema.test.ts new file mode 100644 index 000000000..031dc80a8 --- /dev/null +++ b/packages/common/tests/db/schema/Schema.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest'; +import { Schema } from '../../../src/db/schema/Schema'; +import { Table } from '../../../src/db/schema/Table'; +import { column, ColumnType } from '../../../src/db/schema/Column'; + +describe('Schema', () => { + it('should create a schema with an array of tables', () => { + const tables = [ + new Table({ name: column.text, }), + new Table({ age: { type: ColumnType.INTEGER } }) + ]; + const schema = new Schema(tables); + + expect(schema.tables).toHaveLength(2); + expect(schema.tables[0].columns[0].name).toBe('name'); + expect(schema.tables[1].columns[0].name).toBe('age'); + }); + + it('should create a schema with a SchemaType object', () => { + const schemaDefinition = { + users: new Table({ + name: column.text, + age: { type: ColumnType.INTEGER } + }), + posts: new Table({ + title: column.text, + content: column.text, + }) + }; + const schema = new Schema(schemaDefinition); + + expect(schema.tables).toHaveLength(2); + expect(schema.props).toBeDefined(); + expect(schema.props.users).toBeDefined(); + expect(schema.props.posts).toBeDefined(); + }); + + it('should validate all tables in the schema', () => { + const schema = new Schema({ + users: new Table({ + name: column.text, + age: column.integer + }), + posts: new Table({ + title: column.text, + content: column.text + }) + }); + + expect(() => schema.validate()).not.toThrow(); + + const invalidSchema = new Schema({ + invalidTable: new Table({ + 'invalid name': column.text, + }) + }); + + expect(() => invalidSchema.validate()).toThrow(); + }); + + it('should generate correct JSON representation', () => { + const schema = new Schema({ + users: new Table({ + name: column.text, + age: { type: ColumnType.INTEGER } + }), + posts: new Table({ + title: column.text, + content: column.text, + }) + }); + + const json = schema.toJSON(); + + expect(json).toEqual({ + tables: [ + { + name: 'users', + view_name: 'users', + local_only: false, + insert_only: false, + columns: [ + { name: 'name', type: 'TEXT' }, + { name: 'age', type: 'INTEGER' } + ], + indexes: [] + }, + { + name: 'posts', + view_name: 'posts', + local_only: false, + insert_only: false, + columns: [ + { name: 'title', type: 'TEXT' }, + { name: 'content', type: 'TEXT' } + ], + indexes: [] + } + ] + }); + }); +}); diff --git a/packages/common/tests/db/schema/Table.test.ts b/packages/common/tests/db/schema/Table.test.ts new file mode 100644 index 000000000..2a4b61053 --- /dev/null +++ b/packages/common/tests/db/schema/Table.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect } from 'vitest'; +import { Table } from '../../../src/db/schema/Table'; +import { column, Column, ColumnType } from '../../../src/db/schema/Column'; +import { Index } from '../../../src/db/schema/Index'; +import { IndexedColumn } from '../../../src/db/schema/IndexedColumn'; + +describe('Table', () => { + it('should create a table with V1 syntax', () => { + const table = new Table({ + name: 'users', + columns: [ + new Column({ name: 'name', type: ColumnType.TEXT }), + new Column({ name: 'age', type: ColumnType.INTEGER }) + ], + indexes: [ + new Index({ + name: 'profile_id', + columns: [new IndexedColumn({ name: 'age' })] + }) + ] + }); + + expect(table.name).toBe('users'); + expect(table.columns.length).toBe(2); + expect(table.columns[0].name).toBe('name'); + expect(table.columns[1].name).toBe('age'); + expect(table.indexes[0].name).toBe('profile_id'); + }); + + it('should create a table with V2 syntax', () => { + const table = new Table( + { + name: column.text, + age: column.integer + }, + { indexes: { nameIndex: ['name'] } } + ); + + expect(table.columns.length).toBe(2); + expect(table.columns[0].name).toBe('name'); + expect(table.columns[1].name).toBe('age'); + expect(table.indexes.length).toBe(1); + expect(table.indexes[0].name).toBe('nameIndex'); + }); + + it('should create a local-only table', () => { + const table = new Table( + { + data: column.text + }, + { localOnly: true } + ); + + expect(table.localOnly).toBe(true); + expect(table.insertOnly).toBe(false); + }); + + it('should create an insert-only table', () => { + const table = new Table( + { + data: column.text + }, + { insertOnly: true } + ); + + expect(table.localOnly).toBe(false); + expect(table.insertOnly).toBe(true); + }); + + it('should create correct internal name', () => { + const normalTable = new Table({ + data: column.text + }); + + expect(normalTable.internalName).toBe('ps_data__'); + + const localTable = new Table( + { + data: column.text + }, + { localOnly: true } + ); + + expect(localTable.internalName).toBe('ps_data_local__'); + }); + + it('should generate correct JSON representation', () => { + const table = new Table( + { + name: column.text, + age: column.integer + }, + { + indexes: { nameIndex: ['name'] }, + viewName: 'customView' + } + ); + + const json = table.toJSON(); + + expect(json).toEqual({ + name: '', + view_name: 'customView', + local_only: false, + insert_only: false, + columns: [ + { name: 'name', type: 'TEXT' }, + { name: 'age', type: 'INTEGER' } + ], + indexes: [{ name: 'nameIndex', columns: [{ ascending: true, name: 'name', type: 'TEXT' },] }] + }); + }); + + it('should handle descending index', () => { + const table = new Table( + { + name: column.text, + age: column.integer + }, + { + indexes: { ageIndex: ['-age'] } + } + ); + + expect(table.indexes[0].columns[0].name).toBe('age'); + expect(table.indexes[0].columns[0].ascending).toBe(false); + }); + + describe("validate", () => { + it('should throw an error for invalid view names', () => { + expect(() => { + new Table( + { + data: column.text + }, + { viewName: 'invalid view' } + ).validate(); + }).toThrowError('Invalid characters in view name'); + }); + + it('should throw an error for custom id columns', () => { + expect(() => { + new Table({ + id: column.text + }).validate(); + }).toThrow('id column is automatically added, custom id columns are not supported'); + }); + + it('should throw an error if more than 63 columns are provided', () => { + const columns = {}; + for (let i = 0; i < 64; i++) { + columns[`column${i}`] = column.text; + } + + expect(() => new Table(columns).validate()).toThrowError('Table has too many columns. The maximum number of columns is 63.'); + }); + + it('should throw an error if an id column is provided', () => { + expect( + () => + new Table({ + id: column.text, + name: column.text + }).validate() + ).toThrowError('An id column is automatically added, custom id columns are not supported'); + }); + + it('should throw an error if a column name contains invalid SQL characters', () => { + expect( + () => + new Table({ + '#invalid-name': column.text + }).validate() + ).toThrowError('Invalid characters in column name: #invalid-name'); + }); + }); +}); diff --git a/packages/common/tests/db/schema/TableV2.test.ts b/packages/common/tests/db/schema/TableV2.test.ts index 3d708a48e..29477b092 100644 --- a/packages/common/tests/db/schema/TableV2.test.ts +++ b/packages/common/tests/db/schema/TableV2.test.ts @@ -1,86 +1,153 @@ import { describe, it, expect } from 'vitest'; -import { TableV2, column } from '../../../src/db/schema/TableV2'; // Adjust the import path as needed -import { ColumnType } from '../../../src/db/Column'; +import { TableV2 } from '../../../src/db/schema/TableV2'; +import { column, Column, ColumnType } from '../../../src/db/schema/Column'; describe('TableV2', () => { - it('should create a table with valid columns', () => { - const table = new TableV2({ - name: column.text, - age: column.integer, - height: column.real - }); + it('should create a table', () => { + const table = new TableV2( + { + name: column.text, + age: column.integer + }, + { indexes: { nameIndex: ['name'] } } + ); - expect(table.columns).toEqual({ - name: { type: ColumnType.TEXT }, - age: { type: ColumnType.INTEGER }, - height: { type: ColumnType.REAL } - }); + expect(table.columns.length).toBe(2); + expect(table.columns[0].name).toBe('name'); + expect(table.columns[1].name).toBe('age'); + expect(table.indexes.length).toBe(1); + expect(table.indexes[0].name).toBe('nameIndex'); }); - it('should throw an error if more than 63 columns are provided', () => { - const columns = {}; - for (let i = 0; i < 64; i++) { - columns[`column${i}`] = column.text; - } + it('should create a local-only table', () => { + const table = new TableV2( + { + data: column.text + }, + { localOnly: true } + ); - expect(() => new TableV2(columns)).toThrowError('TableV2 cannot have more than 63 columns'); + expect(table.localOnly).toBe(true); + expect(table.insertOnly).toBe(false); }); - it('should throw an error if an id column is provided', () => { - expect(() => new TableV2({ - id: column.text, - name: column.text - })).toThrowError('An id column is automatically added, custom id columns are not supported'); + it('should create an insert-only table', () => { + const table = new TableV2( + { + data: column.text + }, + { insertOnly: true } + ); + + expect(table.localOnly).toBe(false); + expect(table.insertOnly).toBe(true); }); - it('should throw an error if a column name contains invalid SQL characters', () => { - expect(() => new TableV2({ - '#invalid-name': column.text - })).toThrowError('Invalid characters in column name: #invalid-name'); + it('should create correct internal name', () => { + const normalTable = new TableV2({ + data: column.text + }); + + expect(normalTable.internalName).toBe('ps_data__'); + + const localTable = new TableV2( + { + data: column.text + }, + { localOnly: true } + ); + + expect(localTable.internalName).toBe('ps_data_local__'); }); - it('should create indexes correctly', () => { + it('should generate correct JSON representation', () => { const table = new TableV2( { name: column.text, age: column.integer }, { - indexes: { - nameIndex: ['name'], - '-ageIndex': ['age'] - } + indexes: { nameIndex: ['name'] }, + viewName: 'customView' } ); - expect(table.indexes).toHaveLength(2); - expect(table.indexes[0].name).toBe('nameIndex'); - expect(table.indexes[0].columns[0].ascending).toBe(true); - expect(table.indexes[1].name).toBe('ageIndex'); - expect(table.indexes[1].columns[0].ascending).toBe(false); + const json = table.toJSON(); + + expect(json).toEqual({ + name: '', + view_name: 'customView', + local_only: false, + insert_only: false, + columns: [ + { name: 'name', type: 'TEXT' }, + { name: 'age', type: 'INTEGER' } + ], + indexes: [{ name: 'nameIndex', columns: [{ ascending: true, name: 'name', type: 'TEXT' },] }] + }); }); - it('should allow creating a table with exactly 63 columns', () => { - const columns = {}; - for (let i = 0; i < 63; i++) { - columns[`column${i}`] = column.text; - } + it('should handle descending index', () => { + const table = new TableV2( + { + name: column.text, + age: column.integer + }, + { + indexes: { ageIndex: ['-age'] } + } + ); - expect(() => new TableV2(columns)).not.toThrow(); + expect(table.indexes[0].columns[0].name).toBe('age'); + expect(table.indexes[0].columns[0].ascending).toBe(false); }); - it('should allow creating a table with no columns', () => { - expect(() => new TableV2({})).not.toThrow(); - }); + describe("validate", () => { + it('should throw an error for invalid view names', () => { + expect(() => { + new TableV2( + { + data: column.text + }, + { viewName: 'invalid view' } + ).validate(); + }).toThrowError('Invalid characters in view name'); + }); - it('should allow creating a table with no options', () => { - const table = new TableV2({ name: column.text }); - expect(table.options).toEqual({}); - }); + it('should throw an error for custom id columns', () => { + expect(() => { + new TableV2({ + id: column.text + }).validate(); + }).toThrow('id column is automatically added, custom id columns are not supported'); + }); + + it('should throw an error if more than 63 columns are provided', () => { + const columns = {}; + for (let i = 0; i < 64; i++) { + columns[`column${i}`] = column.text; + } - it('should correctly set options', () => { - const options = { localOnly: true, insertOnly: false, viewName: 'TestView' }; - const table = new TableV2({ name: column.text }, options); - expect(table.options).toEqual(options); + expect(() => new TableV2(columns).validate()).toThrowError('Table has too many columns. The maximum number of columns is 63.'); + }); + + it('should throw an error if an id column is provided', () => { + expect( + () => + new TableV2({ + id: column.text, + name: column.text + }).validate() + ).toThrowError('An id column is automatically added, custom id columns are not supported'); + }); + + it('should throw an error if a column name contains invalid SQL characters', () => { + expect( + () => + new TableV2({ + '#invalid-name': column.text + }).validate() + ).toThrowError('Invalid characters in column name: #invalid-name'); + }); }); }); diff --git a/packages/kysely-driver/README.md b/packages/kysely-driver/README.md index 846eb69de..82cc7ea52 100644 --- a/packages/kysely-driver/README.md +++ b/packages/kysely-driver/README.md @@ -21,7 +21,7 @@ export const powerSyncDb = new PowerSyncDatabase({ database: { dbFilename: 'test.sqlite' }, - schema: appSchema, + schema: appSchema }); export const db = wrapPowerSyncWithKysely(powerSyncDb); diff --git a/tools/diagnostics-app/README.md b/tools/diagnostics-app/README.md index 73aef4969..2a7d92169 100644 --- a/tools/diagnostics-app/README.md +++ b/tools/diagnostics-app/README.md @@ -32,7 +32,7 @@ The app is now available on [http://localhost:5173/](http://localhost:5173/). Signing in as a user requires a PowerSync Token (JWT) and Endpoint. -**PowerSync Token**: +**PowerSync Token**: Generate a [development token](https://docs.powersync.com/usage/installation/authentication-setup/development-tokens) for the user.